Validates the MCP-client loop end-to-end against one real MCP server before the full v1.15 port. New services/mcp-client.ts wraps @modelcontextprotocol/sdk v1.29.0 with Streamable HTTP transport. On startup (when MCP_CONTEXT7_URL is set), connects to Context7, discovers tools via tools/list, wraps each as a ToolDef prefixed context7_<name>, and appends to ALL_TOOLS via appendMcpTools. Read-only invariant guard rejects any tool with readOnlyHint: false. Tool dispatch is transparent — executeToolCall routes MCP calls through the ToolDef execute wrapper, which strips the prefix before calling the MCP server. Result size capped at 5MB with truncation. Graceful degradation: server down at startup → zero tools; server down mid-session → error result, model self-corrects. Adversarial review caught that a Zod .default() on the URL config made MCP always-on instead of opt-in — fixed by removing the default. MCP_CONTEXT7_URL must be explicitly set to enable. ALL_TOOLS changed from ReadonlyArray to mutable to support late-registration. appendMcpTools re-sorts and rebuilds TOOLS_BY_NAME after append. 348/348 server tests passing (16 new mcp-client tests). No schema changes, no frontend changes.
206 lines
6.4 KiB
TypeScript
206 lines
6.4 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
annotations?: McpToolAnnotations;
|
|
}
|
|
|
|
// ---- Module-level state ----
|
|
let client: Client | null = null;
|
|
let tools: ToolDef<Record<string, unknown>>[] = [];
|
|
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<void> {
|
|
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<string, unknown>,
|
|
): Promise<unknown> {
|
|
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<Record<string, unknown>>[] {
|
|
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<Record<string, unknown>> {
|
|
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;
|
|
}
|