/** * 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 _ 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' }); }); }); });