Files
boocode/apps/server/src/services/mcp-client.ts
indifferentketchup d27a977d59 v1.15.0-mcp-multi: multi-server MCP client + stdio transport + config file + tool globs
Generalizes the v1.14.1 single-server Context7 PoC into a multi-server MCP
client registry with per-server graceful degradation. JSON config at
/data/mcp.json (bind-mounted alongside AGENTS.md) matches opencode's
mcpServers schema shape. Config file missing = no MCP (opt-in by presence).

Two transports: Streamable HTTP (remote servers like Context7) and stdio
(local subprocess servers like codecontext). Stdio spawns a persistent child
via the SDK's StdioClientTransport; shutdown hook closes all transports.

Tool prefix generalized from context7_<name> to <serverName>_<toolName> with
a toolToServer reverse map for dispatch routing. AGENTS.md tools: field now
supports glob patterns (context7_*, !web_*) via matchToolGlob — last-match-
wins with ! deny prefix. Replaces exact-match .includes() in stream-phase.ts.

refreshToolNames() in agents.ts rebuilds the DEFAULT_TOOLS snapshot after
appendMcpTools so agents without explicit tools: lists see MCP tools —
reviewer caught that the module-load-time snapshot would permanently exclude
late-registered tools.

Read-only invariant: readOnlyHint === false rejected at discovery. Result
size capped at 5MB. v1.14.1 env vars removed — superseded by config file.
Default data/mcp.json ships with Context7 disabled.

363/363 server tests passing. No schema changes, no frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:08:42 +00:00

289 lines
8.8 KiB
TypeScript

/**
* v1.15.0-mcp-multi: multi-server MCP client registry.
*
* Connects to multiple MCP servers (Streamable HTTP or stdio transport),
* discovers tools from each, wraps them as BooCode ToolDefs with a
* `<serverName>_<toolName>` name prefix, and routes callTool by prefix.
*
* Graceful degradation: one failing server doesn't block others.
* Read-only invariant: tools with readOnlyHint === false are rejected.
*/
import { Client } from '@modelcontextprotocol/sdk/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { z } from 'zod';
import type { FastifyBaseLogger } from 'fastify';
import type { McpServerEntry, McpServerConfig } from './mcp-config.js';
import type { ToolDef } from './tools.js';
// ---- Types ----
interface McpToolAnnotations {
readOnlyHint?: boolean;
destructiveHint?: boolean;
[key: string]: unknown;
}
interface McpToolDef {
name: string;
description?: string;
inputSchema: Record<string, unknown>;
annotations?: McpToolAnnotations;
}
interface ServerState {
client: Client;
transport: StreamableHTTPClientTransport | StdioClientTransport;
tools: ToolDef<Record<string, unknown>>[];
type: 'streamableHttp' | 'stdio';
}
// ---- Module-level state ----
const servers = new Map<string, ServerState>();
// Reverse map: prefixed tool name → server name (built during discovery)
const toolToServer = new Map<string, string>();
let log: FastifyBaseLogger | null = null;
const MAX_RESULT_BYTES = 5 * 1024 * 1024;
// ---- Public API ----
/**
* Connect to all configured MCP servers, discover tools, and wrap them.
* Per-server graceful degradation: a failing server is logged and skipped.
*/
export async function initialize(
entries: McpServerEntry[],
logger: FastifyBaseLogger,
): Promise<void> {
log = logger;
// Connect servers in parallel — each wrapped in try/catch for isolation
await Promise.all(
entries.map(async (entry) => {
try {
await connectServer(entry);
} catch (err) {
log!.warn(
{ err, server: entry.name },
`mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`,
);
}
}),
);
if (servers.size > 0) {
const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0);
log.info(
{ servers: servers.size, tools: totalTools },
'mcp: multi-server initialization complete',
);
}
}
/**
* Call an MCP tool by its prefixed name. Routes to the correct server
* using the toolToServer reverse map.
*/
export async function callTool(
prefixedName: string,
args: Record<string, unknown>,
): Promise<unknown> {
const serverName = toolToServer.get(prefixedName);
if (!serverName) {
return { error: true, output: `MCP tool "${prefixedName}" not found in any server` };
}
const state = servers.get(serverName);
if (!state) {
return { error: true, output: `MCP server "${serverName}" not available` };
}
// Strip the "<serverName>_" prefix to get the original tool name
const originalName = prefixedName.slice(serverName.length + 1);
try {
const result = await state.client.callTool({ name: originalName, arguments: args });
const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>;
if (!content || content.length === 0) {
return '(no output)';
}
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, server: serverName, 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, server: serverName }, 'mcp: callTool failed');
return {
error: true,
output: err instanceof Error ? err.message : 'MCP server unreachable',
};
}
}
/** Return all wrapped ToolDefs from all connected servers, flattened. */
export function getTools(): ToolDef<Record<string, unknown>>[] {
const all: ToolDef<Record<string, unknown>>[] = [];
for (const state of servers.values()) {
all.push(...state.tools);
}
return all;
}
/** Return status of each server (for debug/status endpoints). */
export function getMcpServers(): Array<{
name: string;
type: 'streamableHttp' | 'stdio';
toolCount: number;
connected: boolean;
}> {
return Array.from(servers.entries()).map(([name, state]) => ({
name,
type: state.type,
toolCount: state.tools.length,
connected: true,
}));
}
/**
* Graceful shutdown. For stdio servers, the SDK's transport.close() handles
* SIGTERM + timeout. For HTTP servers, close the transport.
*/
export async function shutdown(): Promise<void> {
const closePromises: Promise<void>[] = [];
for (const [name, state] of servers) {
closePromises.push(
(async () => {
try {
await state.transport.close();
log?.info({ server: name }, 'mcp: server transport closed');
} catch (err) {
log?.warn({ err, server: name }, 'mcp: error closing server transport');
}
})(),
);
}
await Promise.all(closePromises);
servers.clear();
toolToServer.clear();
}
// ---- Internal helpers ----
async function connectServer(entry: McpServerEntry): Promise<void> {
const { name, config } = entry;
const client = new Client({ name: 'boocode', version: '1.15.0' });
let transport: StreamableHTTPClientTransport | StdioClientTransport;
if (config.type === 'streamableHttp') {
transport = createHttpTransport(config);
} else {
transport = createStdioTransport(config);
}
await client.connect(transport);
const result = await client.listTools();
const mcpTools = (result.tools ?? []) as McpToolDef[];
const tools: ToolDef<Record<string, unknown>>[] = [];
for (const t of mcpTools) {
if (t.annotations?.readOnlyHint === false) {
log!.info({ tool: t.name, server: name }, 'mcp: skipping non-read-only tool');
continue;
}
const wrapped = wrapMcpTool(name, t);
tools.push(wrapped);
toolToServer.set(wrapped.name, name);
}
servers.set(name, { client, transport, tools, type: config.type });
log!.info(
{ server: name, type: config.type, count: tools.length, names: tools.map((t) => t.name) },
'mcp: server initialized',
);
}
function createHttpTransport(config: Extract<McpServerConfig, { type: 'streamableHttp' }>): StreamableHTTPClientTransport {
const requestInit: RequestInit = {};
if (config.headers && Object.keys(config.headers).length > 0) {
requestInit.headers = config.headers;
}
return new StreamableHTTPClientTransport(new URL(config.url), { requestInit });
}
function createStdioTransport(config: Extract<McpServerConfig, { type: 'stdio' }>): StdioClientTransport {
return new StdioClientTransport({
command: config.command,
args: config.args,
env: config.env,
stderr: 'pipe',
});
}
/** Wrap an MCP tool as a BooCode ToolDef with a server-name prefix. */
export function wrapMcpTool(
serverName: string,
mcpTool: McpToolDef,
): ToolDef<Record<string, unknown>> {
const prefixedName = `${serverName}_${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 {
return annotations?.readOnlyHint !== false;
}