Add MCP retrieval resources and fix local config

This commit is contained in:
Meta Vens 2026-04-25 08:45:52 +01:00
commit f3a12965d3
2 changed files with 310 additions and 30 deletions

View file

@ -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"

View file

@ -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, unknown>): string {
return `${JSON.stringify(payload, null, 2)}\n`;
}
function jsonResource(uri: URL, payload: Record<string, unknown>) {
return {
contents: [
{
uri: uri.toString(),
mimeType: "application/json",
text: JSON.stringify(payload, null, 2),
},
],
};
}
function getRequiredVariable(
variables: Record<string, string | string[]>,
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<string, string | string[]>,
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<string> {
const response = await fetch("https://discord.com/api/v10/users/@me/channels", {
method: "POST",