1807 lines
57 KiB
TypeScript
1807 lines
57 KiB
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
import crypto from "node:crypto";
|
|
import net from "node:net";
|
|
|
|
import { DEFAULT_RETRIEVAL_RESULT_LIMIT } from "@voidbot/shared";
|
|
import { searchHistoryWithArchiveFallback } from "@voidbot/rag";
|
|
import {
|
|
applyRepoFacePostFatigueAfterSpeech,
|
|
applyVoidSelfStateOperation,
|
|
buildVoidSelfStateContext,
|
|
faceRegistryAsRepoDiscordRegistry,
|
|
findRepoDiscordIdentity,
|
|
isRepoDiscordIdentityAllowedInChannel,
|
|
loadFaceIdentityRegistry,
|
|
loadVoidSelfStateTypedDocuments,
|
|
renderFaceIdentityDoctrine,
|
|
resolveFaceStatePath,
|
|
resolveRepoFaceStatePath,
|
|
VOID_SELF_STATE_SCHEMA_DOCUMENT_TYPES,
|
|
VOID_SELF_STATE_SCHEMA_FINGERPRINT,
|
|
} 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 OdinEndpointArgs,
|
|
type OdinInterfaceCommandArgs,
|
|
type OdinInterfaceContextArgs,
|
|
type OdinSurfaceArgs,
|
|
type ApplyRepoFaceStateOperationArgs,
|
|
type PostDiscordMessageArgs,
|
|
type PostRepoIdentityMessageArgs,
|
|
type RepoFaceStateArgs,
|
|
type RuntimeInfoArgs,
|
|
type SearchHistoryArgs,
|
|
type SearchSourcesArgs,
|
|
type SourceContextArgs,
|
|
formatArchivedMessage,
|
|
formatHistoryResults,
|
|
formatSourceResults,
|
|
applyRepoFaceStateOperationInputSchema,
|
|
messageContextInputSchema,
|
|
notifyOwnerInputSchema,
|
|
odinEndpointInputSchema,
|
|
odinInterfaceCommandInputSchema,
|
|
odinInterfaceContextInputSchema,
|
|
odinSurfaceInputSchema,
|
|
postDiscordMessageInputSchema,
|
|
postRepoIdentityMessageInputSchema,
|
|
repoFaceStateInputSchema,
|
|
renderJsonBlock,
|
|
runtimeInfoInputSchema,
|
|
searchHistoryInputSchema,
|
|
searchSourcesInputSchema,
|
|
sourceContextInputSchema,
|
|
} from "./mcp-server-shared";
|
|
|
|
const READ_ONLY_ANNOTATIONS = {
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
idempotentHint: true,
|
|
openWorldHint: false,
|
|
} as const;
|
|
|
|
const DEFAULT_ODIN_BASE_URL = "http://127.0.0.1:8797";
|
|
|
|
function isMcpToolAllowed(name: string): boolean {
|
|
const raw = process.env.VOIDBOT_MCP_TOOL_ALLOWLIST?.trim();
|
|
if (!raw) {
|
|
return true;
|
|
}
|
|
|
|
const allowed = new Set(
|
|
raw
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean),
|
|
);
|
|
return allowed.has(name);
|
|
}
|
|
|
|
export function registerVoidbotTools(
|
|
server: McpServer,
|
|
context: VoidbotMcpContext,
|
|
): void {
|
|
const registerIfAllowed = (...args: any[]): void => {
|
|
const [name] = args;
|
|
if (!isMcpToolAllowed(name)) {
|
|
return;
|
|
}
|
|
(server.registerTool as any)(...args);
|
|
};
|
|
|
|
registerIfAllowed(
|
|
"get_voidbot_runtime_info",
|
|
{
|
|
title: "Get VoidBot Runtime Info",
|
|
description:
|
|
"Return the running VoidBot MCP server runtime and typed self-state schema identity. Use this to detect stale MCP processes after schema changes.",
|
|
inputSchema: runtimeInfoInputSchema,
|
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
},
|
|
async (_input: RuntimeInfoArgs): Promise<CallToolResult> => {
|
|
const runtimeInfo = {
|
|
serverName: "voidbot",
|
|
serverVersion: "0.1.0",
|
|
pid: process.pid,
|
|
workspaceRoot: process.cwd(),
|
|
selfStateSchemaFingerprint: VOID_SELF_STATE_SCHEMA_FINGERPRINT,
|
|
selfStateDocumentTypes: VOID_SELF_STATE_SCHEMA_DOCUMENT_TYPES,
|
|
};
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: renderJsonBlock(runtimeInfo),
|
|
},
|
|
],
|
|
structuredContent: runtimeInfo,
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"list_odin_providers",
|
|
{
|
|
title: "List Odin Providers",
|
|
description:
|
|
"List Eve/CultUI providers currently advertised through Odin. This is read-only Verse discovery; use it to learn which provider-owned surfaces Odin can see.",
|
|
inputSchema: odinEndpointInputSchema,
|
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
},
|
|
async (input: OdinEndpointArgs): Promise<CallToolResult> => {
|
|
const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl);
|
|
const catalog = await fetchOdinProviderCatalog(odinBaseUrl);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: renderJsonBlock({
|
|
odinBaseUrl,
|
|
providerCount: catalog.providers.length,
|
|
providers: catalog.providers,
|
|
}),
|
|
},
|
|
],
|
|
structuredContent: {
|
|
odinBaseUrl,
|
|
providerCount: catalog.providers.length,
|
|
providers: catalog.providers,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"list_odin_verses",
|
|
{
|
|
title: "List Odin Verses",
|
|
description:
|
|
"Read Odin's current all-seer surface and list the Verse/service nodes it publishes. This is read-only; Odin remains the registry owner.",
|
|
inputSchema: odinEndpointInputSchema,
|
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
},
|
|
async (input: OdinEndpointArgs): Promise<CallToolResult> => {
|
|
const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl);
|
|
const snapshot = await fetchOdinDeckSnapshot(odinBaseUrl);
|
|
const verses = summarizeOdinVerses(snapshot);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: renderJsonBlock({
|
|
odinBaseUrl,
|
|
providerId: snapshot.providerId,
|
|
title: snapshot.title,
|
|
version: snapshot.version,
|
|
updatedAt: snapshot.updatedAt,
|
|
verseCount: verses.length,
|
|
verses,
|
|
}),
|
|
},
|
|
],
|
|
structuredContent: {
|
|
odinBaseUrl,
|
|
providerId: snapshot.providerId,
|
|
title: snapshot.title,
|
|
version: snapshot.version,
|
|
updatedAt: snapshot.updatedAt,
|
|
verseCount: verses.length,
|
|
verses,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"get_odin_surface",
|
|
{
|
|
title: "Get Odin Surface",
|
|
description:
|
|
"Read Odin's current all-seer Eve/CultUI surface, or ask Odin for one provider surface by providerId. Use this for read-only interface inspection, not command execution.",
|
|
inputSchema: odinSurfaceInputSchema,
|
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
},
|
|
async (input: OdinSurfaceArgs): Promise<CallToolResult> => {
|
|
const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl);
|
|
const snapshot = await fetchOdinDeckSnapshot(odinBaseUrl, input.providerId);
|
|
const surfaceSummary = summarizeOdinSurface(snapshot);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: renderJsonBlock({
|
|
odinBaseUrl,
|
|
providerId: snapshot.providerId,
|
|
title: snapshot.title,
|
|
version: snapshot.version,
|
|
updatedAt: snapshot.updatedAt,
|
|
selectedNodeId: snapshot.selectedNodeId,
|
|
surface: surfaceSummary,
|
|
}),
|
|
},
|
|
],
|
|
structuredContent: {
|
|
odinBaseUrl,
|
|
providerId: snapshot.providerId,
|
|
title: snapshot.title,
|
|
version: snapshot.version,
|
|
updatedAt: snapshot.updatedAt,
|
|
selectedNodeId: snapshot.selectedNodeId,
|
|
surface: surfaceSummary,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"load_odin_interface_context",
|
|
{
|
|
title: "Load Odin Interface Context",
|
|
description:
|
|
"Load one provider-owned CultMesh/Eve interface visible through Odin and lower it into compact text, tree, and command context for an agent. This is token-efficient TUI access, not raw dashboard dumping.",
|
|
inputSchema: odinInterfaceContextInputSchema,
|
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
},
|
|
async (input: OdinInterfaceContextArgs): Promise<CallToolResult> => {
|
|
const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl);
|
|
const loaded = await loadOdinProviderInterface(odinBaseUrl, input.providerId);
|
|
const contextSummary = summarizeProviderInterface(loaded, {
|
|
maxTextItems: input.maxTextItems ?? 32,
|
|
maxTreeItems: input.maxTreeItems ?? 80,
|
|
});
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: renderJsonBlock({
|
|
odinBaseUrl,
|
|
...contextSummary,
|
|
}),
|
|
},
|
|
],
|
|
structuredContent: {
|
|
odinBaseUrl,
|
|
...contextSummary,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"invoke_odin_interface_command",
|
|
{
|
|
title: "Invoke Odin Interface Command",
|
|
description:
|
|
"Send an explicit command through a provider-owned Eve/CultUI command boundary discovered via Odin. The command must be advertised by the provider interface. Odin is not the side-effect owner; this relays to the provider endpoint when the interface exposes a WebSocket command transport.",
|
|
inputSchema: odinInterfaceCommandInputSchema,
|
|
annotations: {
|
|
readOnlyHint: false,
|
|
destructiveHint: false,
|
|
idempotentHint: false,
|
|
openWorldHint: true,
|
|
},
|
|
},
|
|
async (input: OdinInterfaceCommandArgs): Promise<CallToolResult> => {
|
|
const odinBaseUrl = normalizeOdinBaseUrl(input.odinBaseUrl);
|
|
const loaded = await loadOdinProviderInterface(odinBaseUrl, input.providerId);
|
|
const command = findAdvertisedCommand(loaded, input.command);
|
|
|
|
if (!command) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Provider ${input.providerId} does not advertise command "${input.command}". Load the interface context first and use one of its advertised commands.`,
|
|
},
|
|
],
|
|
structuredContent: {
|
|
sent: false,
|
|
reason: "command_not_advertised",
|
|
providerId: input.providerId,
|
|
command: input.command,
|
|
advertisedCommands: extractProviderCommands(loaded).map((entry) => entry.command),
|
|
},
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
if (!loaded.source.startsWith("ws://")) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Provider ${input.providerId} is visible through ${loaded.source || "an unknown source"}, but this MCP relay only supports provider WebSocket command endpoints right now.`,
|
|
},
|
|
],
|
|
structuredContent: {
|
|
sent: false,
|
|
reason: "unsupported_command_transport",
|
|
providerId: input.providerId,
|
|
command: input.command,
|
|
source: loaded.source,
|
|
},
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const frame = input.frame ?? buildCommandFrame(input.providerId, command, input.payload ?? {});
|
|
const receipt = await sendProviderCommandFrame(loaded.source, frame, input.expectReceiptMs ?? 5000);
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: renderJsonBlock({
|
|
sent: true,
|
|
providerId: input.providerId,
|
|
source: loaded.source,
|
|
command: input.command,
|
|
frame,
|
|
receipt,
|
|
}),
|
|
},
|
|
],
|
|
structuredContent: {
|
|
sent: true,
|
|
providerId: input.providerId,
|
|
source: loaded.source,
|
|
command: input.command,
|
|
frame,
|
|
receipt,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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 faceRegistry = await loadFaceIdentityRegistry(context.config.repoDiscordIdentitiesPath);
|
|
const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry);
|
|
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,
|
|
}));
|
|
const epiphanies = faceRegistry.epiphanies.map((epiphany) => ({
|
|
id: epiphany.id,
|
|
displayName: epiphany.displayName,
|
|
description: epiphany.description ?? null,
|
|
repoNames: epiphany.repoNames,
|
|
jurisdictions: epiphany.jurisdictions,
|
|
faces: epiphany.faces.map((face) => face.id),
|
|
}));
|
|
const faces = faceRegistry.faces.map((face) => ({
|
|
id: face.id,
|
|
displayName: face.displayName,
|
|
epiphanyId: face.epiphanyId,
|
|
epiphanyDisplayName: face.epiphanyDisplayName,
|
|
roleId: face.roleId ?? null,
|
|
mention: face.roleId ? `<@&${face.roleId}>` : null,
|
|
allowedChannelIds: face.allowedChannelIds,
|
|
grants: face.grants,
|
|
jurisdictions: [...face.inheritedJurisdictions, ...face.jurisdictions],
|
|
faceStatePath: resolveFaceStatePath(face, context.config.storageRoot),
|
|
}));
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text:
|
|
identities.length > 0
|
|
? renderJsonBlock({
|
|
identityCount: identities.length,
|
|
identities,
|
|
epiphanyCount: epiphanies.length,
|
|
epiphanies,
|
|
faceCount: faces.length,
|
|
faces,
|
|
})
|
|
: "No repo Discord identities are registered.",
|
|
},
|
|
],
|
|
structuredContent: {
|
|
identityCount: identities.length,
|
|
identities,
|
|
epiphanyCount: epiphanies.length,
|
|
epiphanies,
|
|
faceCount: faces.length,
|
|
faces,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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 = faceRegistryAsRepoDiscordRegistry(
|
|
await loadFaceIdentityRegistry(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,
|
|
},
|
|
);
|
|
await recordRepoIdentityDeliveryReceipt({
|
|
context,
|
|
identity,
|
|
channelId,
|
|
content,
|
|
replyToMessageId,
|
|
messageId: posted.id,
|
|
transport: posted.transport,
|
|
});
|
|
|
|
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,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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, resolved.face),
|
|
faceStatePath: resolved.faceStatePath,
|
|
summary: rendered.summary,
|
|
typedState,
|
|
}),
|
|
},
|
|
],
|
|
structuredContent: {
|
|
identity: identityForToolResult(resolved.identity, resolved.face),
|
|
faceStatePath: resolved.faceStatePath,
|
|
summary: rendered.summary,
|
|
typedState,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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, resolved.face),
|
|
faceStatePath: resolved.faceStatePath,
|
|
result,
|
|
}),
|
|
},
|
|
],
|
|
structuredContent: {
|
|
applied: true,
|
|
identity: identityForToolResult(resolved.identity, resolved.face),
|
|
faceStatePath: resolved.faceStatePath,
|
|
result,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
registerIfAllowed(
|
|
"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>>;
|
|
face?: Awaited<ReturnType<typeof loadFaceIdentityRegistry>>["faces"][number];
|
|
faceStatePath: string;
|
|
}
|
|
| {
|
|
identity?: undefined;
|
|
error: CallToolResult;
|
|
}
|
|
> {
|
|
const faceRegistry = await loadFaceIdentityRegistry(context.config.repoDiscordIdentitiesPath);
|
|
const registry = faceRegistryAsRepoDiscordRegistry(faceRegistry);
|
|
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,
|
|
face: faceRegistry.faces.find((face) => face.id === identity.id),
|
|
faceStatePath: resolveRepoFaceStatePath(identity, context.config.storageRoot),
|
|
};
|
|
}
|
|
|
|
async function recordRepoIdentityDeliveryReceipt(input: {
|
|
context: VoidbotMcpContext;
|
|
identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>;
|
|
channelId: string;
|
|
content: string;
|
|
replyToMessageId?: string;
|
|
messageId: string;
|
|
transport: "bot" | "webhook";
|
|
}): Promise<void> {
|
|
await applyVoidSelfStateOperation(
|
|
{
|
|
canonicalPath: resolveRepoFaceStatePath(input.identity, input.context.config.storageRoot),
|
|
identity: {
|
|
agentId: input.identity.id,
|
|
publicName: input.identity.displayName,
|
|
publicDescription: input.identity.description,
|
|
},
|
|
},
|
|
{
|
|
operation: "record_delivery_receipt",
|
|
receipt: {
|
|
receiptKey: `repo-identity:${input.identity.id}:${input.messageId}`,
|
|
sentAt: new Date().toISOString(),
|
|
mode: "repo_identity",
|
|
transport: input.transport,
|
|
channelId: input.channelId,
|
|
replyToMessageId: input.replyToMessageId,
|
|
personaName: input.identity.displayName,
|
|
personaAvatarUrl: input.identity.avatarUrl,
|
|
contentLength: input.content.length,
|
|
chunkCount: 1,
|
|
preview: input.content.slice(0, 1000),
|
|
},
|
|
},
|
|
);
|
|
|
|
try {
|
|
await applyRepoFacePostFatigueAfterSpeech({
|
|
identity: input.identity,
|
|
storageRoot: input.context.config.storageRoot,
|
|
heartbeatStatePath: input.context.config.repoFaceHeartbeats.statePath,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`Could not apply repo Face post fatigue for ${input.identity.id}: ${message}`);
|
|
}
|
|
}
|
|
|
|
function identityForToolResult(
|
|
identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>,
|
|
face?: Awaited<ReturnType<typeof loadFaceIdentityRegistry>>["faces"][number],
|
|
) {
|
|
return {
|
|
id: identity.id,
|
|
repoName: identity.repoName,
|
|
displayName: identity.displayName,
|
|
roleId: identity.roleId ?? null,
|
|
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
|
|
epiphanyId: face?.epiphanyId ?? identity.repoName,
|
|
epiphanyDisplayName: face?.epiphanyDisplayName ?? identity.repoName,
|
|
grants: face?.grants ?? [],
|
|
jurisdictions: face ? [...face.inheritedJurisdictions, ...face.jurisdictions] : [],
|
|
doctrine: face ? renderFaceIdentityDoctrine(face) : null,
|
|
};
|
|
}
|
|
|
|
function normalizeOdinBaseUrl(value: string | undefined): string {
|
|
return (value ?? process.env.ODIN_BASE_URL ?? DEFAULT_ODIN_BASE_URL).replace(/\/+$/, "");
|
|
}
|
|
|
|
async function fetchOdinProviderCatalog(odinBaseUrl: string): Promise<{ providers: Array<Record<string, unknown>> }> {
|
|
const response = await fetch(`${odinBaseUrl}/eve/deck/providers`);
|
|
if (!response.ok) {
|
|
throw new Error(`Odin provider catalog failed: HTTP ${response.status}`);
|
|
}
|
|
const payload = await response.json() as { providers?: unknown };
|
|
const providers = Array.isArray(payload.providers)
|
|
? payload.providers.filter(isRecord)
|
|
: [];
|
|
return { providers };
|
|
}
|
|
|
|
async function fetchOdinDeckSnapshot(
|
|
odinBaseUrl: string,
|
|
providerId?: string,
|
|
): Promise<Record<string, unknown>> {
|
|
const deckUrl = new URL(odinBaseUrl);
|
|
deckUrl.protocol = deckUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
deckUrl.pathname = "/eve/deck";
|
|
deckUrl.search = "";
|
|
const connection = await openWebSocketSnapshotSocket(deckUrl);
|
|
const { socket } = connection;
|
|
let pendingBuffer = connection.initialBuffer;
|
|
try {
|
|
if (providerId) {
|
|
sendClientWebSocketText(socket, JSON.stringify({ type: "open-provider", providerId }));
|
|
}
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const frame = await readServerWebSocketText(socket, 3000, pendingBuffer);
|
|
pendingBuffer = frame.remaining;
|
|
const message = frame.text;
|
|
const parsed = JSON.parse(message) as unknown;
|
|
if (!isRecord(parsed)) {
|
|
continue;
|
|
}
|
|
if (!providerId || parsed.providerId === providerId) {
|
|
return parsed;
|
|
}
|
|
}
|
|
throw new Error(providerId
|
|
? `Odin did not publish provider ${providerId} in the snapshot window.`
|
|
: "Odin did not publish a deck snapshot.");
|
|
} finally {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
function summarizeOdinVerses(snapshot: Record<string, unknown>) {
|
|
const root = getSurfaceRoot(snapshot);
|
|
const children = Array.isArray(root?.children) ? root.children.filter(isRecord) : [];
|
|
return children
|
|
.filter((child) => child.kind === "verse")
|
|
.map((child) => {
|
|
const props = isRecord(child.props) ? child.props : {};
|
|
const services = Array.isArray(props.services)
|
|
? props.services.filter(isRecord).map((service) => ({
|
|
id: stringValue(service.id),
|
|
name: stringValue(service.name),
|
|
state: stringValue(service.state),
|
|
detail: stringValue(service.detail),
|
|
}))
|
|
: [];
|
|
return {
|
|
id: stringValue(props.verseId) || stringValue(child.id),
|
|
title: stringValue(props.title),
|
|
role: stringValue(props.role),
|
|
status: stringValue(props.status),
|
|
capabilities: Array.isArray(props.capabilities) ? props.capabilities.map(String) : [],
|
|
serviceCount: services.length,
|
|
services,
|
|
};
|
|
});
|
|
}
|
|
|
|
function summarizeOdinSurface(snapshot: Record<string, unknown>) {
|
|
const root = getSurfaceRoot(snapshot);
|
|
const children = Array.isArray(root?.children) ? root.children.filter(isRecord) : [];
|
|
const nodes = Array.isArray(snapshot.nodes) ? snapshot.nodes.filter(isRecord) : [];
|
|
const interfaces = children
|
|
.filter((child) => child.kind === "interface")
|
|
.map((child) => {
|
|
const props = isRecord(child.props) ? child.props : {};
|
|
return {
|
|
id: stringValue(child.id),
|
|
providerId: stringValue(props.providerId),
|
|
title: stringValue(props.title),
|
|
status: stringValue(props.status),
|
|
detail: stringValue(props.detail),
|
|
source: stringValue(props.source),
|
|
version: props.version ?? null,
|
|
updatedAt: stringValue(props.updatedAt),
|
|
rootKind: child.children && Array.isArray(child.children) && isRecord(child.children[0])
|
|
? stringValue(child.children[0].kind)
|
|
: "",
|
|
};
|
|
});
|
|
const observationStreams = children
|
|
.filter((child) => child.kind === "pane" && isRecord(child.props) && child.props.title === "Device Observation Streams")
|
|
.flatMap((pane) => Array.isArray(pane.children) ? pane.children.filter(isRecord) : [])
|
|
.filter((child) => child.kind === "observation-stream")
|
|
.map((child) => {
|
|
const props = isRecord(child.props) ? child.props : {};
|
|
return {
|
|
id: stringValue(child.id),
|
|
deviceId: stringValue(props.deviceId),
|
|
streamId: stringValue(props.streamId),
|
|
kind: stringValue(props.kind),
|
|
state: stringValue(props.state),
|
|
latestAt: stringValue(props.latestAt),
|
|
};
|
|
});
|
|
|
|
return {
|
|
schema: isRecord(snapshot.surface) ? stringValue(snapshot.surface.schema) : "",
|
|
rootId: stringValue(root?.id),
|
|
rootKind: stringValue(root?.kind),
|
|
title: isRecord(root?.props) ? stringValue(root.props.title) : "",
|
|
summary: isRecord(root?.props) ? stringValue(root.props.summary) : "",
|
|
nodeCount: nodes.length,
|
|
childCount: children.length,
|
|
interfaceCount: interfaces.length,
|
|
interfaces,
|
|
observationStreamCount: observationStreams.length,
|
|
observationStreams,
|
|
};
|
|
}
|
|
|
|
interface LoadedOdinInterface {
|
|
providerId: string;
|
|
title: string;
|
|
source: string;
|
|
status: string;
|
|
detail: string;
|
|
manifest: Record<string, unknown> | null;
|
|
snapshot: Record<string, unknown>;
|
|
root?: Record<string, unknown>;
|
|
loadedFrom: "provider-websocket" | "odin-embedded-interface";
|
|
}
|
|
|
|
interface InterfaceCommandSummary {
|
|
command: string;
|
|
label: string;
|
|
source: "surface.commands" | "node.action" | "node.command";
|
|
nodeId?: string;
|
|
transport?: string;
|
|
frameTemplate?: unknown;
|
|
payloadSchema?: unknown;
|
|
raw: Record<string, unknown>;
|
|
}
|
|
|
|
async function loadOdinProviderInterface(
|
|
odinBaseUrl: string,
|
|
providerId: string,
|
|
): Promise<LoadedOdinInterface> {
|
|
const odinSnapshot = await fetchOdinDeckSnapshot(odinBaseUrl);
|
|
const interfaceNode = findOdinInterfaceNode(odinSnapshot, providerId);
|
|
if (!interfaceNode) {
|
|
throw new Error(`Odin does not currently expose provider interface ${providerId}.`);
|
|
}
|
|
|
|
const props = isRecord(interfaceNode.props) ? interfaceNode.props : {};
|
|
const source = stringValue(props.source);
|
|
if (source.startsWith("ws://")) {
|
|
try {
|
|
const providerSnapshot = await fetchProviderDeckSnapshot(source, providerId);
|
|
return {
|
|
providerId,
|
|
title: stringValue(providerSnapshot.title) || stringValue(props.title) || providerId,
|
|
source,
|
|
status: stringValue(props.status),
|
|
detail: stringValue(props.detail),
|
|
manifest: isRecord(props.manifest) ? props.manifest : null,
|
|
snapshot: providerSnapshot,
|
|
root: getSurfaceRoot(providerSnapshot),
|
|
loadedFrom: "provider-websocket",
|
|
};
|
|
} catch (error) {
|
|
const embedded = embeddedInterfaceRoot(interfaceNode);
|
|
if (!embedded) {
|
|
throw error;
|
|
}
|
|
return {
|
|
providerId,
|
|
title: stringValue(props.title) || providerId,
|
|
source,
|
|
status: stringValue(props.status),
|
|
detail: `provider websocket failed; using Odin embedded root: ${error instanceof Error ? error.message : String(error)}`,
|
|
manifest: isRecord(props.manifest) ? props.manifest : null,
|
|
snapshot: odinSnapshot,
|
|
root: embedded,
|
|
loadedFrom: "odin-embedded-interface",
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
providerId,
|
|
title: stringValue(props.title) || providerId,
|
|
source,
|
|
status: stringValue(props.status),
|
|
detail: stringValue(props.detail),
|
|
manifest: isRecord(props.manifest) ? props.manifest : null,
|
|
snapshot: odinSnapshot,
|
|
root: embeddedInterfaceRoot(interfaceNode),
|
|
loadedFrom: "odin-embedded-interface",
|
|
};
|
|
}
|
|
|
|
async function fetchProviderDeckSnapshot(
|
|
deckUrlText: string,
|
|
providerId: string,
|
|
): Promise<Record<string, unknown>> {
|
|
const deckUrl = new URL(deckUrlText);
|
|
const connection = await openWebSocketSnapshotSocket(deckUrl);
|
|
const { socket } = connection;
|
|
let pendingBuffer = connection.initialBuffer;
|
|
try {
|
|
sendClientWebSocketText(socket, JSON.stringify({ type: "open-provider", providerId }));
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const frame = await readServerWebSocketText(socket, 3000, pendingBuffer);
|
|
pendingBuffer = frame.remaining;
|
|
const parsed = JSON.parse(frame.text) as unknown;
|
|
if (isRecord(parsed) && parsed.providerId === providerId) {
|
|
return parsed;
|
|
}
|
|
}
|
|
throw new Error(`Provider deck did not publish ${providerId}.`);
|
|
} finally {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
function findOdinInterfaceNode(
|
|
odinSnapshot: Record<string, unknown>,
|
|
providerId: string,
|
|
): Record<string, unknown> | undefined {
|
|
const root = getSurfaceRoot(odinSnapshot);
|
|
const children = Array.isArray(root?.children) ? root.children.filter(isRecord) : [];
|
|
return children.find((child) =>
|
|
child.kind === "interface" &&
|
|
isRecord(child.props) &&
|
|
child.props.providerId === providerId,
|
|
);
|
|
}
|
|
|
|
function embeddedInterfaceRoot(interfaceNode: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
const children = Array.isArray(interfaceNode.children) ? interfaceNode.children.filter(isRecord) : [];
|
|
return children[0];
|
|
}
|
|
|
|
function summarizeProviderInterface(
|
|
loaded: LoadedOdinInterface,
|
|
limits: {
|
|
maxTextItems: number;
|
|
maxTreeItems: number;
|
|
},
|
|
) {
|
|
const surface = isRecord(loaded.snapshot.surface) ? loaded.snapshot.surface : {};
|
|
const root = loaded.root;
|
|
const commands = extractProviderCommands(loaded);
|
|
const flatNodes = flattenInterfaceNodes(root, limits.maxTreeItems);
|
|
const textItems = flatNodes
|
|
.map((entry) => entry.text)
|
|
.filter((entry): entry is string => Boolean(entry))
|
|
.slice(0, limits.maxTextItems);
|
|
|
|
return {
|
|
providerId: loaded.providerId,
|
|
title: loaded.title,
|
|
source: loaded.source,
|
|
status: loaded.status,
|
|
detail: loaded.detail,
|
|
loadedFrom: loaded.loadedFrom,
|
|
updatedAt: stringValue(loaded.snapshot.updatedAt),
|
|
surface: {
|
|
schema: stringValue(surface.schema),
|
|
rootId: stringValue(root?.id),
|
|
rootKind: stringValue(root?.kind),
|
|
summary: isRecord(root?.props) ? stringValue(root.props.summary) : "",
|
|
},
|
|
commands,
|
|
textItems,
|
|
tree: flatNodes,
|
|
};
|
|
}
|
|
|
|
function flattenInterfaceNodes(
|
|
root: Record<string, unknown> | undefined,
|
|
limit: number,
|
|
) {
|
|
const output: Array<{
|
|
id: string;
|
|
kind: string;
|
|
title: string;
|
|
text?: string;
|
|
status?: string;
|
|
command?: string;
|
|
depth: number;
|
|
}> = [];
|
|
|
|
function visit(node: Record<string, unknown> | undefined, depth: number): void {
|
|
if (!node || output.length >= limit) {
|
|
return;
|
|
}
|
|
const props = isRecord(node.props) ? node.props : {};
|
|
const action = isRecord(props.action) ? props.action : undefined;
|
|
const command = stringValue(props.command) || stringValue(node.command) || stringValue(action?.command) || stringValue(action?.type);
|
|
output.push({
|
|
id: stringValue(node.id),
|
|
kind: stringValue(node.kind),
|
|
title: stringValue(props.title) || stringValue(props.label),
|
|
text: stringValue(props.text) || stringValue(props.detail) || undefined,
|
|
status: stringValue(props.status) || stringValue(props.tone) || undefined,
|
|
command: command || undefined,
|
|
depth,
|
|
});
|
|
const children = Array.isArray(node.children) ? node.children.filter(isRecord) : [];
|
|
for (const child of children) {
|
|
visit(child, depth + 1);
|
|
}
|
|
}
|
|
|
|
visit(root, 0);
|
|
return output;
|
|
}
|
|
|
|
function extractProviderCommands(loaded: LoadedOdinInterface): InterfaceCommandSummary[] {
|
|
const commands: InterfaceCommandSummary[] = [];
|
|
const surface = isRecord(loaded.snapshot.surface) ? loaded.snapshot.surface : {};
|
|
const surfaceCommands = Array.isArray(surface.commands)
|
|
? surface.commands.filter(isRecord)
|
|
: Array.isArray(loaded.snapshot.commands)
|
|
? loaded.snapshot.commands.filter(isRecord)
|
|
: [];
|
|
|
|
for (const command of surfaceCommands) {
|
|
const commandName = stringValue(command.command) || stringValue(command.id);
|
|
if (!commandName) {
|
|
continue;
|
|
}
|
|
commands.push({
|
|
command: commandName,
|
|
label: stringValue(command.label) || commandName,
|
|
source: "surface.commands",
|
|
transport: stringValue(command.transport),
|
|
frameTemplate: command.frameTemplate,
|
|
payloadSchema: command.payloadSchema,
|
|
raw: command,
|
|
});
|
|
}
|
|
|
|
for (const nodeCommand of extractNodeCommands(loaded.root)) {
|
|
if (!commands.some((entry) => entry.command === nodeCommand.command && entry.nodeId === nodeCommand.nodeId)) {
|
|
commands.push(nodeCommand);
|
|
}
|
|
}
|
|
|
|
return commands;
|
|
}
|
|
|
|
function extractNodeCommands(root: Record<string, unknown> | undefined): InterfaceCommandSummary[] {
|
|
const commands: InterfaceCommandSummary[] = [];
|
|
function visit(node: Record<string, unknown> | undefined): void {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
const props = isRecord(node.props) ? node.props : {};
|
|
const action = isRecord(props.action) ? props.action : undefined;
|
|
const commandName = stringValue(props.command) || stringValue(node.command) || stringValue(action?.command) || stringValue(action?.type);
|
|
if (commandName) {
|
|
commands.push({
|
|
command: commandName,
|
|
label: stringValue(props.label) || stringValue(props.title) || commandName,
|
|
source: action ? "node.action" : "node.command",
|
|
nodeId: stringValue(node.id),
|
|
transport: stringValue(action?.transport),
|
|
frameTemplate: action?.frameTemplate,
|
|
payloadSchema: action?.payloadSchema,
|
|
raw: action ?? node,
|
|
});
|
|
}
|
|
const children = Array.isArray(node.children) ? node.children.filter(isRecord) : [];
|
|
for (const child of children) {
|
|
visit(child);
|
|
}
|
|
}
|
|
visit(root);
|
|
return commands;
|
|
}
|
|
|
|
function findAdvertisedCommand(
|
|
loaded: LoadedOdinInterface,
|
|
command: string,
|
|
): InterfaceCommandSummary | undefined {
|
|
return extractProviderCommands(loaded).find((entry) => entry.command === command);
|
|
}
|
|
|
|
function buildCommandFrame(
|
|
providerId: string,
|
|
command: InterfaceCommandSummary,
|
|
payload: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
if (isRecord(command.frameTemplate)) {
|
|
return materializeFrameTemplate(command.frameTemplate, payload);
|
|
}
|
|
|
|
return {
|
|
type: "surface-command",
|
|
schema: "gamecult.eve.command.v1",
|
|
providerId,
|
|
command: command.command,
|
|
payload,
|
|
nodeId: command.nodeId,
|
|
};
|
|
}
|
|
|
|
function materializeFrameTemplate(
|
|
template: Record<string, unknown>,
|
|
payload: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const materialized: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(template)) {
|
|
materialized[key] = materializeTemplateValue(value, payload);
|
|
}
|
|
return materialized;
|
|
}
|
|
|
|
function materializeTemplateValue(value: unknown, payload: Record<string, unknown>): unknown {
|
|
if (typeof value === "string") {
|
|
const exact = value.match(/^\$\{([A-Za-z0-9_.-]+)\??\}$/);
|
|
if (exact) {
|
|
return payload[exact[1]] ?? "";
|
|
}
|
|
return value.replace(/\$\{([A-Za-z0-9_.-]+)\??\}/g, (_match, key: string) =>
|
|
payload[key] === undefined || payload[key] === null ? "" : String(payload[key]),
|
|
);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((entry) => materializeTemplateValue(entry, payload));
|
|
}
|
|
if (isRecord(value)) {
|
|
return materializeFrameTemplate(value, payload);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function sendProviderCommandFrame(
|
|
deckUrlText: string,
|
|
frame: Record<string, unknown>,
|
|
expectReceiptMs: number,
|
|
) {
|
|
const deckUrl = new URL(deckUrlText);
|
|
const connection = await openWebSocketSnapshotSocket(deckUrl);
|
|
const { socket } = connection;
|
|
let pendingBuffer = connection.initialBuffer;
|
|
try {
|
|
try {
|
|
const initial = await readServerWebSocketText(socket, 1000, pendingBuffer);
|
|
pendingBuffer = initial.remaining;
|
|
} catch {
|
|
// Some providers do not send an initial deck frame before commands.
|
|
}
|
|
sendClientWebSocketText(socket, JSON.stringify(frame));
|
|
if (expectReceiptMs <= 0) {
|
|
return {
|
|
received: false,
|
|
detail: "Command frame sent; receipt wait disabled.",
|
|
};
|
|
}
|
|
try {
|
|
const response = await readServerWebSocketText(socket, expectReceiptMs, pendingBuffer);
|
|
pendingBuffer = response.remaining;
|
|
const parsed = JSON.parse(response.text) as unknown;
|
|
return {
|
|
received: true,
|
|
frame: isRecord(parsed) ? parsed : response.text,
|
|
remainingBytes: pendingBuffer.length,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
received: false,
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
} finally {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
function getSurfaceRoot(snapshot: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
const surface = isRecord(snapshot.surface) ? snapshot.surface : undefined;
|
|
return isRecord(surface?.root) ? surface.root : undefined;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function stringValue(value: unknown): string {
|
|
return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value);
|
|
}
|
|
|
|
function openWebSocketSnapshotSocket(url: URL): Promise<{ socket: net.Socket; initialBuffer: Buffer }> {
|
|
return new Promise((resolve, reject) => {
|
|
if (url.protocol === "wss:") {
|
|
reject(new Error("Odin WSS is not supported by this lightweight MCP snapshot client yet."));
|
|
return;
|
|
}
|
|
const port = Number(url.port || 80);
|
|
const socket = net.createConnection({ host: url.hostname, port, timeout: 3000 });
|
|
const key = crypto.randomBytes(16).toString("base64");
|
|
let buffer = Buffer.alloc(0);
|
|
socket.on("connect", () => {
|
|
socket.write([
|
|
`GET ${url.pathname || "/eve/deck"} HTTP/1.1`,
|
|
`Host: ${url.hostname}:${port}`,
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
`Sec-WebSocket-Key: ${key}`,
|
|
"Sec-WebSocket-Version: 13",
|
|
"",
|
|
"",
|
|
].join("\r\n"));
|
|
});
|
|
socket.on("data", function onHandshake(chunk) {
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
const marker = buffer.indexOf("\r\n\r\n");
|
|
if (marker < 0) {
|
|
return;
|
|
}
|
|
const header = buffer.subarray(0, marker).toString("latin1");
|
|
if (!header.startsWith("HTTP/1.1 101")) {
|
|
reject(new Error(header.split(/\r?\n/)[0] || "Odin websocket handshake failed"));
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
socket.off("data", onHandshake);
|
|
resolve({ socket, initialBuffer: buffer.subarray(marker + 4) });
|
|
});
|
|
socket.on("timeout", () => {
|
|
reject(new Error("Odin websocket connection timed out"));
|
|
socket.destroy();
|
|
});
|
|
socket.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sendClientWebSocketText(socket: net.Socket, text: string): void {
|
|
const payload = Buffer.from(text, "utf8");
|
|
const mask = crypto.randomBytes(4);
|
|
const header = [0x81];
|
|
if (payload.length < 126) {
|
|
header.push(0x80 | payload.length);
|
|
} else if (payload.length <= 0xffff) {
|
|
header.push(0x80 | 126, payload.length >> 8, payload.length & 0xff);
|
|
} else {
|
|
const length = Buffer.alloc(8);
|
|
length.writeBigUInt64BE(BigInt(payload.length));
|
|
header.push(0x80 | 127, ...length);
|
|
}
|
|
const masked = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4]));
|
|
socket.write(Buffer.concat([Buffer.from(header), mask, masked]));
|
|
}
|
|
|
|
function readServerWebSocketText(
|
|
socket: net.Socket,
|
|
timeoutMs: number,
|
|
initialBuffer: Buffer<ArrayBufferLike> = Buffer.alloc(0),
|
|
): Promise<{ text: string; remaining: Buffer<ArrayBufferLike> }> {
|
|
return new Promise((resolve, reject) => {
|
|
let buffer = initialBuffer;
|
|
const timer = setTimeout(() => cleanup(new Error("Timed out waiting for Odin deck frame")), timeoutMs);
|
|
function cleanup(error?: Error, value?: { text: string; remaining: Buffer<ArrayBufferLike> }) {
|
|
clearTimeout(timer);
|
|
socket.off("data", onData);
|
|
socket.off("error", onError);
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve(value ?? { text: "", remaining: Buffer.alloc(0) });
|
|
}
|
|
}
|
|
function onError(error: Error) {
|
|
cleanup(error);
|
|
}
|
|
function onData(chunk: Buffer) {
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
readBufferedFrame();
|
|
}
|
|
function readBufferedFrame() {
|
|
const frame = tryReadWebSocketFrame(buffer);
|
|
if (!frame) {
|
|
return;
|
|
}
|
|
buffer = buffer.subarray(frame.consumed);
|
|
if (frame.opcode === 0x1) {
|
|
cleanup(undefined, {
|
|
text: frame.payload.toString("utf8"),
|
|
remaining: buffer,
|
|
});
|
|
} else if (frame.opcode === 0x8) {
|
|
cleanup(new Error("Odin websocket closed"));
|
|
}
|
|
}
|
|
socket.on("data", onData);
|
|
socket.on("error", onError);
|
|
readBufferedFrame();
|
|
});
|
|
}
|
|
|
|
function tryReadWebSocketFrame(buffer: Buffer): { opcode: number; payload: Buffer; consumed: number } | null {
|
|
if (buffer.length < 2) {
|
|
return null;
|
|
}
|
|
const opcode = buffer[0] & 0x0f;
|
|
const masked = Boolean(buffer[1] & 0x80);
|
|
let length = buffer[1] & 0x7f;
|
|
let offset = 2;
|
|
if (length === 126) {
|
|
if (buffer.length < offset + 2) {
|
|
return null;
|
|
}
|
|
length = buffer.readUInt16BE(offset);
|
|
offset += 2;
|
|
} else if (length === 127) {
|
|
if (buffer.length < offset + 8) {
|
|
return null;
|
|
}
|
|
length = Number(buffer.readBigUInt64BE(offset));
|
|
offset += 8;
|
|
}
|
|
const mask = masked ? buffer.subarray(offset, offset + 4) : null;
|
|
if (masked) {
|
|
offset += 4;
|
|
}
|
|
if (buffer.length < offset + length) {
|
|
return null;
|
|
}
|
|
let payload = buffer.subarray(offset, offset + length);
|
|
if (mask) {
|
|
payload = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4]));
|
|
}
|
|
return { opcode, payload, consumed: offset + length };
|
|
}
|