fix(everything-server): normalize streamed tool responses (#462)

Signed-off-by: lucarlig <luca.carlig@ibm.com>
This commit is contained in:
Luca 2026-09-06 18:53:40 +01:00 • committed by GitHub
commit 1573ad5fc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 80 additions and 39 deletions

View file

@ -1246,14 +1246,13 @@ const STATELESS_CACHEABLE_METHODS: ReadonlySet<string> = new Set([
'resources/read' '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 * 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. */ * not set so every stateless result is draft-schema-valid. Errors pass through untouched. */
function sendStatelessJson( function normalizeStatelessResponse(
res: import('express').Response,
method: string, method: string,
payload: { result?: Record<string, unknown>; [key: string]: unknown } payload: { result?: Record<string, unknown>; [key: string]: unknown }
): import('express').Response { ): { result?: Record<string, unknown>; [key: string]: unknown } {
const result = payload.result; const result = payload.result;
if (result && typeof result === 'object' && !Array.isArray(result)) { if (result && typeof result === 'object' && !Array.isArray(result)) {
result.resultType ??= 'complete'; result.resultType ??= 'complete';
@ -1262,7 +1261,16 @@ function sendStatelessJson(
result.cacheScope ??= 'private'; 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<string, unknown>; [key: string]: unknown }
): import('express').Response {
return res.json(normalizeStatelessResponse(method, payload));
} }
// Handle POST requests - stateful mode // Handle POST requests - stateful mode
@ -2257,7 +2265,9 @@ app.post('/mcp', async (req, res) => {
ResultSchema as any ResultSchema as any
); );
for (const n of dispatch.drainNotifications()) write(n); 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) { } catch (e: any) {
for (const n of dispatch.drainNotifications()) write(n); for (const n of dispatch.drainNotifications()) write(n);
write({ write({

View file

@ -7,7 +7,11 @@ import {
listDraftClientScenarios, listDraftClientScenarios,
listPendingClientScenarios listPendingClientScenarios
} from '../index'; } 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'; import path from 'path';
function getFreePort(): Promise<number> { function getFreePort(): Promise<number> {
@ -132,40 +136,67 @@ describe('Server Scenarios', () => {
...listDraftClientScenarios().filter((name) => !pendingScenarios.has(name)) ...listDraftClientScenarios().filter((name) => !pendingScenarios.has(name))
]; ];
async function expectScenarioToPass(
scenarioName: string,
specVersion?: SpecVersion
): Promise<void> {
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) { for (const scenarioName of scenarios) {
it(`${scenarioName}`, async () => { it(`${scenarioName}`, async () => {
const scenario = getClientScenario(scenarioName); await expectScenarioToPass(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);
}, 10000); // 10 second timeout per scenario }, 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);
}
}); });