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.
95 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|