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>
170 lines
5.8 KiB
TypeScript
170 lines
5.8 KiB
TypeScript
/**
|
|
* 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 — multi-server prefixing', () => {
|
|
it('produces a ToolDef with <serverName>_ 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('context7', 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('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('myserver', 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('s', mcpTool);
|
|
|
|
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', () => {
|
|
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' });
|
|
});
|
|
});
|
|
});
|