/** * v1.14.1-mcp-poc: singleton MCP client for Context7. * * Connects via Streamable HTTP transport, discovers tools at startup, * wraps each as a BooCode ToolDef with a `context7_` name prefix. * Graceful degradation: if the server is unreachable, zero tools are * exposed and BooCode functions normally with native tools. */ import { Client } from '@modelcontextprotocol/sdk/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { z } from 'zod'; import type { FastifyBaseLogger } from 'fastify'; import type { Config } from '../config.js'; import type { ToolDef } from './tools.js'; // ---- Types for the MCP tool shape returned by listTools ---- interface McpToolAnnotations { readOnlyHint?: boolean; destructiveHint?: boolean; [key: string]: unknown; } interface McpToolDef { name: string; description?: string; inputSchema: Record; annotations?: McpToolAnnotations; } // ---- Module-level state ---- let client: Client | null = null; let tools: ToolDef>[] = []; let initialized = false; let log: FastifyBaseLogger | null = null; const NAME_PREFIX = 'context7_'; const MAX_RESULT_BYTES = 5 * 1024 * 1024; // ---- Public API ---- /** * Connect to the Context7 MCP server, discover tools, and wrap them * as BooCode ToolDefs. On failure, logs a warning and exposes zero tools. */ export async function initialize(config: Config, logger: FastifyBaseLogger): Promise { log = logger; if (!config.MCP_CONTEXT7_URL) { log.info('mcp: MCP_CONTEXT7_URL not set, skipping Context7 initialization'); initialized = true; return; } try { client = new Client({ name: 'boocode', version: '1.14.1' }); const requestInit: RequestInit = {}; if (config.MCP_CONTEXT7_API_KEY) { requestInit.headers = { Authorization: `Bearer ${config.MCP_CONTEXT7_API_KEY}` }; } const transport = new StreamableHTTPClientTransport( new URL(config.MCP_CONTEXT7_URL), { requestInit }, ); await client.connect(transport); const result = await client.listTools(); const mcpTools = (result.tools ?? []) as McpToolDef[]; tools = []; for (const t of mcpTools) { // D3: read-only invariant guard. Reject tools that explicitly declare // readOnlyHint: false (i.e. write tools). Accept readOnlyHint: true // or absent annotations (fail-open — most MCP servers don't annotate). if (t.annotations?.readOnlyHint === false) { log.info({ tool: t.name }, 'mcp: skipping non-read-only tool'); continue; } tools.push(wrapMcpTool(t)); } log.info( { count: tools.length, names: tools.map((t) => t.name) }, 'mcp: initialized Context7', ); initialized = true; } catch (err) { log.warn({ err }, 'mcp: failed to initialize Context7 — MCP tools will be unavailable'); client = null; tools = []; initialized = true; } } /** * Call an MCP tool by its prefixed name. Strips the prefix before * forwarding to the MCP server. Returns a string on success or an * error-shaped object on failure. */ export async function callTool( prefixedName: string, args: Record, ): Promise { if (!client) { return { error: true, output: 'MCP client not initialized' }; } const originalName = prefixedName.startsWith(NAME_PREFIX) ? prefixedName.slice(NAME_PREFIX.length) : prefixedName; try { const result = await client.callTool({ name: originalName, arguments: args }); // D8: extract content blocks const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>; if (!content || content.length === 0) { return '(no output)'; } // If MCP reports an error, return error shape if (result.isError) { const joined = content .map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block))) .join('\n'); return { error: true, output: joined || '(MCP error with no details)' }; } const parts = content.map((block) => { if (block.type === 'text') return block.text ?? ''; return JSON.stringify(block); }); const joined = parts.join('\n'); if (joined.length > MAX_RESULT_BYTES) { log?.warn({ tool: originalName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated'); return joined.slice(0, MAX_RESULT_BYTES) + '\n\n[truncated — MCP result exceeded size limit]'; } return joined; } catch (err) { log?.warn({ err, tool: originalName }, 'mcp: callTool failed'); return { error: true, output: err instanceof Error ? err.message : 'MCP server unreachable', }; } } /** Return the wrapped ToolDefs discovered at initialization. */ export function getTools(): ToolDef>[] { return tools; } /** Whether initialize() has been called (even if it failed). */ export function isInitialized(): boolean { return initialized; } // ---- Internal helpers ---- /** Exposed for unit tests. */ export function wrapMcpTool(mcpTool: McpToolDef): ToolDef> { const prefixedName = `${NAME_PREFIX}${mcpTool.name}`; return { name: prefixedName, description: mcpTool.description ?? '', inputSchema: z.record(z.unknown()), jsonSchema: { type: 'function' as const, function: { name: prefixedName, description: mcpTool.description ?? '', parameters: mcpTool.inputSchema ?? { type: 'object', properties: {} }, }, }, execute: async (input) => { return callTool(prefixedName, input); }, }; } /** Exposed for unit tests — extract content from an MCP result. */ export function extractContent( content: Array<{ type: string; text?: string; [key: string]: unknown }> | undefined, isError?: boolean, ): unknown { if (!content || content.length === 0) return '(no output)'; const parts = content.map((block) => { if (block.type === 'text') return block.text ?? ''; return JSON.stringify(block); }); const joined = parts.join('\n'); if (isError) { return { error: true, output: joined || '(MCP error with no details)' }; } return joined; } /** Exposed for unit tests — the read-only guard predicate. */ export function isToolReadOnly(annotations?: McpToolAnnotations): boolean { // Reject explicitly non-read-only tools; accept everything else return annotations?.readOnlyHint !== false; }