Add MCP retrieval resources and fix local config
This commit is contained in:
parent
2b627b0d13
commit
f3a12965d3
2 changed files with 310 additions and 30 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue