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:
141
apps/server/src/services/__tests__/mcp-client.test.ts
Normal file
141
apps/server/src/services/__tests__/mcp-client.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user