/** * MCP conformance + tool-hiding over the protocol (build-plan ยง5). * * Uses the SDK's in-memory linked transport to drive a real Client against our * `createMcpServer`: handshake, scope-filtered `tools/list`, a successful * `tools/call` round-trip, server-side scope enforcement on a forced hidden * call, and validation that every advertised tool carries a JSON-Schema input. */ import { describe, it, expect } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { ToolRegistry, defineTool } from './registry.js'; import { createMcpServer } from './mcp.js'; import { NoopAuditLogger } from './audit.js'; import { NoopRateLimiter } from './rate-limit.js'; import type { AuthContext, Scope } from './types.js'; import type { DispatchDeps } from './dispatch.js'; const deps: DispatchDeps = { auditLogger: new NoopAuditLogger(), rateLimiter: new NoopRateLimiter(), }; function buildRegistry(): ToolRegistry { const r = new ToolRegistry(); r.register( defineTool<{ id: string }, { id: string; ok: boolean }>({ name: 'lookup_thing', description: 'look up a thing', tier: 'ops', requiredScope: 'ops:read', inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'string' } }, }, handler: async (input) => ({ id: input.id, ok: true }), }), ); r.register( defineTool<{ vendor: string }, unknown>({ name: 'lookup_payment', description: 'finance only', tier: 'finance', requiredScope: 'finance:read', inputSchema: { type: 'object', properties: { vendor: { type: 'string' } } }, handler: async () => ({ secret: true }), }), ); return r; } async function connect(scopes: Scope[]): Promise { const ctx: AuthContext = { sub: 'u@seahavenind.com', scopes, aud: 'sh-mcp-ops' }; const server = createMcpServer(buildRegistry(), ctx, deps, { name: 'sh-mcp-test', version: '0.0.1', }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); const client = new Client({ name: 'test-client', version: '0.0.1' }); await client.connect(clientTransport); return client; } describe('MCP conformance', () => { it('completes the handshake and lists scope-permitted tools', async () => { const client = await connect(['ops:read']); const { tools } = await client.listTools(); const names = tools.map((t) => t.name); expect(names).toContain('lookup_thing'); // finance tool is hidden from an ops-only caller. expect(names).not.toContain('lookup_payment'); // every advertised tool carries a JSON-Schema input. for (const t of tools) { expect(t.inputSchema).toBeDefined(); expect((t.inputSchema as { type?: string }).type).toBe('object'); } await client.close(); }); it('round-trips a successful tools/call', async () => { const client = await connect(['ops:read']); const res = await client.callTool({ name: 'lookup_thing', arguments: { id: 'WO-1' } }); expect(res.structuredContent).toEqual({ id: 'WO-1', ok: true }); await client.close(); }); it('hides finance tools but STILL enforces scope on a forced call (hiding is not the boundary)', async () => { const client = await connect(['ops:read']); await expect( client.callTool({ name: 'lookup_payment', arguments: { vendor: 'x' } }), ).rejects.toThrow(); await client.close(); }); it('rejects malformed input through the protocol', async () => { const client = await connect(['ops:read']); await expect( client.callTool({ name: 'lookup_thing', arguments: { wrong: 'field' } }), ).rejects.toThrow(); await client.close(); }); it('reveals finance tools to a finance caller', async () => { const client = await connect(['finance:read']); const names = (await client.listTools()).tools.map((t) => t.name); expect(names).toContain('lookup_payment'); expect(names).not.toContain('lookup_thing'); await client.close(); }); });