diff --git a/.codex/config.toml b/.codex/config.toml index c23c0f8..0d44998 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,7 +1,7 @@ [mcp_servers.voidbot] -command = "node" -args = ["apps/worker/dist/mcp-server.js"] -cwd = "." +command = "C:\\Program Files\\nodejs\\node.exe" +args = ["E:\\Projects\\VoidBot\\apps\\worker\\dist\\mcp-server.js"] +cwd = "E:\\Projects\\VoidBot" [mcp_servers.voidbot.env] -VOIDBOT_WORKSPACE_ROOT = "." +VOIDBOT_WORKSPACE_ROOT = "E:\\Projects\\VoidBot" diff --git a/apps/worker/src/mcp-server.ts b/apps/worker/src/mcp-server.ts index 3eecc86..46ad04f 100644 --- a/apps/worker/src/mcp-server.ts +++ b/apps/worker/src/mcp-server.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { config as loadDotenv } from "dotenv"; @@ -10,6 +10,7 @@ import { loadConfig } from "@voidbot/config"; import { DEFAULT_RETRIEVAL_RESULT_LIMIT, MAX_RETRIEVAL_RESULT_LIMIT, + type RetrievalResult, } from "@voidbot/shared"; import { createTextEmbedder, @@ -142,6 +143,227 @@ interface SourceContextArgs { after?: number; } +server.registerResource( + "indexed_repos", + "voidbot://repos/indexed", + { + title: "Indexed Repositories", + description: + "List the indexed source and lore repositories currently available to search.", + mimeType: "application/json", + }, + async (uri) => { + const repos = await sourceArchiveRepository.listRepoSummaries(); + return jsonResource(uri, { + repoCount: repos.length, + repos, + }); + }, +); + +server.registerResource( + "retrieval_guide", + "voidbot://retrieval/guide", + { + title: "Retrieval Guide", + description: + "Quick guide to the available semantic-search resource templates for history, source, and lore retrieval.", + mimeType: "application/json", + }, + async (uri) => + jsonResource(uri, { + resources: [ + { + uri: "voidbot://repos/indexed", + purpose: "List indexed source and lore repos.", + }, + ], + templates: [ + { + uriTemplate: "voidbot://history/search/{query}", + purpose: "Semantic search across archived Discord history.", + }, + { + uriTemplate: "voidbot://history/context/{messageId}", + purpose: "Fetch a surrounding conversation window around one archived message.", + }, + { + uriTemplate: "voidbot://sources/search/{query}", + purpose: "Semantic search across all indexed source trees and lore repositories.", + }, + { + uriTemplate: "voidbot://sources/repo/{repoName}/search/{query}", + purpose: "Semantic search scoped to one indexed source or lore repository.", + }, + { + uriTemplate: "voidbot://sources/context/{sourceId}/{chunkIndex}", + purpose: "Fetch a chunk window around one indexed source or lore document.", + }, + ], + }), +); + +server.registerResource( + "history_search", + new ResourceTemplate("voidbot://history/search/{query}", { list: undefined }), + { + title: "History Search", + description: + "Semantic search across archived Discord history.", + mimeType: "application/json", + }, + async (uri, variables) => { + const query = getRequiredVariable(variables, "query", "history search"); + const results = await retrievalService.searchHistory( + query, + DEFAULT_RETRIEVAL_RESULT_LIMIT, + ); + + return jsonResource(uri, { + query, + resultCount: results.length, + results: formatHistoryResults(results), + }); + }, +); + +server.registerResource( + "history_context", + new ResourceTemplate("voidbot://history/context/{messageId}", { list: undefined }), + { + title: "History Context", + description: + "Fetch the surrounding conversation window for one archived Discord message ID.", + mimeType: "application/json", + }, + async (uri, variables) => { + const messageId = getRequiredVariable(variables, "messageId", "history context"); + const anchor = await archiveRepository.get(messageId); + + if (!anchor) { + return jsonResource(uri, { + found: false, + messageId, + messages: [], + }); + } + + const messages = await archiveRepository.listContextWindow(messageId, 2, 2); + + return jsonResource(uri, { + found: true, + messageId, + count: messages.length, + messages: messages.map((message) => formatArchivedMessage(message, messageId)), + }); + }, +); + +server.registerResource( + "source_search", + new ResourceTemplate("voidbot://sources/search/{query}", { list: undefined }), + { + title: "Source Search", + description: + "Semantic search across all indexed source trees and lore repositories.", + mimeType: "application/json", + }, + async (uri, variables) => { + const query = getRequiredVariable(variables, "query", "source search"); + const results = await retrievalService.searchRepositorySources( + query, + DEFAULT_RETRIEVAL_RESULT_LIMIT, + ); + + return jsonResource(uri, { + query, + resultCount: results.length, + results: formatSourceResults(results), + }); + }, +); + +server.registerResource( + "source_search_repo", + new ResourceTemplate("voidbot://sources/repo/{repoName}/search/{query}", { list: undefined }), + { + title: "Repository-Scoped Source Search", + description: + "Semantic search across one indexed source tree or lore repository.", + mimeType: "application/json", + }, + async (uri, variables) => { + const repoName = getRequiredVariable(variables, "repoName", "repo-scoped source search"); + const query = getRequiredVariable(variables, "query", "repo-scoped source search"); + const results = await retrievalService.searchRepositorySources( + query, + DEFAULT_RETRIEVAL_RESULT_LIMIT, + { + repoName, + }, + ); + + return jsonResource(uri, { + query, + resultCount: results.length, + results: formatSourceResults(results), + }); + }, +); + +server.registerResource( + "source_context", + new ResourceTemplate("voidbot://sources/context/{sourceId}/{chunkIndex}", { list: undefined }), + { + title: "Source Context", + description: + "Fetch a surrounding chunk window from one indexed source or lore document.", + mimeType: "application/json", + }, + async (uri, variables) => { + const sourceId = getRequiredVariable(variables, "sourceId", "source context"); + const chunkIndex = parseOptionalInt(variables.chunkIndex) ?? 0; + const document = await sourceArchiveRepository.get(sourceId); + + if (!document) { + return jsonResource(uri, { + found: false, + sourceId, + chunks: [], + }); + } + + const chunks = sourceDocumentIngester.buildContextWindow( + document, + chunkIndex, + 1, + 1, + ); + const anchorIndex = + chunks.find((chunk) => chunk.chunkIndex === chunkIndex)?.chunkIndex ?? + chunks[0]?.chunkIndex ?? + 0; + const formattedChunks = chunks.map((chunk) => ({ + chunkId: chunk.chunkId, + chunkIndex: chunk.chunkIndex, + lineStart: chunk.lineStart, + lineEnd: chunk.lineEnd, + isAnchor: chunk.chunkIndex === anchorIndex, + text: chunk.text, + })); + + return jsonResource(uri, { + found: true, + sourceId, + repoName: document.repoName, + path: document.path, + language: document.language, + count: formattedChunks.length, + chunks: formattedChunks, + }); + }, +); + server.registerTool( "search_history", { @@ -163,18 +385,7 @@ server.registerTool( channelId, authorId, }); - const formattedResults = results.map((result) => ({ - score: Number(result.score.toFixed(4)), - text: result.text, - sourceId: result.sourceId, - channelId: result.metadata.channelId, - channelName: result.metadata.channelName, - authorId: result.metadata.authorId, - authorName: result.metadata.authorName, - timestamp: result.metadata.timestamp, - jumpUrl: result.metadata.jumpUrl, - threadId: result.metadata.threadId, - })); + const formattedResults = formatHistoryResults(results); return { content: [ @@ -262,19 +473,7 @@ server.registerTool( language, }, ); - const formattedResults = results.map((result) => ({ - score: Number(result.score.toFixed(4)), - text: result.text, - sourceId: result.sourceId, - repoName: result.metadata.repoName, - path: result.metadata.path, - language: result.metadata.language, - title: result.metadata.title, - chunkIndex: Number(result.metadata.chunkIndex ?? 0), - lineStart: Number(result.metadata.lineStart ?? 1), - lineEnd: Number(result.metadata.lineEnd ?? 1), - lastModifiedAt: result.metadata.lastModifiedAt, - })); + const formattedResults = formatSourceResults(results); return { content: [ @@ -529,6 +728,87 @@ function renderJsonBlock(payload: Record): string { return `${JSON.stringify(payload, null, 2)}\n`; } +function jsonResource(uri: URL, payload: Record) { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify(payload, null, 2), + }, + ], + }; +} + +function getRequiredVariable( + variables: Record, + key: string, + context: string, +): string { + const value = getOptionalVariable(variables, key); + + if (!value) { + throw new Error(`Missing required ${key} for ${context}.`); + } + + return value; +} + +function getOptionalVariable( + variables: Record, + key: string, +): string | undefined { + const value = variables[key]; + + if (Array.isArray(value)) { + return value[0]; + } + + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function parseOptionalInt(value: string | string[] | undefined): number | undefined { + const normalized = Array.isArray(value) ? value[0] : value; + + if (!normalized) { + return undefined; + } + + const parsed = Number.parseInt(normalized, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function formatHistoryResults(results: RetrievalResult[]) { + return results.map((result) => ({ + score: Number(result.score.toFixed(4)), + text: result.text, + sourceId: result.sourceId, + channelId: result.metadata.channelId, + channelName: result.metadata.channelName, + authorId: result.metadata.authorId, + authorName: result.metadata.authorName, + timestamp: result.metadata.timestamp, + jumpUrl: result.metadata.jumpUrl, + threadId: result.metadata.threadId, + })); +} + +function formatSourceResults(results: RetrievalResult[]) { + return results.map((result) => ({ + score: Number(result.score.toFixed(4)), + text: result.text, + sourceId: result.sourceId, + repoName: result.metadata.repoName, + path: result.metadata.path, + language: result.metadata.language, + title: result.metadata.title, + chunkIndex: Number(result.metadata.chunkIndex ?? 0), + lineStart: Number(result.metadata.lineStart ?? 1), + lineEnd: Number(result.metadata.lineEnd ?? 1), + lastModifiedAt: result.metadata.lastModifiedAt, + })); +} + async function openOwnerDmChannel(botToken: string, recipientId: string): Promise { const response = await fetch("https://discord.com/api/v10/users/@me/channels", { method: "POST",