storebound/tests/mcp.test.ts
CRT 20db1345e4 MCP 2026-07-28: stateless revision with server/discover and per-request _meta, still 2025-11-25 compatible
Dual-era server: requests that carry io.modelcontextprotocol/protocolVersion are served statelessly (server/discover
with supportedVersions, capabilities, instructions, ttlMs / cacheScope; resultType complete and serverInfo in every
result's _meta; cacheable tools/list; -32022 UnsupportedProtocolVersion with data { supported, requested }; -32602 when
clientCapabilities is missing). initialize / ping / unannotated requests keep the 2025-11-25 (and older) behaviour.
Tests for both handshakes, including one stdio process serving a modern probe and a legacy client.
2026-10-10 23:28:10 +08:00

95 lines
6.5 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { join } from 'node:path';
import { eraOf, handle, LEGACY, LEGACY_PROTOCOL, META, PROTOCOL, SUPPORTED, TOOLS_TTL_MS, UNSUPPORTED_PROTOCOL_VERSION } from '../cli/mcp';
import { TOOLS } from '../cli/mcp-tools';
const modernMeta = (v = PROTOCOL, caps: unknown = {}) => ({ [META.protocolVersion]: v, [META.clientCapabilities]: caps, [META.clientInfo]: { name: 'test', version: '1' } });
type R = Record<string, unknown>;
const res = (r: Record<string, unknown> | null) => r!.result as R;
const errOf = (r: Record<string, unknown> | null) => r!.error as { code: number; message: string; data?: R };
describe('mcp 2026-07-28 (modern, stateless)', () => {
test('server/discover: versions, capabilities, instructions, serverInfo in _meta, cacheable', async () => {
const r = res(await handle({ jsonrpc: '2.0', id: 'd1', method: 'server/discover', params: { _meta: modernMeta() } }));
expect(r.resultType).toBe('complete');
expect(r.supportedVersions).toEqual(SUPPORTED);
expect((r.supportedVersions as string[])[0]).toBe('2026-07-28');
expect(r.capabilities).toEqual({ tools: {} });
expect(typeof r.instructions).toBe('string');
expect((r._meta as R)[META.serverInfo]).toMatchObject({ name: 'storebound' });
expect(r).toMatchObject({ ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
expect(r.protocolVersion).toBeUndefined();
});
test('tools/list and tools/call without any handshake', async () => {
const list = res(await handle({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: modernMeta() } }));
expect(list.resultType).toBe('complete');
expect((list.tools as unknown[]).length).toBe(TOOLS.length);
expect(list).toMatchObject({ ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
let seen: string[] = [];
const call = res(await handle({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'check', arguments: {}, _meta: modernMeta() } }, async argv => { seen = argv; return { code: 0, out: 'ok' }; }));
expect(seen).toEqual(['check']);
expect(call).toMatchObject({ resultType: 'complete', isError: false, structuredContent: { exitCode: 0, dryRun: false } });
expect((call._meta as R)[META.serverInfo]).toBeDefined();
});
test('version and _meta errors', async () => {
const bad = errOf(await handle({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: modernMeta('2099-01-01') } }));
expect(bad.code).toBe(UNSUPPORTED_PROTOCOL_VERSION);
expect(bad.code).toBe(-32022);
expect(bad.data).toEqual({ supported: SUPPORTED, requested: '2099-01-01' });
const noCaps = errOf(await handle({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: { _meta: { [META.protocolVersion]: PROTOCOL } } }));
expect(noCaps.code).toBe(-32602);
expect(noCaps.message).toContain('clientCapabilities');
const probe = errOf(await handle({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }));
expect(probe.code).toBe(-32602);
expect(errOf(await handle({ jsonrpc: '2.0', id: 6, method: 'resources/list', params: { _meta: modernMeta() } })).code).toBe(-32601);
const unknownTool = errOf(await handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'apple_submit', _meta: modernMeta() } }));
expect(unknownTool.code).toBe(-32602);
});
test('era detection', () => {
expect(eraOf({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: modernMeta() } })).toEqual({ era: 'modern', version: PROTOCOL });
expect(eraOf({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: modernMeta(LEGACY_PROTOCOL) } }).era).toBe('legacy');
expect(eraOf({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).toEqual({ era: 'legacy', version: null });
});
});
describe('mcp 2025-11-25 (legacy handshake) stays compatible', () => {
test('initialize negotiates a legacy version; results have no resultType', async () => {
const init = res(await handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'old', version: '1' } } }));
expect(init.protocolVersion).toBe('2025-11-25');
expect(init.capabilities).toEqual({ tools: { listChanged: false } });
expect(res(await handle({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2025-06-18' } })).protocolVersion).toBe('2025-06-18');
// a modern client that wrongly sends initialize with the modern version gets the newest legacy version back
expect(res(await handle({ jsonrpc: '2.0', id: 3, method: 'initialize', params: { protocolVersion: PROTOCOL } })).protocolVersion).toBe(LEGACY_PROTOCOL);
expect(await handle({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeNull();
expect(res(await handle({ jsonrpc: '2.0', id: 4, method: 'ping' }))).toEqual({});
const list = res(await handle({ jsonrpc: '2.0', id: 5, method: 'tools/list' }));
expect(list.resultType).toBeUndefined();
expect(list.ttlMs).toBeUndefined();
expect(LEGACY).toEqual(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']);
});
});
describe('stdio end to end', () => {
test('one process answers a modern probe and a legacy handshake', async () => {
const p = Bun.spawn([process.execPath, join(import.meta.dir, '..', 'cli', 'index.ts'), 'mcp'], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
const lines = [
{ jsonrpc: '2.0', id: 'probe', method: 'server/discover', params: { _meta: modernMeta() } },
{ jsonrpc: '2.0', id: 'old', method: 'server/discover', params: { _meta: modernMeta('1999-01-01') } },
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'legacy', version: '1' } } },
{ jsonrpc: '2.0', method: 'notifications/initialized' },
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: modernMeta() } },
];
p.stdin.write(lines.map(l => JSON.stringify(l)).join('\n') + '\n');
p.stdin.end();
const out = await new Response(p.stdout).text();
await p.exited;
const byId = new Map(out.trim().split('\n').map(l => JSON.parse(l) as R).map(r => [r.id, r] as const));
expect((byId.get('probe')!.result as R).supportedVersions).toContain('2026-07-28');
expect((byId.get('old')!.error as R).code).toBe(-32022);
expect((byId.get(1)!.result as R).protocolVersion).toBe('2025-11-25');
expect((byId.get(2)!.result as R).resultType).toBeUndefined();
expect((byId.get(3)!.result as R).resultType).toBe('complete');
expect(byId.size).toBe(5);
});
});