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>
This commit is contained in:
2026-05-24 04:08:42 +00:00
parent 5692e99a5d
commit d27a977d59
14 changed files with 741 additions and 121 deletions

View File

@@ -1,14 +1,15 @@
/**
* v1.14.1-mcp-poc: unit tests for the MCP client service.
* Pure unit tests — no live MCP server needed. Tests the tool-wrapping,
* v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
* Pure unit tests — no live MCP server needed. Tests tool-wrapping,
* read-only guard, name prefixing, content extraction, and error handling.
* Multi-server routing tested via wrapMcpTool's server-name prefix.
*/
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', () => {
describe('wrapMcpTool — multi-server prefixing', () => {
it('produces a ToolDef with <serverName>_ prefix', () => {
const mcpTool = {
name: 'resolve-library-id',
description: 'Resolve a library identifier',
@@ -19,7 +20,7 @@ describe('mcp-client', () => {
},
};
const wrapped = wrapMcpTool(mcpTool);
const wrapped = wrapMcpTool('context7', mcpTool);
expect(wrapped.name).toBe('context7_resolve-library-id');
expect(wrapped.description).toBe('Resolve a library identifier');
@@ -29,13 +30,56 @@ describe('mcp-client', () => {
expect(typeof wrapped.execute).toBe('function');
});
it('prefixes tools from different servers correctly', () => {
const toolA = {
name: 'query-docs',
description: 'Query docs',
inputSchema: { type: 'object' as const, properties: {} },
};
const toolB = {
name: 'overview',
description: 'Get overview',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrappedA = wrapMcpTool('context7', toolA);
const wrappedB = wrapMcpTool('codecontext', toolB);
expect(wrappedA.name).toBe('context7_query-docs');
expect(wrappedB.name).toBe('codecontext_overview');
});
it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => {
const serverATools = [
{ name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } },
{ name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } },
];
const serverBTools = [
{ name: 'overview', inputSchema: { type: 'object' as const, properties: {} } },
{ name: 'search', inputSchema: { type: 'object' as const, properties: {} } },
];
const allWrapped = [
...serverATools.map((t) => wrapMcpTool('context7', t)),
...serverBTools.map((t) => wrapMcpTool('codecontext', t)),
];
expect(allWrapped).toHaveLength(4);
expect(allWrapped.map((t) => t.name)).toEqual([
'context7_query-docs',
'context7_resolve-library-id',
'codecontext_overview',
'codecontext_search',
]);
});
it('defaults description to empty string when absent', () => {
const mcpTool = {
name: 'no-desc',
inputSchema: { type: 'object' as const, properties: {} },
};
const wrapped = wrapMcpTool(mcpTool);
const wrapped = wrapMcpTool('myserver', mcpTool);
expect(wrapped.description).toBe('');
expect(wrapped.jsonSchema.function.description).toBe('');
@@ -47,9 +91,8 @@ describe('mcp-client', () => {
inputSchema: { type: 'object' as const, properties: {} },
};
const wrapped = wrapMcpTool(mcpTool);
const wrapped = wrapMcpTool('s', mcpTool);
// z.record(z.unknown()) should accept any object
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
expect(result.success).toBe(true);
});
@@ -73,7 +116,6 @@ describe('mcp-client', () => {
});
it('accepts tools with only destructiveHint set', () => {
// readOnlyHint is not set, so it should be accepted per D3
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
});
});
@@ -124,18 +166,4 @@ describe('mcp-client', () => {
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');
});
});
});