diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index d7de2228..c76c1f56 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1246,14 +1246,13 @@ const STATELESS_CACHEABLE_METHODS: ReadonlySet = new Set([ 'resources/read' ]); -/** Send a stateless (draft) JSON-RPC response. Draft results MUST carry `resultType` +/** Normalize a stateless (draft) JSON-RPC response. Draft results MUST carry `resultType` * and cacheable operations the SEP-2549 caching hints; stamp any the dispatch site did * not set so every stateless result is draft-schema-valid. Errors pass through untouched. */ -function sendStatelessJson( - res: import('express').Response, +function normalizeStatelessResponse( method: string, payload: { result?: Record; [key: string]: unknown } -): import('express').Response { +): { result?: Record; [key: string]: unknown } { const result = payload.result; if (result && typeof result === 'object' && !Array.isArray(result)) { result.resultType ??= 'complete'; @@ -1262,7 +1261,16 @@ function sendStatelessJson( result.cacheScope ??= 'private'; } } - return res.json(payload); + return payload; +} + +/** Send a normalized stateless response as JSON. */ +function sendStatelessJson( + res: import('express').Response, + method: string, + payload: { result?: Record; [key: string]: unknown } +): import('express').Response { + return res.json(normalizeStatelessResponse(method, payload)); } // Handle POST requests - stateful mode @@ -2257,7 +2265,9 @@ app.post('/mcp', async (req, res) => { ResultSchema as any ); for (const n of dispatch.drainNotifications()) write(n); - write({ jsonrpc: '2.0', id, result }); + write( + normalizeStatelessResponse(method, { jsonrpc: '2.0', id, result }) + ); } catch (e: any) { for (const n of dispatch.drainNotifications()) write(n); write({ diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 68387d73..bce7845f 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -7,7 +7,11 @@ import { listDraftClientScenarios, listPendingClientScenarios } from '../index'; -import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; +import { + DRAFT_PROTOCOL_VERSION, + LATEST_SPEC_VERSION, + type SpecVersion +} from '../../types'; import path from 'path'; function getFreePort(): Promise { @@ -132,40 +136,67 @@ describe('Server Scenarios', () => { ...listDraftClientScenarios().filter((name) => !pendingScenarios.has(name)) ]; + async function expectScenarioToPass( + scenarioName: string, + specVersion?: SpecVersion + ): Promise { + const scenario = getClientScenario(scenarioName); + expect(scenario).toBeDefined(); + + if (!scenario) { + throw new Error(`Scenario ${scenarioName} not found`); + } + + // Draft-only scenarios expect the draft (stateless) connection. Other + // scenarios normally use the latest stateful wire unless a test overrides it. + const targetSpecVersion = + specVersion ?? + ('introducedIn' in scenario.source && + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ? DRAFT_PROTOCOL_VERSION + : LATEST_SPEC_VERSION); + + const checks = await scenario.run( + testContext(serverUrl, targetSpecVersion) + ); + + // Verify checks were returned + expect(checks.length).toBeGreaterThan(0); + + // Verify all checks passed + const failures = checks.filter((c) => c.status === 'FAILURE'); + if (failures.length > 0) { + const failureMessages = failures + .map((c) => `${c.name}: ${c.errorMessage || c.description}`) + .join('\n '); + throw new Error(`Scenario failed with checks:\n ${failureMessages}`); + } + + // All checks should be non-FAILURE (SUCCESS, WARNING, or INFO are acceptable) + const nonFailures = checks.filter((c) => c.status !== 'FAILURE'); + expect(nonFailures.length).toBe(checks.length); + } + for (const scenarioName of scenarios) { it(`${scenarioName}`, async () => { - const scenario = getClientScenario(scenarioName); - expect(scenario).toBeDefined(); - - if (!scenario) { - throw new Error(`Scenario ${scenarioName} not found`); - } - - // Draft-only scenarios expect the draft (stateless) connection, so - // derive the spec version from the scenario's declared source. - const specVersion = - 'introducedIn' in scenario.source && - scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION - ? DRAFT_PROTOCOL_VERSION - : LATEST_SPEC_VERSION; - - const checks = await scenario.run(testContext(serverUrl, specVersion)); - - // Verify checks were returned - expect(checks.length).toBeGreaterThan(0); - - // Verify all checks passed - const failures = checks.filter((c) => c.status === 'FAILURE'); - if (failures.length > 0) { - const failureMessages = failures - .map((c) => `${c.name}: ${c.errorMessage || c.description}`) - .join('\n '); - throw new Error(`Scenario failed with checks:\n ${failureMessages}`); - } - - // All checks should be non-FAILURE (SUCCESS, WARNING, or INFO are acceptable) - const nonFailures = checks.filter((c) => c.status !== 'FAILURE'); - expect(nonFailures.length).toBe(checks.length); + await expectScenarioToPass(scenarioName); }, 10000); // 10 second timeout per scenario } + + // These scenarios are introduced before the stateless protocol, so the normal + // fixture matrix exercises them on the latest stateful wire. Run them again on + // the modern wire to cover the streamed response adapter used by tools/call. + for (const scenarioName of [ + 'tools-call-simple-text', + 'tools-call-image', + 'tools-call-audio', + 'tools-call-embedded-resource', + 'tools-call-mixed-content', + 'tools-call-error', + 'tools-call-with-progress' + ]) { + it(`${scenarioName} on ${DRAFT_PROTOCOL_VERSION}`, async () => { + await expectScenarioToPass(scenarioName, DRAFT_PROTOCOL_VERSION); + }, 10000); + } });