Validate all wire messages against the per-version spec JSON schema (#399)
* Vendor schema/{version}/schema.json alongside schema.ts
sync-schema now copies the spec repo's JSON Schema for each version into
src/spec-types/{version}.schema.json (same pinned SHA recorded in SOURCE:
modelcontextprotocol@f817239f4d6b1efff2c4dfc2f7af85c985d73076). These are
the inputs for runtime wire-message validation. Vendored verbatim, so they
join schema.ts in .prettierignore.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Validate all wire messages against the per-version spec JSON schema
Every JSON-RPC message the harness sends or receives is now validated
(ajv) against the vendored schema.json for the run's spec version, at the
wire choke points: sendStatelessRequest (outbound request, JSON response,
SSE events), connectStateful (request/result/notifications), and both mock
servers (inbound client requests, outbound responses/handler results).
Beyond the JSONRPCMessage envelope, messages are validated against the
most specific definition the schema has: typed requests/notifications by
method const, typed error responses by error.code const (e.g. -32021 ->
MissingRequiredClientCapabilityError, the #376 hallucination), and typed
results via the XxxRequest -> XxxResult pair, with SEP-2322
InputRequiredResult discriminated on resultType.
Violations are recorded per scenario and surfaced by the runners as a
synthetic wire-schema-valid check (FAILURE when the implementation under
test sent an invalid message) plus a wire-schema-harness-error check when
the invalid message was harness-authored. A vitest-wide afterEach guard
fails any self-test that recorded violations, so hallucinated fixtures
can't ship (#376, #271). Opt-outs are explicit and greppable: per-call
skipValidation for deliberately malformed traffic, takeWireViolations()
drains in tests that exercise deliberately broken fixtures.
Offenders the new validation caught, now fixed:
- everything-server stateless path omitted resultType (and the caching
hints on server/discover) required by the draft schema
- sep-2164-empty-contents fixture omitted resultType/ttlMs/cacheScope
(its intended violation, empty contents, is schema-valid prose-only)
- connection/mock-server unit-test fixtures used schema-invalid stubs
(tools/call without name, ListToolsResult without required members,
Tool without inputSchema, ProgressNotification without progressToken,
ElicitRequest without message/requestedSchema)
- http-standard-headers test mocks returned bare { tools: [] } results
- input-required-result-validate-input deliberately sends malformed
inputResponses; now marked skipValidation
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Validate the stateful wire at the SDK transport, close review-panel gaps
Address the review-panel findings on the wire-schema validation PR:
- Stateful wire: hook the SDK transport inside connectToServer, wrapping
send/onmessage so every raw wire message in both directions is
validated for the scenario's spec version -- including the initialize
handshake, server->client requests (elicitation/sampling), and the
harness's responses to them. Request ids are correlated per direction
so responses validate against their typed result definition (e.g. an
outbound ElicitResult). The reconstructed stand-ins in
connectStateful (fake id: 0 envelopes, Zod-reparsed results) are
removed; the hook sees the real bytes. The four scenarios that call
connectToServer directly (elicitation-defaults, elicitation-enums,
tools, lifecycle) now pass ctx.specVersion and get validation for
free; the runner surfaces their violations through the existing
recorder. Known remaining gap (documented in the module doc): the
client-auth scenarios' bespoke express mock is not instrumented.
- skipValidation now skips only the request direction; the
implementation's response to a deliberately malformed request is
still validated, with a narrow carve-out tolerating the JSON-RPC 2.0
id: null an error response must carry when the request could not be
processed (the MCP schema's RequestId forbids null). Covered by new
unit tests pinning both the still-validated response and the
narrowness of the carve-out.
- negative-mrtr.test.ts: the file-wide drain now asserts every recorded
violation is implementation-origin (the deliberately broken fixture)
and throws on any harness-origin violation.
- everything-server: hoist the cacheable-methods set to module scope
and replace the per-request res.json monkey-patch with an explicit
sendStatelessJson(res, method, payload) helper applied at each
dispatch site.
- Pin the extracted dispatch maps (methodDefs/errorDefs, e.g.
-32021 -> MissingRequiredClientCapabilityError under the draft) per
spec version in wire-schema.test.ts, so a schema sync that breaks the
const extraction fails loudly instead of degrading to envelope-only
validation. Also note the batch branch's requestMethod forwarding
limitation.
Offender the transport hook flushed out: the everything-server's
test_elicitation_sep1034_defaults tool sent a number-typed elicitation
field with default: 95.5, which the 2025-11-25 schema forbids
(NumberSchema.default is typed integer there; widened to number only in
the draft). The fixture and the scenario's expected default are now the
integer-valued 95, with comments explaining why.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Condense comments to three lines max
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY
* Re-vendor spec schemas at pinned SHA 71e30695
Regenerate src/spec-types/*.schema.json via npm run sync-schema at the
SHA already recorded in SOURCE. The previously vendored draft schema
predated spec PR #3002, so it still required DiscoverResult.serverInfo
at the top level; the mock server now correctly reports serverInfo via
result _meta, which the stale schema rejected.
* Scope the wire-violation recorder per scenario for parallel suite runs
Client --suite runs execute scenarios concurrently over what was a
module-global violation recorder, so scenarios could steal or wipe each
other's recorded violations. Give each suite scenario its own recorder
via AsyncLocalStorage; code outside a scope (single-scenario runs, the
sequential server suite, vitest hooks) falls back to the global
recorder. Also condense a four-line comment in connection/stateful.ts
to three lines.
* Patch the 2025-11-25 NumberSchema generator bug as a load-time erratum
The released 2025-11-25 schema.json types NumberSchema
minimum/maximum/default as integer, contradicting its own schema.ts
(default?: number) — a typescript-json-schema artifact, fixed for draft
in modelcontextprotocol#2710 and proposed for 2025-11-25 in
modelcontextprotocol#3139. Patch the schema at load time so SDK
fixtures with fractional number defaults (all tier-1 SDKs send 95.5)
are not flagged; restore the sep-1034 scenario and example-server
fixture to 95.5. Delete the erratum when #3139 lands and the schemas
are re-vendored.
Also scope the sequential server-suite loop with withWireRecorder so a
connection leaked by a failed scenario cannot attribute late traffic to
the next scenario.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
parent
2a705a811e
commit
89c2c0452e
38 changed files with 14136 additions and 129 deletions
|
|
@ -4,10 +4,11 @@
|
|||
# output (and the refresh workflow's `git diff` check).
|
||||
src/seps/traceability.json
|
||||
|
||||
# Vendored verbatim from modelcontextprotocol/schema/{version}/schema.ts via
|
||||
# `npm run sync-schema`. Keep byte-identical with upstream so the SOURCE pin
|
||||
# is meaningful and re-syncing produces a clean diff.
|
||||
# Vendored verbatim from modelcontextprotocol/schema/{version}/schema.ts and
|
||||
# schema.json via `npm run sync-schema`. Keep byte-identical with upstream so
|
||||
# the SOURCE pin is meaningful and re-syncing produces a clean diff.
|
||||
src/spec-types/*.ts
|
||||
src/spec-types/*.schema.json
|
||||
|
||||
# Local tooling workspaces (not part of the repo).
|
||||
.claude/
|
||||
|
|
|
|||
|
|
@ -1235,6 +1235,36 @@ const LEGACY_SESSION_PROTOCOL_VERSIONS = [
|
|||
'2025-11-25'
|
||||
];
|
||||
|
||||
// Stateless (draft) operations whose results MUST carry the SEP-2549 caching
|
||||
// hints (`ttlMs`, `cacheScope`).
|
||||
const STATELESS_CACHEABLE_METHODS: ReadonlySet<string> = new Set([
|
||||
'server/discover',
|
||||
'tools/list',
|
||||
'prompts/list',
|
||||
'resources/list',
|
||||
'resources/templates/list',
|
||||
'resources/read'
|
||||
]);
|
||||
|
||||
/** Send 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,
|
||||
method: string,
|
||||
payload: { result?: Record<string, unknown>; [key: string]: unknown }
|
||||
): import('express').Response {
|
||||
const result = payload.result;
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
result.resultType ??= 'complete';
|
||||
if (STATELESS_CACHEABLE_METHODS.has(method)) {
|
||||
result.ttlMs ??= 0;
|
||||
result.cacheScope ??= 'private';
|
||||
}
|
||||
}
|
||||
return res.json(payload);
|
||||
}
|
||||
|
||||
// Handle POST requests - stateful mode
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
|
@ -1360,7 +1390,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
|
||||
if (method === 'server/discover') {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1390,7 +1420,7 @@ app.post('/mcp', async (req, res) => {
|
|||
{ method: 'tools/list', params: {} },
|
||||
ResultSchema as any
|
||||
)) as { tools: any[]; [k: string]: unknown };
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1466,7 +1496,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: e.code ?? -32603, message: e.message, data: e.data }
|
||||
|
|
@ -1484,7 +1514,7 @@ app.post('/mcp', async (req, res) => {
|
|||
{ method: 'prompts/list', params: {} },
|
||||
ResultSchema as any
|
||||
)) as { prompts: any[]; [k: string]: unknown };
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1501,7 +1531,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: e.code ?? -32603, message: e.message, data: e.data }
|
||||
|
|
@ -1522,7 +1552,7 @@ app.post('/mcp', async (req, res) => {
|
|||
inputResponses['user_context'],
|
||||
'context'
|
||||
);
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1538,7 +1568,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1572,7 +1602,7 @@ app.post('/mcp', async (req, res) => {
|
|||
{ method: 'resources/list', params: {} },
|
||||
ResultSchema as any
|
||||
)) as { resources: any[]; [k: string]: unknown };
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1591,7 +1621,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: e.code ?? -32603, message: e.message, data: e.data }
|
||||
|
|
@ -1608,7 +1638,7 @@ app.post('/mcp', async (req, res) => {
|
|||
{ method: 'resources/templates/list', params: {} },
|
||||
ResultSchema as any
|
||||
)) as { resourceTemplates: any[]; [k: string]: unknown };
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1618,7 +1648,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: e.code ?? -32603, message: e.message, data: e.data }
|
||||
|
|
@ -1631,7 +1661,7 @@ app.post('/mcp', async (req, res) => {
|
|||
if (method === 'resources/read') {
|
||||
const uri = params.uri as string | undefined;
|
||||
if (uri === 'test://stateless-static-text') {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1675,7 +1705,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { content: [{ type: 'text', text: 'Success' }] }
|
||||
|
|
@ -1687,13 +1717,13 @@ app.post('/mcp', async (req, res) => {
|
|||
if (name === 'test_input_required_result_elicitation') {
|
||||
if (inputResponses?.['user_name']) {
|
||||
const userName = getMrtInputText(inputResponses['user_name'], 'name');
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { content: [{ type: 'text', text: `Hello, ${userName}!` }] }
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1722,7 +1752,7 @@ app.post('/mcp', async (req, res) => {
|
|||
unknown
|
||||
>;
|
||||
const content = sample.content as Record<string, unknown> | undefined;
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1735,7 +1765,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1770,7 +1800,7 @@ app.post('/mcp', async (req, res) => {
|
|||
const roots = Array.isArray(rootsResult.roots)
|
||||
? rootsResult.roots
|
||||
: [];
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1778,7 +1808,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1796,7 +1826,7 @@ app.post('/mcp', async (req, res) => {
|
|||
const ok = (inputResponses['confirm'] as Record<string, unknown>)
|
||||
?.content as Record<string, unknown> | undefined;
|
||||
if (state.kind === 'request-state' && ok?.ok === true) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1807,7 +1837,7 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1860,7 +1890,7 @@ app.post('/mcp', async (req, res) => {
|
|||
const roots = Array.isArray(rootsResult.roots)
|
||||
? rootsResult.roots
|
||||
: [];
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1874,7 +1904,7 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1915,7 +1945,7 @@ app.post('/mcp', async (req, res) => {
|
|||
|
||||
if (name === 'test_input_required_result_multi_round') {
|
||||
if (!requestState) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1940,7 +1970,7 @@ app.post('/mcp', async (req, res) => {
|
|||
const state = JSON.parse(requestState) as Record<string, unknown>;
|
||||
if (state.round === 1 && inputResponses?.['step1']) {
|
||||
const userName = getMrtInputText(inputResponses['step1'], 'name');
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1970,7 +2000,7 @@ app.post('/mcp', async (req, res) => {
|
|||
const userName =
|
||||
typeof state.name === 'string' ? state.name : 'friend';
|
||||
const color = getMrtInputText(inputResponses['step2'], 'color');
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -1984,7 +2014,7 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
// Fallback: restart
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -2011,7 +2041,7 @@ app.post('/mcp', async (req, res) => {
|
|||
if (requestState) {
|
||||
const verified = verifyMrtState(requestState);
|
||||
if (!verified) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
|
|
@ -2021,7 +2051,7 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
if (verified.kind === 'tamper-test' && inputResponses?.['confirm']) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -2032,7 +2062,7 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -2093,7 +2123,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
|
||||
if (inputResponses && Object.keys(inputResponses).length > 0) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -2107,7 +2137,7 @@ app.post('/mcp', async (req, res) => {
|
|||
});
|
||||
}
|
||||
if (Object.keys(inputRequests).length === 0) {
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -2117,7 +2147,7 @@ app.post('/mcp', async (req, res) => {
|
|||
}
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
|
|
@ -2197,7 +2227,7 @@ app.post('/mcp', async (req, res) => {
|
|||
} else {
|
||||
notifyListenStreams('prompts', 'notifications/prompts/list_changed');
|
||||
}
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: { content: [{ type: 'text', text: 'Mutation triggered' }] }
|
||||
|
|
@ -2255,14 +2285,14 @@ app.post('/mcp', async (req, res) => {
|
|||
{ method, params },
|
||||
ResultSchema as any
|
||||
);
|
||||
return res.json({ jsonrpc: '2.0', id, result });
|
||||
return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result });
|
||||
} catch (e: any) {
|
||||
// SEP-2164: unknown resources get -32602 with the requested uri in
|
||||
// data; the SDK's McpError does not populate data itself.
|
||||
const data =
|
||||
e.data ??
|
||||
(method === 'resources/read' ? { uri: params.uri } : undefined);
|
||||
return res.json({
|
||||
return sendStatelessJson(res, method, {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: e.code ?? -32603, message: e.message, data }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
* contents array for any resources/read request, violating the SEP-2164 MUST
|
||||
* NOT. The sep-2164-resource-not-found scenario should emit FAILURE for
|
||||
* sep-2164-no-empty-contents against this server.
|
||||
*
|
||||
* Responses are otherwise schema-valid (resultType and SEP-2549 caching hints present);
|
||||
* an empty contents array is schema-valid too, forbidden only by the SEP-2164 prose.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
|
|
@ -25,16 +28,37 @@ app.post('/mcp', (req, res) => {
|
|||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resultType: 'complete',
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private',
|
||||
supportedVersions: ['2026-07-28'],
|
||||
capabilities: { resources: {} },
|
||||
serverInfo: { name: 'sep-2164-empty-contents', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
case 'resources/list':
|
||||
return res.json({ jsonrpc: '2.0', id, result: { resources: [] } });
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resultType: 'complete',
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private',
|
||||
resources: []
|
||||
}
|
||||
});
|
||||
case 'resources/read':
|
||||
// Deliberately return an empty contents array instead of an error.
|
||||
return res.json({ jsonrpc: '2.0', id, result: { contents: [] } });
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
resultType: 'complete',
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private',
|
||||
contents: []
|
||||
}
|
||||
});
|
||||
default:
|
||||
return res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -11,6 +11,8 @@
|
|||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"commander": "^14.0.2",
|
||||
"eventsource-parser": "^3.0.8",
|
||||
"express": "^5.1.0",
|
||||
|
|
@ -2083,9 +2085,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@
|
|||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"commander": "^14.0.2",
|
||||
"eventsource-parser": "^3.0.8",
|
||||
"express": "^5.1.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
#!/usr/bin/env -S npx tsx
|
||||
/**
|
||||
* Vendor schema/{version}/schema.ts from the modelcontextprotocol spec repo
|
||||
* into src/spec-types/{version}.ts at a pinned SHA.
|
||||
* Vendor schema/{version}/schema.ts and schema/{version}/schema.json from the
|
||||
* modelcontextprotocol spec repo into src/spec-types/{version}.ts and
|
||||
* src/spec-types/{version}.schema.json at a pinned SHA.
|
||||
*
|
||||
* Usage: npm run sync-schema -- <sha-or-ref>
|
||||
*/
|
||||
|
|
@ -39,6 +40,11 @@ try {
|
|||
for (const v of VERSIONS) {
|
||||
copyFileSync(join(tmp, 'schema', v, 'schema.ts'), join(OUT_DIR, `${v}.ts`));
|
||||
console.log(` ${v} -> src/spec-types/${v}.ts`);
|
||||
copyFileSync(
|
||||
join(tmp, 'schema', v, 'schema.json'),
|
||||
join(OUT_DIR, `${v}.schema.json`)
|
||||
);
|
||||
console.log(` ${v} -> src/spec-types/${v}.schema.json`);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { isStatefulVersion, STATELESS_SPEC_VERSIONS } from './select';
|
|||
import { connectStateless } from './stateless';
|
||||
import { JsonRpcError } from './index';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../types';
|
||||
import { takeWireViolations } from '../validation/wire-schema';
|
||||
|
||||
describe('STATELESS_SPEC_VERSIONS', () => {
|
||||
it('contains exactly the versions isStatefulVersion rejects', () => {
|
||||
|
|
@ -37,7 +38,16 @@ describe('connectStateless', () => {
|
|||
|
||||
it('injects required _meta keys and MCP-Protocol-Version header', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ jsonrpc: '2.0', id: 1, result: { ok: true } })
|
||||
jsonResponse({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {
|
||||
tools: [],
|
||||
resultType: 'complete',
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private'
|
||||
}
|
||||
})
|
||||
);
|
||||
const conn = await connectStateless('http://test/mcp');
|
||||
await conn.request('tools/list');
|
||||
|
|
@ -76,6 +86,8 @@ describe('connectStateless', () => {
|
|||
);
|
||||
const conn = await connectStateless('http://test/mcp');
|
||||
await expect(conn.request('tools/list')).rejects.toThrow(/HTTP 502/);
|
||||
// The gateway body is deliberately not a JSON-RPC message.
|
||||
expect(takeWireViolations().violations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws a useful error for non-JSON non-SSE responses', async () => {
|
||||
|
|
@ -92,12 +104,15 @@ describe('connectStateless', () => {
|
|||
it('parses SSE: collects notifications and returns final result (LF)', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}\n\n',
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"done":true}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t1","progress":1}}\n\n',
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[],"resultType":"complete","done":true}}\n\n'
|
||||
])
|
||||
);
|
||||
const conn = await connectStateless('http://test/mcp');
|
||||
const result = await conn.request<{ done: boolean }>('tools/call', {});
|
||||
const result = await conn.request<{ done: boolean }>('tools/call', {
|
||||
name: 'echo',
|
||||
arguments: {}
|
||||
});
|
||||
expect(result.done).toBe(true);
|
||||
expect(conn.notifications).toHaveLength(1);
|
||||
expect(conn.notifications[0].method).toBe('notifications/progress');
|
||||
|
|
@ -106,21 +121,26 @@ describe('connectStateless', () => {
|
|||
it('parses SSE with CRLF line endings', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\r\n\r\n'
|
||||
'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[],"resultType":"complete","ok":true}}\r\n\r\n'
|
||||
])
|
||||
);
|
||||
const conn = await connectStateless('http://test/mcp');
|
||||
const result = await conn.request<{ ok: boolean }>('tools/call', {});
|
||||
const result = await conn.request<{ ok: boolean }>('tools/call', {
|
||||
name: 'echo',
|
||||
arguments: {}
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects server-to-client requests on the SSE stream', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
sseResponse([
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":99,"method":"elicitation/create","params":{}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":99,"method":"elicitation/create","params":{"message":"Name?","requestedSchema":{"type":"object","properties":{}}}}\n\n'
|
||||
])
|
||||
);
|
||||
const conn = await connectStateless('http://test/mcp');
|
||||
await expect(conn.request('tools/call', {})).rejects.toThrow(/MRTR/);
|
||||
await expect(
|
||||
conn.request('tools/call', { name: 'echo', arguments: {} })
|
||||
).rejects.toThrow(/MRTR/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,9 +6,17 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import {
|
||||
LoggingMessageNotificationSchema,
|
||||
ProgressNotificationSchema
|
||||
ProgressNotificationSchema,
|
||||
type JSONRPCMessage,
|
||||
type MessageExtraInfo
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
|
||||
import { LATEST_SPEC_VERSION, type SpecVersion } from '../types';
|
||||
import {
|
||||
validateWireMessage,
|
||||
type WireOrigin
|
||||
} from '../validation/wire-schema';
|
||||
import type { ConnectOptions } from './index';
|
||||
|
||||
const DEFAULT_CLIENT_INFO = {
|
||||
|
|
@ -26,21 +34,100 @@ export interface MCPClientConnection {
|
|||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Hook an SDK transport so every raw wire message, both directions (handshake included,
|
||||
* no bypass), is schema-validated. Outbound = harness origin, inbound = implementation;
|
||||
* request ids are tracked per direction to validate responses against typed results. */
|
||||
function instrumentTransport(
|
||||
transport: Transport,
|
||||
specVersion: SpecVersion
|
||||
): void {
|
||||
// Request id → method, per direction. An entry is consumed by the response
|
||||
// travelling the opposite way, so the maps stay bounded.
|
||||
const harnessRequests = new Map<string | number, string>();
|
||||
const implementationRequests = new Map<string | number, string>();
|
||||
|
||||
const validate = (message: unknown, origin: WireOrigin): void => {
|
||||
const ownRequests =
|
||||
origin === 'harness' ? harnessRequests : implementationRequests;
|
||||
const peerRequests =
|
||||
origin === 'harness' ? implementationRequests : harnessRequests;
|
||||
// `send` accepts arrays (2025-03-26 batches); validate each element with
|
||||
// its own classification, then the batch envelope itself.
|
||||
for (const m of Array.isArray(message) ? message : [message]) {
|
||||
const msg = (typeof m === 'object' && m !== null ? m : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const id = msg.id as string | number | undefined;
|
||||
let context: string;
|
||||
let requestMethod: string | undefined;
|
||||
if (typeof msg.method === 'string') {
|
||||
if (id !== undefined) {
|
||||
ownRequests.set(id, msg.method);
|
||||
context = `stateful request '${msg.method}'`;
|
||||
} else {
|
||||
context = `stateful notification '${msg.method}'`;
|
||||
}
|
||||
} else {
|
||||
requestMethod = id !== undefined ? peerRequests.get(id) : undefined;
|
||||
if (id !== undefined) peerRequests.delete(id);
|
||||
context = `stateful response to '${requestMethod ?? `id ${String(id)}`}'`;
|
||||
}
|
||||
validateWireMessage(specVersion, m, { origin, context, requestMethod });
|
||||
}
|
||||
if (Array.isArray(message)) {
|
||||
validateWireMessage(specVersion, message, {
|
||||
origin,
|
||||
context: 'stateful batch'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const originalSend = transport.send.bind(transport);
|
||||
transport.send = (message, options) => {
|
||||
validate(message, 'harness');
|
||||
return originalSend(message, options);
|
||||
};
|
||||
|
||||
// The SDK's protocol layer assigns `onmessage` during `client.connect()`;
|
||||
// intercept the assignment so the validator sees every inbound message
|
||||
// before the handler does.
|
||||
let inner:
|
||||
| ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void)
|
||||
| undefined;
|
||||
const wrapped = (message: JSONRPCMessage, extra?: MessageExtraInfo): void => {
|
||||
validate(message, 'implementation');
|
||||
inner?.(message, extra);
|
||||
};
|
||||
Object.defineProperty(transport, 'onmessage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => (inner === undefined ? undefined : wrapped),
|
||||
set: (handler: typeof inner) => {
|
||||
inner = handler;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and connect an MCP client to a server. `opts.capabilities` and
|
||||
* `opts.clientInfo` override the harness defaults — scenarios that
|
||||
* negotiate extensions (tasks, EMA, ...) pass them through to drive a
|
||||
* conformant `initialize`.
|
||||
* conformant `initialize`. The transport is instrumented so every wire
|
||||
* message in both directions is validated against `specVersion`'s spec
|
||||
* JSON schema; scenarios that call this directly pass `ctx.specVersion`.
|
||||
*/
|
||||
export async function connectToServer(
|
||||
serverUrl: string,
|
||||
opts: ConnectOptions = {}
|
||||
opts: ConnectOptions = {},
|
||||
specVersion: SpecVersion = LATEST_SPEC_VERSION
|
||||
): Promise<MCPClientConnection> {
|
||||
const client = new Client(opts.clientInfo ?? DEFAULT_CLIENT_INFO, {
|
||||
capabilities: opts.capabilities ?? DEFAULT_CAPABILITIES
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
instrumentTransport(transport, specVersion);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,11 +43,11 @@ export const STATELESS_SPEC_VERSIONS: readonly SpecVersion[] =
|
|||
export function connectFor(
|
||||
specVersion: SpecVersion
|
||||
): (serverUrl: string, opts?: ConnectOptions) => Promise<Connection> {
|
||||
// Pass the version through so requests declare (and are wire-schema
|
||||
// validated against) the spec version the run was invoked with.
|
||||
return isStatefulVersion(specVersion)
|
||||
? connectStateful
|
||||
: // Pass the version through so stateless requests declare the spec
|
||||
// version the run was invoked with (matters under --force).
|
||||
(serverUrl, opts) => connectStateless(serverUrl, specVersion, opts);
|
||||
? (serverUrl, opts) => connectStateful(serverUrl, opts, specVersion)
|
||||
: (serverUrl, opts) => connectStateless(serverUrl, specVersion, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,23 +15,29 @@ import {
|
|||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { connectToServer } from './sdk-client';
|
||||
import type { JSONRPCNotification } from '../spec-types/2025-11-25';
|
||||
import { LATEST_SPEC_VERSION, type SpecVersion } from '../types';
|
||||
import { JsonRpcError, type Connection, type ConnectOptions } from './index';
|
||||
|
||||
export async function connectStateful(
|
||||
serverUrl: string,
|
||||
opts: ConnectOptions = {}
|
||||
opts: ConnectOptions = {},
|
||||
specVersion: SpecVersion = LATEST_SPEC_VERSION
|
||||
): Promise<Connection> {
|
||||
const { client, close } = await connectToServer(serverUrl, opts);
|
||||
// Wire-schema validation happens inside connectToServer, which hooks the
|
||||
// SDK transport's send/onmessage so the real bytes of every message in
|
||||
// both directions are validated — no per-call choke points needed here.
|
||||
const { client, close } = await connectToServer(serverUrl, opts, specVersion);
|
||||
|
||||
const notifications: JSONRPCNotification[] = [];
|
||||
const collect = (n: unknown) => {
|
||||
// The SDK's Zod parsing strips the jsonrpc field; restore it so collected
|
||||
// notifications match the JSONRPCNotification wire shape, as
|
||||
// connectStateless provides.
|
||||
notifications.push({
|
||||
// notifications match the wire shape connectStateless provides (the raw
|
||||
// notification was already validated by the transport hook).
|
||||
const notification = {
|
||||
jsonrpc: '2.0',
|
||||
...(n as object)
|
||||
} as JSONRPCNotification);
|
||||
} as JSONRPCNotification;
|
||||
notifications.push(notification);
|
||||
};
|
||||
// The SDK pre-registers a handler for notifications/progress (to drive the
|
||||
// onprogress callback feature), so it never reaches the fallback. Register
|
||||
|
|
@ -73,7 +79,8 @@ export async function connectStateful(
|
|||
);
|
||||
}
|
||||
try {
|
||||
return (await client.request({ method, params }, ResultSchema)) as R;
|
||||
const result = await client.request({ method, params }, ResultSchema);
|
||||
return result as R;
|
||||
} catch (e) {
|
||||
// Normalize so scenarios always see JsonRpcError regardless of impl.
|
||||
// The SDK prefixes messages with "MCP error <code>: "; strip it so
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
DEFAULT_CLIENT_CAPABILITIES
|
||||
} from './stateless';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../types';
|
||||
import { takeWireViolations } from '../validation/wire-schema';
|
||||
|
||||
describe('buildStandardHeaders', () => {
|
||||
test('sets the standard headers pinned to the draft protocol version', () => {
|
||||
|
|
@ -87,7 +88,12 @@ describe('sendStatelessRequest', () => {
|
|||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: { tools: [] }
|
||||
result: {
|
||||
tools: [],
|
||||
resultType: 'complete',
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private'
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -100,7 +106,12 @@ describe('sendStatelessRequest', () => {
|
|||
'tools/list'
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body?.result).toEqual({ tools: [] });
|
||||
expect(response.body?.result).toEqual({
|
||||
tools: [],
|
||||
resultType: 'complete',
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private'
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
|
@ -151,6 +162,8 @@ describe('connectStateless', () => {
|
|||
await expect(conn.request('tools/list')).rejects.toThrow(
|
||||
/HTTP 502.*upstream timeout/
|
||||
);
|
||||
// The proxy body is deliberately not a JSON-RPC message.
|
||||
expect(takeWireViolations().violations).toHaveLength(1);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types';
|
||||
import type { JSONRPCNotification } from '../spec-types/2025-11-25';
|
||||
import { validateWireMessage } from '../validation/wire-schema';
|
||||
import { JsonRpcError, type Connection, type ConnectOptions } from './index';
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
|
|
@ -242,19 +243,47 @@ export async function sendStatelessRequest(
|
|||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
specVersion?: SpecVersion;
|
||||
/** Skip wire-schema validation of the *outgoing request* only (for intentionally
|
||||
* malformed traffic). Responses stay validated, with one carve-out: JSON-RPC 2.0
|
||||
* requires `id: null` on errors for unprocessable requests, which RequestId forbids. */
|
||||
skipValidation?: boolean;
|
||||
} = {}
|
||||
): Promise<StatelessResponse> {
|
||||
const id = nextRequestId++;
|
||||
const specVersion = options.specVersion ?? DRAFT_PROTOCOL_VERSION;
|
||||
const headers = buildStandardHeaders(method, params, {
|
||||
headers: options.headers,
|
||||
specVersion: options.specVersion
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
const request = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params: withRequestMeta(params, options.specVersion)
|
||||
};
|
||||
if (!options.skipValidation) {
|
||||
validateWireMessage(specVersion, request, {
|
||||
origin: 'harness',
|
||||
context: `stateless request '${method}'`
|
||||
});
|
||||
}
|
||||
const body = JSON.stringify(request);
|
||||
|
||||
// See the `skipValidation` doc above: on a skip-validated request, tolerate the
|
||||
// JSON-RPC 2.0 `id: null` an error response must carry when the request could not be
|
||||
// processed, by validating the rest of the shape with the request's real id substituted.
|
||||
const withNullIdCarveOut = (event: unknown): unknown => {
|
||||
if (
|
||||
options.skipValidation &&
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
(event as Record<string, unknown>).error !== undefined &&
|
||||
(event as Record<string, unknown>).id === null
|
||||
) {
|
||||
return { ...(event as Record<string, unknown>), id };
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
|
|
@ -275,6 +304,13 @@ export async function sendStatelessRequest(
|
|||
// Read the stream incrementally and resolve on the matching response —
|
||||
// a server that keeps the stream open must not stall the harness.
|
||||
const { events, body: matched } = await readSseJsonRpcResponse(res, id);
|
||||
for (const event of events) {
|
||||
validateWireMessage(specVersion, withNullIdCarveOut(event), {
|
||||
origin: 'implementation',
|
||||
context: `SSE event during '${method}'`,
|
||||
requestMethod: event === matched ? method : undefined
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
|
|
@ -286,11 +322,19 @@ export async function sendStatelessRequest(
|
|||
|
||||
const text = await res.text();
|
||||
try {
|
||||
const parsed = text ? (JSON.parse(text) as JsonRpcResponse) : undefined;
|
||||
if (parsed !== undefined) {
|
||||
validateWireMessage(specVersion, withNullIdCarveOut(parsed), {
|
||||
origin: 'implementation',
|
||||
context: `response to '${method}'`,
|
||||
requestMethod: method
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
contentType,
|
||||
body: text ? (JSON.parse(text) as JsonRpcResponse) : undefined
|
||||
body: parsed
|
||||
};
|
||||
} catch {
|
||||
return { status: res.status, headers: res.headers, contentType, text };
|
||||
|
|
|
|||
13
src/index.ts
13
src/index.ts
|
|
@ -43,6 +43,7 @@ import {
|
|||
ServerOptionsSchema
|
||||
} from './schemas';
|
||||
import type { AuthorizationServerOptions } from './schemas';
|
||||
import { withWireRecorder } from './validation/wire-schema';
|
||||
import {
|
||||
loadExpectedFailures,
|
||||
evaluateBaseline,
|
||||
|
|
@ -159,13 +160,17 @@ program
|
|||
const results = await Promise.all(
|
||||
scenarios.map(async (scenarioName) => {
|
||||
try {
|
||||
const result = await runConformanceTest(
|
||||
// Each concurrent scenario records wire violations into its own
|
||||
// AsyncLocalStorage-scoped recorder (see withWireRecorder).
|
||||
const result = await withWireRecorder(() =>
|
||||
runConformanceTest(
|
||||
options.command,
|
||||
scenarioName,
|
||||
timeout,
|
||||
outputDir,
|
||||
specVersionFilter,
|
||||
options.force ?? false
|
||||
)
|
||||
);
|
||||
return {
|
||||
scenario: scenarioName,
|
||||
|
|
@ -452,11 +457,15 @@ program
|
|||
for (const scenarioName of scenarios) {
|
||||
console.log(`\n=== Running scenario: ${scenarioName} ===`);
|
||||
try {
|
||||
const result = await runServerConformanceTest(
|
||||
// Sequential, but a failed scenario can leak a connection that
|
||||
// emits late traffic; scoping keeps it out of the next scenario.
|
||||
const result = await withWireRecorder(() =>
|
||||
runServerConformanceTest(
|
||||
validated.url,
|
||||
scenarioName,
|
||||
outputDir,
|
||||
specVersionFilter
|
||||
)
|
||||
);
|
||||
allResults.push({ scenario: scenarioName, checks: result.checks });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from './stateless';
|
||||
import { STATELESS_SPEC_VERSIONS } from '../connection/select';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../types';
|
||||
import { takeWireViolations } from '../validation/wire-schema';
|
||||
|
||||
const meta = {
|
||||
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
|
||||
|
|
@ -180,9 +181,33 @@ describe('withRequiredDraftResultFields', () => {
|
|||
});
|
||||
|
||||
describe('createServerFor', () => {
|
||||
it('returns stateful for dated 2025-x versions', () => {
|
||||
expect(createServerFor('2025-06-18')).toBe(createServerStateful);
|
||||
expect(createServerFor('2025-11-25')).toBe(createServerStateful);
|
||||
it('returns a stateful factory for dated 2025-x versions', async () => {
|
||||
const srv = await createServerFor('2025-11-25')({
|
||||
'tools/list': () => ({ tools: [] })
|
||||
});
|
||||
try {
|
||||
// Only the stateful (initialize handshake) impl answers initialize.
|
||||
const r = await fetch(srv.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 't', version: '1' }
|
||||
}
|
||||
})
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
it('returns a stateless factory bound to the requested version', async () => {
|
||||
const srv = await createServerFor(DRAFT_PROTOCOL_VERSION)({});
|
||||
|
|
@ -232,6 +257,8 @@ describe('createServerStateless', () => {
|
|||
);
|
||||
expect(status).toBe(400);
|
||||
expect(body.error.code).toBe(-32602);
|
||||
// The request is deliberately schema-invalid (missing params._meta).
|
||||
expect(takeWireViolations().violations).toHaveLength(1);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
|
|
@ -307,7 +334,9 @@ describe('createServerStateless', () => {
|
|||
|
||||
it('routes to handlers and records requests', async () => {
|
||||
const srv = await createServerStateless({
|
||||
'tools/list': () => ({ tools: [{ name: 'x' }] })
|
||||
'tools/list': () => ({
|
||||
tools: [{ name: 'x', inputSchema: { type: 'object' } }]
|
||||
})
|
||||
});
|
||||
try {
|
||||
const { body } = await post(
|
||||
|
|
@ -338,6 +367,8 @@ describe('createServerStateless', () => {
|
|||
);
|
||||
expect(status).toBe(400);
|
||||
expect(srv.recorded.map((r) => r.method)).toEqual(['tools/list']);
|
||||
// The request is deliberately schema-invalid (missing params._meta).
|
||||
expect(takeWireViolations().violations).toHaveLength(1);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
|
|
@ -365,7 +396,9 @@ describe('createServerStateless', () => {
|
|||
|
||||
it('stamps the draft-required result members onto handler results', async () => {
|
||||
const srv = await createServerStateless({
|
||||
'tools/list': () => ({ tools: [{ name: 'x' }] }),
|
||||
'tools/list': () => ({
|
||||
tools: [{ name: 'x', inputSchema: { type: 'object' } }]
|
||||
}),
|
||||
'tools/call': () => ({ content: [{ type: 'text', text: 'ok' }] })
|
||||
});
|
||||
try {
|
||||
|
|
@ -415,7 +448,7 @@ describe('createServerStateless', () => {
|
|||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { _meta: meta }
|
||||
params: { _meta: meta, name: 'x' }
|
||||
},
|
||||
headers
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@ export function createServerFor(
|
|||
specVersion: SpecVersion
|
||||
): (handlers: RequestHandlers) => Promise<MockServer> {
|
||||
return isStatefulVersion(specVersion)
|
||||
? createServerStateful
|
||||
? (handlers) => createServerStateful(handlers, specVersion)
|
||||
: (handlers) => createServerStateless(handlers, specVersion);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|||
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
import type { JSONRPCRequest } from '../spec-types/2025-11-25';
|
||||
import { LATEST_SPEC_VERSION, type SpecVersion } from '../types';
|
||||
import { validateWireMessage } from '../validation/wire-schema';
|
||||
import type { MockServer, RequestHandlers } from './index';
|
||||
|
||||
const CAPABILITY_BY_PREFIX: Record<string, string> = {
|
||||
|
|
@ -40,7 +42,8 @@ export function capabilitiesFromHandlers(
|
|||
}
|
||||
|
||||
export async function createServerStateful(
|
||||
handlers: RequestHandlers
|
||||
handlers: RequestHandlers,
|
||||
specVersion: SpecVersion = LATEST_SPEC_VERSION
|
||||
): Promise<MockServer> {
|
||||
const recorded: JSONRPCRequest[] = [];
|
||||
const capabilities = capabilitiesFromHandlers(handlers);
|
||||
|
|
@ -61,10 +64,22 @@ export async function createServerStateful(
|
|||
});
|
||||
server.setRequestHandler(schema, async (request) => {
|
||||
try {
|
||||
return (await handler(
|
||||
const result = (await handler(
|
||||
(request.params ?? {}) as Record<string, unknown>,
|
||||
request as JSONRPCRequest
|
||||
)) as Record<string, unknown>;
|
||||
// The SDK builds the response envelope; validate the
|
||||
// harness-authored result against its typed definition.
|
||||
validateWireMessage(
|
||||
specVersion,
|
||||
{ jsonrpc: '2.0', id: 0, result },
|
||||
{
|
||||
origin: 'harness',
|
||||
context: `stateful mock result for '${method}'`,
|
||||
requestMethod: method
|
||||
}
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof McpError) throw e;
|
||||
throw new McpError(
|
||||
|
|
@ -85,6 +100,10 @@ export async function createServerStateful(
|
|||
// preamble) at the HTTP layer so unregistered methods are captured too,
|
||||
// matching the stateless impl and the MockServer.recorded contract.
|
||||
const body = req.body;
|
||||
validateWireMessage(specVersion, body, {
|
||||
origin: 'implementation',
|
||||
context: `client request '${body?.method ?? '(unknown)'}' to stateful mock`
|
||||
});
|
||||
if (
|
||||
body?.method &&
|
||||
body.method !== 'initialize' &&
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@
|
|||
*/
|
||||
|
||||
import express from 'express';
|
||||
import type { SpecVersion } from '../types';
|
||||
import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types';
|
||||
import type { JSONRPCRequest } from '../spec-types/2025-11-25';
|
||||
import type { MockServer, RequestHandlers } from './index';
|
||||
import { STATELESS_SPEC_VERSIONS } from '../connection/select';
|
||||
import { validateWireMessage } from '../validation/wire-schema';
|
||||
import { capabilitiesFromHandlers } from './stateful';
|
||||
|
||||
/**
|
||||
|
|
@ -190,6 +191,7 @@ export async function createServerStateless(
|
|||
const supportedVersions: readonly string[] = specVersion
|
||||
? [specVersion]
|
||||
: STATELESS_SPEC_VERSIONS;
|
||||
const wireVersion = specVersion ?? DRAFT_PROTOCOL_VERSION;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
|
@ -200,16 +202,32 @@ export async function createServerStateless(
|
|||
// requests are captured too, matching the stateful impl and the
|
||||
// MockServer.recorded contract.
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
validateWireMessage(wireVersion, req.body, {
|
||||
origin: 'implementation',
|
||||
context: `client request '${body?.method ?? '(unknown)'}' to stateless mock`
|
||||
});
|
||||
if (body?.method && body.method !== 'server/discover') {
|
||||
recorded.push(req.body as JSONRPCRequest);
|
||||
}
|
||||
const sendJson = (status: number, payload: object, method?: string) => {
|
||||
validateWireMessage(wireVersion, payload, {
|
||||
origin: 'harness',
|
||||
context: `stateless mock response${method ? ` to '${method}'` : ''}`,
|
||||
requestMethod: method
|
||||
});
|
||||
return res.status(status).json(payload);
|
||||
};
|
||||
const v = validateStatelessRequest(req, capabilities, supportedVersions);
|
||||
if (v.kind !== 'route') {
|
||||
return res.status(v.status).json(v.body);
|
||||
return sendJson(
|
||||
v.status,
|
||||
v.body,
|
||||
v.kind === 'handled' ? (body?.method as string) : undefined
|
||||
);
|
||||
}
|
||||
const { id, method, params } = v;
|
||||
const error = (status: number, code: number, message: string) =>
|
||||
res.status(status).json({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
sendJson(status, { jsonrpc: '2.0', id, error: { code, message } });
|
||||
|
||||
const handler = handlers[method];
|
||||
if (!handler) {
|
||||
|
|
@ -217,11 +235,15 @@ export async function createServerStateless(
|
|||
}
|
||||
try {
|
||||
const result = await handler(params, req.body as JSONRPCRequest);
|
||||
return res.json({
|
||||
return sendJson(
|
||||
200,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: withRequiredDraftResultFields(method, result)
|
||||
});
|
||||
},
|
||||
method
|
||||
);
|
||||
} catch (e) {
|
||||
return error(500, -32603, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import {
|
|||
} from '../types';
|
||||
import { getScenario, isScenarioApplicableAt } from '../scenarios';
|
||||
import { createServerFor, type ScenarioContext } from '../mock-server';
|
||||
import {
|
||||
resetWireValidation,
|
||||
wireSchemaChecks
|
||||
} from '../validation/wire-schema';
|
||||
import { createResultDir, formatPrettyChecks } from './utils';
|
||||
|
||||
export interface ClientExecutionResult {
|
||||
|
|
@ -182,6 +186,7 @@ export async function runConformanceTest(
|
|||
};
|
||||
|
||||
console.error(`Starting scenario: ${scenarioName}`);
|
||||
resetWireValidation();
|
||||
const urls = await scenario.start(ctx);
|
||||
|
||||
console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`);
|
||||
|
|
@ -215,6 +220,7 @@ export async function runConformanceTest(
|
|||
}
|
||||
|
||||
const checks = scenario.getChecks();
|
||||
checks.push(...wireSchemaChecks(resolvedVersion));
|
||||
|
||||
if (resultDir) {
|
||||
await fs.writeFile(
|
||||
|
|
@ -366,6 +372,7 @@ export async function runInteractiveMode(
|
|||
};
|
||||
|
||||
console.log(`Starting scenario: ${scenarioName}`);
|
||||
resetWireValidation();
|
||||
const urls = await scenario.start(ctx);
|
||||
|
||||
console.log(`Server URL: ${urls.serverUrl}`);
|
||||
|
|
@ -375,6 +382,7 @@ export async function runInteractiveMode(
|
|||
console.log('\nShutting down...');
|
||||
|
||||
const checks = scenario.getChecks();
|
||||
checks.push(...wireSchemaChecks(resolvedVersion));
|
||||
|
||||
if (resultDir) {
|
||||
await fs.writeFile(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {
|
|||
} from '../types';
|
||||
import { getClientScenario, isScenarioApplicableAt } from '../scenarios';
|
||||
import { connectFor, type RunContext } from '../connection';
|
||||
import {
|
||||
resetWireValidation,
|
||||
wireSchemaChecks
|
||||
} from '../validation/wire-schema';
|
||||
import { createResultDir, formatPrettyChecks } from './utils';
|
||||
|
||||
/**
|
||||
|
|
@ -93,7 +97,9 @@ export async function runServerConformanceTest(
|
|||
specVersion: resolvedSpecVersion,
|
||||
connect: (opts) => connectFor(resolvedSpecVersion)(serverUrl, opts)
|
||||
};
|
||||
resetWireValidation();
|
||||
const checks = await scenario.run(ctx);
|
||||
checks.push(...wireSchemaChecks(resolvedSpecVersion));
|
||||
|
||||
if (resultDir) {
|
||||
await fs.writeFile(
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Implement a tool named \`test_elicitation_sep1034_defaults\` (no arguments) that
|
|||
const checks: ConformanceCheck[] = [];
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const connection = await connectToServer(serverUrl, {}, ctx.specVersion);
|
||||
|
||||
let capturedRequest: any = null;
|
||||
connection.client.setRequestHandler(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue