/** * MCP Tool Registry — converts MCP server tools into main ToolDefinitions * and registers them into the central tool registry. * * Naming convention: mcp____ * Example: server "chrome-devtools-mcp" tool "click" → "mcp__chrome_devtools_mcp__click" */ import type { ToolDefinition as McpToolDefinition, ToolCallResult } from './types' import type { ToolDefinition, ToolResult, ToolContext } from '../shared/types' import { McpClient } from './client' import { createT } from '../i18n-core/t' import enUS from '../i18n-core/locales/en-US.json' import zhCN from '../i18n-core/locales/zh-CN.json' import type { TranslationMap } from '../i18n-core/types' const bundles: Record = { 'en-US': enUS as TranslationMap, 'zh-CN': zhCN as TranslationMap, } const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap) const MCP_TOOL_PREFIX = 'mcp__' const MAX_NAME_LENGTH = 128 /** * Sanitize a name for use in a tool identifier. * Lowercases and replaces non-alphanumeric characters (except hyphens) with underscores. */ function sanitizeName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9-]/g, '_') .replace(/_+/g, '_') .replace(/^_|_$/g, '') } /** * Build a namespaced MCP tool name. * Format: mcp____ */ function buildToolName(serverName: string, toolName: string): string { const base = `${MCP_TOOL_PREFIX}${sanitizeName(serverName)}__${sanitizeName(toolName)}` if (base.length <= MAX_NAME_LENGTH) return base const prefix = `${MCP_TOOL_PREFIX}${sanitizeName(serverName)}__` const maxToolLen = MAX_NAME_LENGTH - prefix.length return prefix + sanitizeName(toolName).slice(0, maxToolLen) } /** * Convert an MCP server ToolDefinition into the main ToolDefinition format * that can be registered in the central tool registry. */ export function convertMcpTool(serverName: string, mcpTool: McpToolDefinition): ToolDefinition { const toolName = buildToolName(serverName, mcpTool.name) // Build parameters schema — pass through MCP inputSchema as-is const parameters: Record = { type: 'object', properties: (mcpTool.inputSchema.properties as Record) ?? {}, } if (mcpTool.inputSchema.required) { parameters.required = mcpTool.inputSchema.required } return { name: toolName, description: `[MCP:${serverName}] ${mcpTool.description || mcpTool.name}`, category: 'system', permission: 'ask', parameters, async execute(params: Record, _ctx: ToolContext): Promise { const client = McpClient.getInstance() const conn = client.getConnection(serverName) if (!conn || conn.status !== 'connected') { return { success: false, content: '', error: `MCP server "${serverName}" is not connected (status: ${conn?.status || 'unknown'}). The server may have crashed or been disconnected.`, } } const result: ToolCallResult = await client.callTool(serverName, mcpTool.name, params) const text = result.content .map((c) => { if (c.type === 'text' && c.text) return c.text if (c.type === 'image') return `[Image: ${c.mimeType || 'unknown'}]` return JSON.stringify(c) }) .join('\n') return { success: !result.isError, content: text || '(empty result)', error: result.isError ? text : undefined, } }, } } /** * Register all tools from a connected MCP server into the tool registry. * Returns the number of tools registered. */ export function registerMcpServerTools( serverName: string, toolsMap: Map, ): number { const client = McpClient.getInstance() const mcpTools = client.getTools(serverName) let registered = 0 for (const mcpTool of mcpTools) { try { const tool = convertMcpTool(serverName, mcpTool) if (toolsMap.has(tool.name)) { console.error(t('errors.mcp_register_collision', { name: tool.name, server: serverName })) continue } toolsMap.set(tool.name, tool) registered++ } catch (err) { console.error( t('errors.mcp_register_failed', { tool: mcpTool.name, server: serverName, error: String(err), }), ) } } return registered } /** * Unregister all tools previously registered from a specific MCP server. * Tools are identified by the mcp____ prefix. */ export function unregisterMcpServerTools( serverName: string, toolsMap: Map, ): void { const prefix = `${MCP_TOOL_PREFIX}${sanitizeName(serverName)}__` for (const name of toolsMap.keys()) { if (name.startsWith(prefix)) { toolsMap.delete(name) } } } /** * Apply a runtime tool-list change (tools/list_changed) to the central registry. * Pure core of the dynamic-tool-update path: add new tools, remove stale ones. */ export function applyToolChanges( serverName: string, added: McpToolDefinition[], removed: McpToolDefinition[], toolsMap: Map, ): { added: number; removed: number } { let removedCount = 0 for (const t of removed) { if (toolsMap.delete(convertMcpTool(serverName, t).name)) removedCount++ } let addedCount = 0 for (const t of added) { const converted = convertMcpTool(serverName, t) toolsMap.set(converted.name, converted) addedCount++ } return { added: addedCount, removed: removedCount } } /** * Keep the central registry in sync with runtime MCP tool changes. * Register once at startup — the handler is global across all connected * MCP servers (both config.yml and plugin). */ export function syncMcpToolsOnChange( mcpClient: McpClient, toolsMap: Map, ): void { mcpClient.on('tools-changed', (name: unknown, added: unknown, removed: unknown) => { applyToolChanges( name as string, (added as McpToolDefinition[] | undefined) ?? [], (removed as McpToolDefinition[] | undefined) ?? [], toolsMap, ) }) }