Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,14 +1246,13 @@ const STATELESS_CACHEABLE_METHODS: ReadonlySet<string> = 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<string, unknown>; [key: string]: unknown }
): import('express').Response {
): { result?: Record<string, unknown>; [key: string]: unknown } {
const result = payload.result;
if (result && typeof result === 'object' && !Array.isArray(result)) {
result.resultType ??= 'complete';
Expand All @@ -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<string, unknown>; [key: string]: unknown }
): import('express').Response {
return res.json(normalizeStatelessResponse(method, payload));
}

// Handle POST requests - stateful mode
Expand Down Expand Up @@ -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({
Expand Down
97 changes: 64 additions & 33 deletions src/scenarios/server/all-scenarios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
Expand Down Expand Up @@ -132,40 +136,67 @@ describe('Server Scenarios', () => {
...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) {
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);
}
});