VoidBot/apps/worker/src/mcp-server-tools.ts
2026-05-18 15:04:15 +01:00

753 lines
22 KiB
TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_RETRIEVAL_RESULT_LIMIT } from "@voidbot/shared";
import { searchHistoryWithArchiveFallback } from "@voidbot/rag";
import {
applyVoidSelfStateOperation,
buildVoidSelfStateContext,
findRepoDiscordIdentity,
isRepoDiscordIdentityAllowedInChannel,
loadRepoDiscordIdentityRegistry,
loadVoidSelfStateTypedDocuments,
resolveRepoFaceStatePath,
} from "@voidbot/core";
import { type VoidbotMcpContext } from "./mcp-server-context";
import { openOwnerDmChannel, postDiscordMessage } from "./mcp-server-discord";
import {
type ListIndexedReposArgs,
type MessageContextArgs,
type NotifyOwnerArgs,
type ApplyRepoFaceStateOperationArgs,
type PostDiscordMessageArgs,
type PostRepoIdentityMessageArgs,
type RepoFaceStateArgs,
type SearchHistoryArgs,
type SearchSourcesArgs,
type SourceContextArgs,
formatArchivedMessage,
formatHistoryResults,
formatSourceResults,
applyRepoFaceStateOperationInputSchema,
messageContextInputSchema,
notifyOwnerInputSchema,
postDiscordMessageInputSchema,
postRepoIdentityMessageInputSchema,
repoFaceStateInputSchema,
renderJsonBlock,
searchHistoryInputSchema,
searchSourcesInputSchema,
sourceContextInputSchema,
} from "./mcp-server-shared";
const READ_ONLY_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
export function registerVoidbotTools(
server: McpServer,
context: VoidbotMcpContext,
): void {
server.registerTool(
"search_history",
{
title: "Search Discord History",
description:
"Search archived Discord history with semantic retrieval. Use this for historical discussion, decisions, preferences, and prior conversations.",
inputSchema: searchHistoryInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: SearchHistoryArgs): Promise<CallToolResult> => {
const { query, limit, guildId, channelId, authorId } = input;
const results = await searchHistoryWithArchiveFallback({
retrievalService: context.retrievalService,
archiveRepository: context.archiveRepository,
query,
limit: limit ?? DEFAULT_RETRIEVAL_RESULT_LIMIT,
guildId,
channelId,
authorId,
});
const formattedResults = formatHistoryResults(results);
return {
content: [
{
type: "text",
text:
formattedResults.length > 0
? renderJsonBlock({
query,
resultCount: formattedResults.length,
results: formattedResults,
})
: `No archived Discord history matched "${query}".`,
},
],
structuredContent: {
query,
resultCount: formattedResults.length,
results: formattedResults,
},
};
},
);
server.registerTool(
"list_indexed_repos",
{
title: "List Indexed Repositories",
description:
"List the indexed source and lore repositories currently available to search. Use this when you want valid repoName filters for search_sources.",
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
async (_input: ListIndexedReposArgs): Promise<CallToolResult> => {
const repos = await context.sourceArchiveRepository.listRepoSummaries();
return {
content: [
{
type: "text",
text:
repos.length > 0
? renderJsonBlock({
repoCount: repos.length,
repos,
})
: "No indexed source repositories are currently available.",
},
],
structuredContent: {
repoCount: repos.length,
repos,
},
};
},
);
server.registerTool(
"search_sources",
{
title: "Search Repository Sources",
description:
"Search indexed source trees and lore repositories with semantic retrieval. Use this for code structure, implementation details, lore references, and repo-local documentation. Omit repoName to search across all indexed repos, or call list_indexed_repos first if you need valid repoName options.",
inputSchema: searchSourcesInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: SearchSourcesArgs): Promise<CallToolResult> => {
const { query, limit, repoName, pathPrefix, language } = input;
const results = await context.retrievalService.searchRepositorySources(
query,
limit ?? DEFAULT_RETRIEVAL_RESULT_LIMIT,
{
repoName,
pathPrefix,
language,
},
);
const formattedResults = formatSourceResults(results);
return {
content: [
{
type: "text",
text:
formattedResults.length > 0
? renderJsonBlock({
query,
resultCount: formattedResults.length,
results: formattedResults,
})
: `No indexed source documents matched "${query}".`,
},
],
structuredContent: {
query,
resultCount: formattedResults.length,
results: formattedResults,
},
};
},
);
server.registerTool(
"get_source_context",
{
title: "Get Source Context",
description:
"Fetch a surrounding chunk window from an indexed source document. Use after search_sources when you need adjacent code, prose, or lore context inside a file.",
inputSchema: sourceContextInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: SourceContextArgs): Promise<CallToolResult> => {
const { sourceId, chunkIndex, before, after } = input;
const document = await context.sourceArchiveRepository.get(sourceId);
if (!document) {
return {
content: [
{
type: "text",
text: `No indexed source document with ID ${sourceId} was found.`,
},
],
structuredContent: {
found: false,
sourceId,
chunks: [],
},
isError: true,
};
}
const chunks = context.sourceDocumentIngester.buildContextWindow(
document,
chunkIndex ?? 0,
before ?? 1,
after ?? 1,
);
const anchorIndex =
chunks.find((chunk) => chunk.chunkIndex === (chunkIndex ?? 0))?.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 {
content: [
{
type: "text",
text: renderJsonBlock({
found: true,
sourceId,
repoName: document.repoName,
path: document.path,
language: document.language,
count: formattedChunks.length,
chunks: formattedChunks,
}),
},
],
structuredContent: {
found: true,
sourceId,
repoName: document.repoName,
path: document.path,
language: document.language,
count: formattedChunks.length,
chunks: formattedChunks,
},
};
},
);
server.registerTool(
"get_message_context",
{
title: "Get Message Context",
description:
"Fetch the surrounding conversation window for a specific archived Discord message ID. Use after search_history when you need neighboring context or thread flow.",
inputSchema: messageContextInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: MessageContextArgs): Promise<CallToolResult> => {
const { messageId, before, after } = input;
const anchor = await context.archiveRepository.get(messageId);
if (!anchor) {
return {
content: [
{
type: "text",
text: `No archived message with ID ${messageId} was found.`,
},
],
structuredContent: {
found: false,
messageId,
messages: [],
},
isError: true,
};
}
const messages = await context.archiveRepository.listContextWindow(
messageId,
before ?? 4,
after ?? 4,
);
const formattedMessages = messages.map((message) => formatArchivedMessage(message, messageId));
return {
content: [
{
type: "text",
text: renderJsonBlock({
found: true,
messageId,
count: formattedMessages.length,
messages: formattedMessages,
}),
},
],
structuredContent: {
found: true,
messageId,
count: formattedMessages.length,
messages: formattedMessages,
},
};
},
);
server.registerTool(
"post_discord_message",
{
title: "Post Discord Message",
description:
"Post a message to a Discord channel or thread. Use this for constructive proactive participation, moderation nudges, or replies when speaking in-channel would genuinely help. Set replyToMessageId to reply to a specific message; omit it for a fresh post. Supply personaName and optional personaAvatarUrl when an agent should speak through the shared webhook pipe as itself instead of the base bot identity.",
inputSchema: postDiscordMessageInputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async (input: PostDiscordMessageArgs): Promise<CallToolResult> => {
const { channelId, content, replyToMessageId, personaName, personaAvatarUrl } = input;
if (!context.config.botToken) {
return {
content: [
{
type: "text",
text: "DISCORD_BOT_TOKEN is not configured, so Discord posting is unavailable.",
},
],
structuredContent: {
sent: false,
reason: "missing_bot_token",
channelId,
},
isError: true,
};
}
const posted = await postDiscordMessage(
context.config.botToken,
channelId,
content,
replyToMessageId,
{
personaName,
personaAvatarUrl,
},
);
return {
content: [
{
type: "text",
text:
replyToMessageId
? `Posted a reply in Discord channel ${channelId} as message ${posted.id} via ${posted.transport}.`
: `Posted a message in Discord channel ${channelId} as message ${posted.id} via ${posted.transport}.`,
},
],
structuredContent: {
sent: true,
channelId,
messageId: posted.id,
transport: posted.transport,
personaName: personaName ?? null,
personaAvatarUrl: personaAvatarUrl ?? null,
replyToMessageId: replyToMessageId ?? null,
},
};
},
);
server.registerTool(
"list_repo_discord_identities",
{
title: "List Repo Discord Identities",
description:
"List registered repo identities that can be addressed by Discord roles and speak through the shared webhook persona pipe.",
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
async (): Promise<CallToolResult> => {
const registry = await loadRepoDiscordIdentityRegistry(
context.config.repoDiscordIdentitiesPath,
);
const identities = registry.identities.map((identity) => ({
id: identity.id,
repoName: identity.repoName,
repoPath: identity.repoPath ?? null,
displayName: identity.displayName,
roleId: identity.roleId ?? null,
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
allowedChannelIds: identity.allowedChannelIds,
faceStatePath: resolveRepoFaceStatePath(identity, context.config.storageRoot),
hasAvatarUrl: Boolean(identity.avatarUrl),
description: identity.description ?? null,
}));
return {
content: [
{
type: "text",
text:
identities.length > 0
? renderJsonBlock({
identityCount: identities.length,
identities,
})
: "No repo Discord identities are registered.",
},
],
structuredContent: {
identityCount: identities.length,
identities,
},
};
},
);
server.registerTool(
"post_repo_identity_message",
{
title: "Post Repo Identity Message",
description:
"Post in Discord as a registered repo identity. The identity's Discord role is the mention target; this tool speaks through the shared webhook persona pipe using the registered display name and avatar.",
inputSchema: postRepoIdentityMessageInputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async (input: PostRepoIdentityMessageArgs): Promise<CallToolResult> => {
const { identity: identitySelector, channelId, content, replyToMessageId } = input;
const registry = await loadRepoDiscordIdentityRegistry(
context.config.repoDiscordIdentitiesPath,
);
const identity = findRepoDiscordIdentity(registry, identitySelector);
if (!identity) {
return {
content: [
{
type: "text",
text: `No registered repo Discord identity matched "${identitySelector}".`,
},
],
structuredContent: {
sent: false,
reason: "unknown_identity",
identity: identitySelector,
},
isError: true,
};
}
if (!isRepoDiscordIdentityAllowedInChannel(identity, channelId)) {
return {
content: [
{
type: "text",
text: `Repo identity ${identity.id} is not registered for Discord channel ${channelId}.`,
},
],
structuredContent: {
sent: false,
reason: "channel_not_allowed",
identity: identity.id,
channelId,
allowedChannelIds: identity.allowedChannelIds,
},
isError: true,
};
}
if (!context.config.botToken) {
return {
content: [
{
type: "text",
text: "DISCORD_BOT_TOKEN is not configured, so Discord posting is unavailable.",
},
],
structuredContent: {
sent: false,
reason: "missing_bot_token",
channelId,
identity: identity.id,
},
isError: true,
};
}
const posted = await postDiscordMessage(
context.config.botToken,
channelId,
content,
replyToMessageId,
{
personaName: identity.displayName,
personaAvatarUrl: identity.avatarUrl,
},
);
return {
content: [
{
type: "text",
text: replyToMessageId
? `Posted a reply in Discord channel ${channelId} as ${identity.displayName} (${identity.id}) message ${posted.id} via ${posted.transport}.`
: `Posted a message in Discord channel ${channelId} as ${identity.displayName} (${identity.id}) message ${posted.id} via ${posted.transport}.`,
},
],
structuredContent: {
sent: true,
channelId,
messageId: posted.id,
transport: posted.transport,
identity: identity.id,
repoName: identity.repoName,
personaName: identity.displayName,
roleId: identity.roleId ?? null,
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
replyToMessageId: replyToMessageId ?? null,
},
};
},
);
server.registerTool(
"read_repo_face_state",
{
title: "Read Repo Face State",
description:
"Read the typed persistent Face state for a registered repo identity. Face state uses the same typed operation machinery as Void, but the state file belongs to the repo identity.",
inputSchema: repoFaceStateInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: RepoFaceStateArgs): Promise<CallToolResult> => {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
if (!resolved.identity) {
return resolved.error;
}
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
});
const rendered = buildVoidSelfStateContext(typedState, {
sourcePath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
});
return {
content: [
{
type: "text",
text: renderJsonBlock({
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
summary: rendered.summary,
typedState,
}),
},
],
structuredContent: {
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
summary: rendered.summary,
typedState,
},
};
},
);
server.registerTool(
"apply_repo_face_state_operation",
{
title: "Apply Repo Face State Operation",
description:
"Apply one typed state operation to a registered repo identity's Face state. Use this for Face memory, incubation, agency pressure, candidate interventions, and receipts; do not edit the Face state file directly.",
inputSchema: applyRepoFaceStateOperationInputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async (input: ApplyRepoFaceStateOperationArgs): Promise<CallToolResult> => {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
if (!resolved.identity) {
return resolved.error;
}
const result = await applyVoidSelfStateOperation(
{
canonicalPath: resolved.faceStatePath,
identity: {
agentId: resolved.identity.id,
publicName: resolved.identity.displayName,
publicDescription: resolved.identity.description,
},
},
input.operation,
);
return {
content: [
{
type: "text",
text: renderJsonBlock({
applied: true,
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
result,
}),
},
],
structuredContent: {
applied: true,
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
result,
},
};
},
);
server.registerTool(
"notify_owner",
{
title: "Notify Owner",
description:
"Send a Discord DM to the configured owner. Use for completion notices, progress updates, or when explicitly asked to ping them.",
inputSchema: notifyOwnerInputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async (input: NotifyOwnerArgs): Promise<CallToolResult> => {
const { message } = input;
if (!context.config.botToken) {
return {
content: [
{
type: "text",
text: "DISCORD_BOT_TOKEN is not configured, so notifications are unavailable.",
},
],
structuredContent: {
sent: false,
reason: "missing_bot_token",
},
isError: true,
};
}
const dmChannelId = await openOwnerDmChannel(context.config.botToken, context.config.ownerDiscordId);
const posted = await postDiscordMessage(context.config.botToken, dmChannelId, message);
return {
content: [
{
type: "text",
text: `Sent a Discord DM to the owner as message ${posted.id}.`,
},
],
structuredContent: {
sent: true,
channelId: dmChannelId,
messageId: posted.id,
},
};
},
);
}
async function resolveRepoIdentityForTool(
context: VoidbotMcpContext,
identitySelector: string,
): Promise<
| {
identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>;
faceStatePath: string;
}
| {
identity?: undefined;
error: CallToolResult;
}
> {
const registry = await loadRepoDiscordIdentityRegistry(
context.config.repoDiscordIdentitiesPath,
);
const identity = findRepoDiscordIdentity(registry, identitySelector);
if (!identity) {
return {
error: {
content: [
{
type: "text",
text: `No registered repo Discord identity matched "${identitySelector}".`,
},
],
structuredContent: {
found: false,
reason: "unknown_identity",
identity: identitySelector,
},
isError: true,
},
};
}
return {
identity,
faceStatePath: resolveRepoFaceStatePath(identity, context.config.storageRoot),
};
}
function identityForToolResult(identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>) {
return {
id: identity.id,
repoName: identity.repoName,
displayName: identity.displayName,
roleId: identity.roleId ?? null,
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
};
}