v1.14.1-mcp-poc: single-server MCP client against Context7

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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:58:09 +00:00
parent f4a97808ad
commit 5692e99a5d
11 changed files with 612 additions and 6 deletions

View File

@@ -0,0 +1,141 @@
/**
* v1.14.1-mcp-poc: unit tests for the MCP client service.
* Pure unit tests — no live MCP server needed. Tests the tool-wrapping,
* read-only guard, name prefixing, content extraction, and error handling.
*/
import { describe, it, expect } from 'vitest';
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
describe('mcp-client', () => {
describe('wrapMcpTool', () => {
it('produces a ToolDef with context7_ prefix', () => {
const mcpTool = {
name: 'resolve-library-id',
description: 'Resolve a library identifier',
inputSchema: {
type: 'object' as const,
properties: { query: { type: 'string' } },
required: ['query'],
},
};
const wrapped = wrapMcpTool(mcpTool);
expect(wrapped.name).toBe('context7_resolve-library-id');
expect(wrapped.description).toBe('Resolve a library identifier');
expect(wrapped.jsonSchema.type).toBe('function');
expect(wrapped.jsonSchema.function.name).toBe('context7_resolve-library-id');
expect(wrapped.jsonSchema.function.parameters).toEqual(mcpTool.inputSchema);
expect(typeof wrapped.execute).toBe('function');
});
it('defaults description to empty string when absent', () => {
const mcpTool = {
name: 'no-desc',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrapped = wrapMcpTool(mcpTool);
expect(wrapped.description).toBe('');
expect(wrapped.jsonSchema.function.description).toBe('');
});
it('uses passthrough Zod schema (z.record)', () => {
const mcpTool = {
name: 'test',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrapped = wrapMcpTool(mcpTool);
// z.record(z.unknown()) should accept any object
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
expect(result.success).toBe(true);
});
});
describe('isToolReadOnly', () => {
it('accepts tools with readOnlyHint: true', () => {
expect(isToolReadOnly({ readOnlyHint: true })).toBe(true);
});
it('accepts tools with no annotations', () => {
expect(isToolReadOnly(undefined)).toBe(true);
});
it('accepts tools with empty annotations', () => {
expect(isToolReadOnly({})).toBe(true);
});
it('rejects tools with readOnlyHint: false', () => {
expect(isToolReadOnly({ readOnlyHint: false })).toBe(false);
});
it('accepts tools with only destructiveHint set', () => {
// readOnlyHint is not set, so it should be accepted per D3
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
});
});
describe('extractContent', () => {
it('extracts single text block', () => {
const content = [{ type: 'text', text: 'hello world' }];
expect(extractContent(content)).toBe('hello world');
});
it('joins multiple text blocks with newline', () => {
const content = [
{ type: 'text', text: 'line 1' },
{ type: 'text', text: 'line 2' },
];
expect(extractContent(content)).toBe('line 1\nline 2');
});
it('returns "(no output)" for empty content', () => {
expect(extractContent([])).toBe('(no output)');
});
it('returns "(no output)" for undefined content', () => {
expect(extractContent(undefined)).toBe('(no output)');
});
it('serializes non-text blocks as JSON', () => {
const content = [
{ type: 'resource', uri: 'file:///foo', mimeType: 'text/plain' },
];
const result = extractContent(content);
expect(result).toContain('"type":"resource"');
expect(result).toContain('"uri":"file:///foo"');
});
it('returns error shape when isError is true', () => {
const content = [{ type: 'text', text: 'something failed' }];
const result = extractContent(content, true);
expect(result).toEqual({ error: true, output: 'something failed' });
});
it('returns error shape with joined content on isError', () => {
const content = [
{ type: 'text', text: 'error 1' },
{ type: 'text', text: 'error 2' },
];
const result = extractContent(content, true);
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' });
});
});
describe('name prefix', () => {
it('prefixed name maps correctly in wrapped tool', () => {
const mcpTool = {
name: 'query-docs',
description: 'Query documentation',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrapped = wrapMcpTool(mcpTool);
expect(wrapped.name).toBe('context7_query-docs');
expect(wrapped.jsonSchema.function.name).toBe('context7_query-docs');
});
});
});

View File

@@ -0,0 +1,205 @@
/**
* 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;
}

View File

@@ -651,7 +651,9 @@ export const askUserInput: ToolDef<AskUserInputInputT> = {
// of the system prompt, so any order drift would invalidate every cached
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
// and TOOLS_BY_NAME inherit it.
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
// v1.14.1-mcp-poc: changed from ReadonlyArray to let-bound mutable array
// so appendMcpTools() can push MCP-discovered tools at startup.
export let ALL_TOOLS: ToolDef<unknown>[] = [
viewFile as ToolDef<unknown>,
viewTruncatedOutput as ToolDef<unknown>,
listDir as ToolDef<unknown>,
@@ -725,10 +727,23 @@ export const READ_ONLY_TOOL_NAMES = [
'request_read_access',
] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
ALL_TOOLS.map((t) => [t.name, t])
);
// v1.14.1-mcp-poc: append MCP-discovered tools at startup. Called once
// from index.ts after mcpClient.initialize(). Re-sorts ALL_TOOLS and
// rebuilds TOOLS_BY_NAME. READ_ONLY_TOOL_NAMES is not rebuilt because
// it's a const tuple used only for budget-tier checks; MCP tools are
// individually checked via their category at budget resolution time —
// they are all read_only by construction (the read-only guard in
// mcp-client.ts rejects any tool with readOnlyHint: false).
export function appendMcpTools(mcpTools: ToolDef<unknown>[]): void {
if (mcpTools.length === 0) return;
ALL_TOOLS = [...ALL_TOOLS, ...mcpTools].sort((a, b) => a.name.localeCompare(b.name));
TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map((t) => [t.name, t]));
}
// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from