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:
claude[bot] 2026-07-27 15:58:43 +01:00 • committed by GitHub
commit 89c2c0452e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 14136 additions and 129 deletions

View file

@ -0,0 +1,23 @@
// Vitest-wide guard: in self-tests both sides of the wire are harness-authored, so any
// wire-schema violation is a harness bug — fail the test loudly. Tests that intentionally
// send nonconformant traffic drain the recorder with `takeWireViolations()` before ending.
import { afterEach, beforeEach } from 'vitest';
import { formatWireViolation, takeWireViolations } from './wire-schema';
beforeEach(() => {
// Discard anything recorded between tests (e.g. by a mock server still
// answering a straggling request from the previous test).
takeWireViolations();
});
afterEach(() => {
const { violations } = takeWireViolations();
if (violations.length > 0) {
throw new Error(
'Wire messages violated the spec JSON schema during this test ' +
'(intentional? drain with takeWireViolations()):\n ' +
violations.map(formatWireViolation).join('\n ')
);
}
});

View file

@ -0,0 +1,509 @@
import { createServer, type Server } from 'http';
import { DRAFT_PROTOCOL_VERSION } from '../types';
import { sendStatelessRequest } from '../connection/stateless';
import {
resetWireValidation,
specDispatchMaps,
takeWireViolations,
validateWireMessage,
wireSchemaChecks,
wireSchemaErrors,
withWireRecorder
} from './wire-schema';
const META = {
'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION,
'io.modelcontextprotocol/clientInfo': { name: 'c', version: '1' },
'io.modelcontextprotocol/clientCapabilities': { sampling: {} }
};
/** The exact hallucinated shape PR #376 fixed: an array instead of a
* ClientCapabilities object in error.data.requiredCapabilities. */
const PR_376_INVALID_ERROR = {
jsonrpc: '2.0',
id: 1,
error: {
code: -32021,
message: 'Server requires the sampling capability',
data: { requiredCapabilities: ['sampling'] }
}
};
function listen(
handler: (body: string) => { status: number; body: unknown }
): Promise<{ url: string; server: Server }> {
return new Promise((resolve) => {
const server = createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const { status, body } = handler(raw);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
});
});
server.listen(0, () => {
const addr = server.address() as { port: number };
resolve({ url: `http://localhost:${addr.port}/mcp`, server });
});
});
}
describe('wireSchemaErrors', () => {
it('rejects the PR #376 hallucinated requiredCapabilities array under the draft schema', () => {
const errors = wireSchemaErrors(
DRAFT_PROTOCOL_VERSION,
PR_376_INVALID_ERROR
);
expect(errors).toEqual([
expect.stringContaining(
'MissingRequiredClientCapabilityError/error/data/requiredCapabilities: must be object'
)
]);
});
it('accepts the spec-valid ClientCapabilities object shape', () => {
expect(
wireSchemaErrors(DRAFT_PROTOCOL_VERSION, {
jsonrpc: '2.0',
id: 1,
error: {
code: -32021,
message: 'Server requires the sampling capability',
data: { requiredCapabilities: { sampling: {} } }
}
})
).toEqual([]);
});
it('rejects messages that are not JSON-RPC at all', () => {
expect(wireSchemaErrors('2025-06-18', { hello: 'world' })).toEqual([
expect.stringContaining('JSONRPCMessage')
]);
});
it('validates typed requests by method const', () => {
expect(
wireSchemaErrors(DRAFT_PROTOCOL_VERSION, {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { _meta: META }
})
).toEqual([
expect.stringContaining(
"CallToolRequest/params: must have required property 'name'"
)
]);
});
it('validates results against the typed result definition for the request method', () => {
// Draft results must carry resultType / ttlMs / cacheScope.
const errors = wireSchemaErrors(
DRAFT_PROTOCOL_VERSION,
{ jsonrpc: '2.0', id: 3, result: { tools: [] } },
'tools/list'
);
expect(errors.join('\n')).toContain('ListToolsResult');
// The same result is valid at 2025-11-25, which has no such requirement.
expect(
wireSchemaErrors(
'2025-11-25',
{ jsonrpc: '2.0', id: 3, result: { tools: [] } },
'tools/list'
)
).toEqual([]);
});
it('accepts a JSON-RPC batch under 2025-03-26 and reports per-element errors', () => {
expect(
wireSchemaErrors('2025-03-26', [
{ jsonrpc: '2.0', id: 1, method: 'ping' },
{ jsonrpc: '2.0', method: 'notifications/initialized' }
])
).toEqual([]);
expect(
wireSchemaErrors('2025-03-26', [
{ jsonrpc: '2.0', id: 1, method: 'ping' },
{ nope: true }
])
).toEqual([expect.stringContaining('[1] JSONRPCMessage')]);
});
});
describe('wire-schema choke points and checks', () => {
it('records a failing wire-schema-valid check when the implementation sends the PR #376 shape', async () => {
const { url, server } = await listen(() => ({
status: 400,
body: PR_376_INVALID_ERROR
}));
try {
resetWireValidation();
await sendStatelessRequest(url, 'tools/list');
const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION);
const wire = checks.find((c) => c.id === 'wire-schema-valid');
expect(wire?.status).toBe('FAILURE');
expect(wire?.errorMessage).toContain('requiredCapabilities');
// The invalid message came from the (mock) implementation, not the
// harness — no harness-error check.
expect(
checks.find((c) => c.id === 'wire-schema-harness-error')
).toBeUndefined();
} finally {
server.close();
}
});
it('records a hard harness error when the harness itself sends an invalid message', async () => {
const { url, server } = await listen(() => ({
status: 200,
body: {
jsonrpc: '2.0',
id: 1,
result: { resultType: 'complete', content: [] }
}
}));
try {
resetWireValidation();
// tools/call without params.name violates CallToolRequest.
await sendStatelessRequest(url, 'tools/call', { arguments: {} });
const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION);
const harness = checks.find((c) => c.id === 'wire-schema-harness-error');
expect(harness?.status).toBe('FAILURE');
expect(harness?.errorMessage).toContain('HARNESS ERROR');
expect(harness?.errorMessage).toContain("required property 'name'");
} finally {
server.close();
}
});
it('skips only the request direction for a call that opts out via skipValidation', async () => {
const { url, server } = await listen(() => ({
status: 400,
body: PR_376_INVALID_ERROR
}));
try {
resetWireValidation();
// The deliberately-malformed request is not validated, but the
// implementation's response still is — and here it is invalid.
await sendStatelessRequest(
url,
'tools/call',
{ arguments: {} },
{ skipValidation: true }
);
const { violations, observed } = takeWireViolations();
expect(observed).toBe(1);
expect(violations).toHaveLength(1);
expect(violations[0].origin).toBe('implementation');
expect(violations[0].errors.join('\n')).toContain('requiredCapabilities');
} finally {
server.close();
}
});
it('tolerates a JSON-RPC 2.0 `id: null` error response only on skip-validated requests', async () => {
// JSON-RPC 2.0 requires id: null on error responses to requests that
// could not be processed; the MCP schema's RequestId forbids null.
const { url, server } = await listen(() => ({
status: 400,
body: {
jsonrpc: '2.0',
id: null,
error: { code: -32600, message: 'Invalid Request' }
}
}));
try {
resetWireValidation();
await sendStatelessRequest(
url,
'tools/call',
{ arguments: {} },
{ skipValidation: true }
);
const skipped = takeWireViolations();
expect(skipped.observed).toBe(1);
expect(skipped.violations).toEqual([]);
// The carve-out is narrow: the same response on a normal (validated)
// call is still a violation.
await sendStatelessRequest(url, 'tools/list');
const normal = takeWireViolations();
expect(
normal.violations.filter((v) => v.origin === 'implementation')
).toHaveLength(1);
} finally {
server.close();
}
});
it('emits a passing wire-schema-valid check for clean traffic', async () => {
const { url, server } = await listen(() => ({
status: 200,
body: {
jsonrpc: '2.0',
id: 1,
result: {
resultType: 'complete',
ttlMs: 0,
cacheScope: 'private',
tools: []
}
}
}));
try {
resetWireValidation();
await sendStatelessRequest(url, 'tools/list');
const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION);
expect(checks).toHaveLength(1);
expect(checks[0].id).toBe('wire-schema-valid');
expect(checks[0].status).toBe('SUCCESS');
expect(checks[0].details?.messagesValidated).toBe(2);
} finally {
server.close();
}
});
it('emits no checks when no wire traffic was observed', () => {
resetWireValidation();
expect(wireSchemaChecks(DRAFT_PROTOCOL_VERSION)).toEqual([]);
});
});
describe('specDispatchMaps', () => {
// Pin the dispatch maps extracted from each vendored schema: if a schema sync
// restructures the `method`/`error.code` consts the extraction walks, validation
// silently degrades to envelope-only. These pins make that loud instead.
const METHOD_DEFS_2025_03_26: Record<string, string> = {
'tools/call': 'CallToolRequest',
'notifications/cancelled': 'CancelledNotification',
'completion/complete': 'CompleteRequest',
'sampling/createMessage': 'CreateMessageRequest',
'prompts/get': 'GetPromptRequest',
initialize: 'InitializeRequest',
'notifications/initialized': 'InitializedNotification',
'prompts/list': 'ListPromptsRequest',
'resources/templates/list': 'ListResourceTemplatesRequest',
'resources/list': 'ListResourcesRequest',
'roots/list': 'ListRootsRequest',
'tools/list': 'ListToolsRequest',
'notifications/message': 'LoggingMessageNotification',
ping: 'PingRequest',
'notifications/progress': 'ProgressNotification',
'notifications/prompts/list_changed': 'PromptListChangedNotification',
'resources/read': 'ReadResourceRequest',
'notifications/resources/list_changed': 'ResourceListChangedNotification',
'notifications/resources/updated': 'ResourceUpdatedNotification',
'notifications/roots/list_changed': 'RootsListChangedNotification',
'logging/setLevel': 'SetLevelRequest',
'resources/subscribe': 'SubscribeRequest',
'notifications/tools/list_changed': 'ToolListChangedNotification',
'resources/unsubscribe': 'UnsubscribeRequest'
};
// 2025-06-18 adds elicitation on top of 2025-03-26.
const METHOD_DEFS_2025_06_18: Record<string, string> = {
...METHOD_DEFS_2025_03_26,
'elicitation/create': 'ElicitRequest'
};
// 2025-11-25 adds tasks (SEP-1686) and the elicitation-complete
// notification on top of 2025-06-18.
const METHOD_DEFS_2025_11_25: Record<string, string> = {
...METHOD_DEFS_2025_06_18,
'tasks/cancel': 'CancelTaskRequest',
'notifications/elicitation/complete': 'ElicitationCompleteNotification',
'tasks/result': 'GetTaskPayloadRequest',
'tasks/get': 'GetTaskRequest',
'tasks/list': 'ListTasksRequest',
'notifications/tasks/status': 'TaskStatusNotification'
};
// The draft (SEP-2575) drops the initialize/session lifecycle, ping,
// logging/setLevel, roots-changed, tasks, and resource subscriptions, and
// adds server/discover plus subscriptions/listen.
const METHOD_DEFS_DRAFT: Record<string, string> = (() => {
const defs: Record<string, string> = {
...METHOD_DEFS_2025_11_25,
'server/discover': 'DiscoverRequest',
'subscriptions/listen': 'SubscriptionsListenRequest',
'notifications/subscriptions/acknowledged':
'SubscriptionsAcknowledgedNotification'
};
for (const removed of [
'initialize',
'notifications/initialized',
'ping',
'logging/setLevel',
'notifications/roots/list_changed',
'resources/subscribe',
'resources/unsubscribe',
'tasks/cancel',
'notifications/elicitation/complete',
'tasks/result',
'tasks/get',
'tasks/list',
'notifications/tasks/status'
]) {
delete defs[removed];
}
return defs;
})();
it.each([
['2025-03-26', METHOD_DEFS_2025_03_26, {}],
['2025-06-18', METHOD_DEFS_2025_06_18, {}],
[
'2025-11-25',
METHOD_DEFS_2025_11_25,
{ '-32042': 'URLElicitationRequiredError' }
],
[
DRAFT_PROTOCOL_VERSION,
METHOD_DEFS_DRAFT,
{
'-32020': 'HeaderMismatchError',
'-32021': 'MissingRequiredClientCapabilityError',
'-32022': 'UnsupportedProtocolVersionError'
}
]
] as const)(
'extracts the expected methodDefs and errorDefs for %s',
(version, expectedMethods, expectedErrors) => {
const { methodDefs, errorDefs, resultDefs } = specDispatchMaps(
version as never
);
expect(Object.fromEntries(methodDefs)).toEqual(expectedMethods);
expect(
Object.fromEntries(
[...errorDefs].map(([code, def]) => [String(code), def])
)
).toEqual(expectedErrors);
// Every XxxRequest with a schema-defined XxxResult must be paired.
expect(resultDefs.get('tools/call')).toBe('CallToolResult');
expect(resultDefs.get('tools/list')).toBe('ListToolsResult');
}
);
it('pairs elicitation/create with ElicitResult from 2025-06-18 on (the #376 class)', () => {
expect(
specDispatchMaps('2025-03-26').resultDefs.get('elicitation/create')
).toBeUndefined();
expect(
specDispatchMaps('2025-06-18').resultDefs.get('elicitation/create')
).toBe('ElicitResult');
expect(
specDispatchMaps('2025-11-25').resultDefs.get('elicitation/create')
).toBe('ElicitResult');
expect(
specDispatchMaps(DRAFT_PROTOCOL_VERSION).resultDefs.get(
'elicitation/create'
)
).toBe('ElicitResult');
expect(
specDispatchMaps(DRAFT_PROTOCOL_VERSION).resultDefs.get('server/discover')
).toBe('DiscoverResult');
});
});
describe('withWireRecorder', () => {
it('keeps concurrent scenario recorders isolated (suite runs)', async () => {
const { url, server } = await listen(() => ({
status: 400,
body: PR_376_INVALID_ERROR
}));
try {
const scope = () =>
withWireRecorder(async () => {
await sendStatelessRequest(url, 'tools/list');
return takeWireViolations();
});
// With a shared recorder one scope would drain both violations and the
// other none; scoped recorders see exactly their own.
const [a, b] = await Promise.all([scope(), scope()]);
expect(a.violations).toHaveLength(1);
expect(b.violations).toHaveLength(1);
expect(takeWireViolations().observed).toBe(0);
} finally {
server.close();
}
});
it('propagates the scoped recorder into handlers of servers created in-scope', async () => {
// Mirrors the mock-server flow: the server is created inside the scenario
// scope, but requests arrive from outside it (the client subprocess).
let resolveUrl!: (url: string) => void;
const urlReady = new Promise<string>((r) => (resolveUrl = r));
let resolveRequestDone!: () => void;
const requestDone = new Promise<void>((r) => (resolveRequestDone = r));
const scopeResult = withWireRecorder(async () => {
const server = createServer((_req, res) => {
validateWireMessage(DRAFT_PROTOCOL_VERSION, PR_376_INVALID_ERROR, {
origin: 'implementation',
context: 'inbound message to in-scope server'
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
await new Promise<void>((r) => {
server.listen(0, () => {
const addr = server.address() as { port: number };
resolveUrl(`http://localhost:${addr.port}/`);
r();
});
});
await requestDone;
const drained = takeWireViolations();
server.close();
return drained;
});
await fetch(await urlReady, { method: 'POST', body: '{}' });
resolveRequestDone();
const drained = await scopeResult;
expect(drained.observed).toBe(1);
expect(drained.violations).toHaveLength(1);
expect(takeWireViolations().observed).toBe(0);
});
});
describe('schema errata', () => {
const elicitWithScore = (score: Record<string, unknown>) => ({
jsonrpc: '2.0',
id: 1,
method: 'elicitation/create',
params: {
message: 'score please',
requestedSchema: { type: 'object', properties: { score } }
}
});
it('accepts fractional NumberSchema bounds/defaults at 2025-11-25 (spec generator bug, modelcontextprotocol#2710)', () => {
expect(
wireSchemaErrors(
'2025-11-25',
elicitWithScore({
type: 'number',
minimum: 0.5,
maximum: 99.5,
default: 95.5
})
)
).toEqual([]);
// The erratum makes 2025-11-25 agree with the fixed draft schema.
expect(
wireSchemaErrors(
DRAFT_PROTOCOL_VERSION,
elicitWithScore({
type: 'number',
minimum: 0.5,
maximum: 99.5,
default: 95.5
})
)
).toEqual([]);
});
});

View file

@ -0,0 +1,420 @@
// Validates wire JSON-RPC messages against the vendored per-version spec schema
// (src/spec-types/*.schema.json): envelope, then typed defs via method/error.code consts and
// request→result pairs. Known gap: the client-auth scenarios' express mock is not instrumented.
import { AsyncLocalStorage } from 'node:async_hooks';
import { Ajv, type ValidateFunction, type ErrorObject } from 'ajv';
import { Ajv2020 } from 'ajv/dist/2020.js';
import { default as addFormats } from 'ajv-formats';
import {
DRAFT_PROTOCOL_VERSION,
type ConformanceCheck,
type SpecVersion
} from '../types';
import schema2025_03_26 from '../spec-types/2025-03-26.schema.json';
import schema2025_06_18 from '../spec-types/2025-06-18.schema.json';
import schema2025_11_25 from '../spec-types/2025-11-25.schema.json';
import schemaDraft from '../spec-types/draft.schema.json';
export type WireOrigin = 'harness' | 'implementation';
export interface WireMessageInfo {
/** Who authored the message: the harness or the implementation-under-test. */
origin: WireOrigin;
/** Human-readable location, e.g. `stateless request 'tools/call'`. */
context: string;
/** For responses: the answered request's method, so the result is validated
* against its typed result definition. */
requestMethod?: string;
}
export interface WireSchemaViolation {
origin: WireOrigin;
specVersion: SpecVersion;
context: string;
errors: string[];
message: unknown;
}
const SCHEMAS: Record<SpecVersion, Record<string, unknown>> = {
'2025-03-26': schema2025_03_26,
'2025-06-18': schema2025_06_18,
'2025-11-25': schema2025_11_25,
[DRAFT_PROTOCOL_VERSION]: schemaDraft
};
/** Spec-repo directory name for a version (the draft is pinned unversioned). */
export function schemaDirFor(specVersion: SpecVersion): string {
return specVersion === DRAFT_PROTOCOL_VERSION ? 'draft' : specVersion;
}
/** Union definitions that alias a single concrete type (and so carry a
* `method` const) but are not the canonical definition for that method. */
const NON_CANONICAL_DEFS = new Set([
'ClientRequest',
'ClientNotification',
'ClientResult',
'ClientMessage',
'ServerRequest',
'ServerNotification',
'ServerResult',
'ServerMessage'
]);
interface CompiledSpec {
defsKey: '$defs' | 'definitions';
defs: Record<string, Record<string, unknown>>;
/** JSON-RPC method → canonical request/notification definition name. */
methodDefs: Map<string, string>;
/** `error.code` const → typed error-response definition name. */
errorDefs: Map<number, string>;
/** JSON-RPC method → typed result definition name. */
resultDefs: Map<string, string>;
validatorFor(defName: string): ValidateFunction;
}
const compiledSpecs = new Map<SpecVersion, CompiledSpec>();
/** Patch known bugs in released (frozen) generated spec schemas at load time
* so validation matches the schema.ts source of truth. Each erratum cites the
* upstream fix; delete it once the dated schema.json is corrected upstream
* and re-vendored. */
function applySchemaErrata(
specVersion: SpecVersion,
schema: Record<string, unknown>
): Record<string, unknown> {
if (specVersion !== '2025-11-25') return schema;
// 2025-11-25 generates NumberSchema minimum/maximum/default as `integer`,
// contradicting its own schema.ts (`default?: number`). Fixed for draft in
// modelcontextprotocol#2710, which left the released schema.json as-is.
const patched = structuredClone(schema);
const defs = (patched.$defs ?? patched.definitions) as Record<
string,
{ properties?: Record<string, { type?: string }> }
>;
for (const prop of ['minimum', 'maximum', 'default']) {
const p = defs.NumberSchema?.properties?.[prop];
if (p?.type === 'integer') p.type = 'number';
}
return patched;
}
function compileSpec(specVersion: SpecVersion): CompiledSpec {
let compiled = compiledSpecs.get(specVersion);
if (compiled) return compiled;
const schema = applySchemaErrata(specVersion, SCHEMAS[specVersion]);
const is2020 =
typeof schema.$schema === 'string' && schema.$schema.includes('2020-12');
// The spec schemas are not authored for ajv strict mode; validate them
// as-is rather than editing vendored files.
const options = { strict: false, allErrors: true } as const;
const ajv = is2020 ? new Ajv2020(options) : new Ajv(options);
addFormats(ajv);
// "byte" (base64) is an OpenAPI format ajv-formats doesn't ship; the spec
// uses it on blob contents. Accept any string rather than reimplementing it.
ajv.addFormat('byte', true);
const schemaId = `mcp://${schemaDirFor(specVersion)}/schema.json`;
ajv.addSchema({ ...schema, $id: schemaId });
const defsKey: '$defs' | 'definitions' =
'$defs' in schema ? '$defs' : 'definitions';
const defs = schema[defsKey] as Record<string, Record<string, unknown>>;
const methodDefs = new Map<string, string>();
const errorDefs = new Map<number, string>();
const resultDefs = new Map<string, string>();
for (const [name, def] of Object.entries(defs)) {
if (NON_CANONICAL_DEFS.has(name)) continue;
const props = (def.properties ?? {}) as Record<
string,
Record<string, unknown>
>;
const method = props.method?.const;
if (typeof method === 'string') {
methodDefs.set(method, name);
if (name.endsWith('Request')) {
const resultName = `${name.slice(0, -'Request'.length)}Result`;
if (resultName in defs) resultDefs.set(method, resultName);
}
}
const errorProp = props.error as
| { allOf?: { properties?: { code?: { const?: unknown } } }[] }
| undefined;
for (const part of errorProp?.allOf ?? []) {
const code = part.properties?.code?.const;
if (typeof code === 'number') errorDefs.set(code, name);
}
}
const validators = new Map<string, ValidateFunction>();
compiled = {
defsKey,
defs,
methodDefs,
errorDefs,
resultDefs,
validatorFor(defName: string): ValidateFunction {
let v = validators.get(defName);
if (!v) {
v = ajv.compile({ $ref: `${schemaId}#/${defsKey}/${defName}` });
validators.set(defName, v);
}
return v;
}
};
compiledSpecs.set(specVersion, compiled);
return compiled;
}
/** Dispatch maps extracted from a version's schema (method → request/notification def,
* `error.code` const → error def). Exposed so a unit test can pin them: if a schema sync
* restructures what the extraction walks, validation silently degrades to envelope-only. */
export function specDispatchMaps(specVersion: SpecVersion): {
methodDefs: ReadonlyMap<string, string>;
errorDefs: ReadonlyMap<number, string>;
resultDefs: ReadonlyMap<string, string>;
} {
const spec = compileSpec(specVersion);
return {
methodDefs: spec.methodDefs,
errorDefs: spec.errorDefs,
resultDefs: spec.resultDefs
};
}
function formatErrors(
defName: string,
errors: ErrorObject[] | null | undefined
): string[] {
const MAX = 6;
const formatted = (errors ?? []).map(
(e) =>
`${defName}${e.instancePath || ''}: ${e.message ?? 'invalid'}` +
(e.keyword === 'const' || e.keyword === 'enum'
? ` (${JSON.stringify(e.params)})`
: '')
);
if (formatted.length > MAX) {
return [
...formatted.slice(0, MAX),
`... ${formatted.length - MAX} more error(s)`
];
}
return formatted.length > 0 ? formatted : [`${defName}: invalid`];
}
/** Validate a single JSON-RPC message against the given spec version's schema.
* Returns `[]` when the message is valid. Pure — does not touch the recorder. */
export function wireSchemaErrors(
specVersion: SpecVersion,
message: unknown,
requestMethod?: string
): string[] {
const spec = compileSpec(specVersion);
const validateAgainst = (defName: string, value: unknown): string[] => {
const validate = spec.validatorFor(defName);
return validate(value) ? [] : formatErrors(defName, validate.errors);
};
const firstDef = (...names: string[]): string =>
names.find((n) => n in spec.defs) ?? names[names.length - 1];
if (Array.isArray(message)) {
// A batch: only legal where the envelope union admits arrays (2025-03-26).
// Limitation: `requestMethod` is forwarded to every element, so batched responses all
// validate against one result type — no caller sends batched requests today.
const elementErrors = message.flatMap((m, i) =>
wireSchemaErrors(specVersion, m, requestMethod).map((e) => `[${i}] ${e}`)
);
if (elementErrors.length > 0) return elementErrors;
if (validateAgainst('JSONRPCMessage', message).length > 0) {
return [
'JSONRPCMessage: batch arrays are not valid in this spec version'
];
}
return [];
}
const msg = (
typeof message === 'object' && message !== null ? message : {}
) as Record<string, unknown>;
// Classify the message and validate against the most specific definition
// the schema has for it. The typed definitions include the envelope
// requirements (jsonrpc, id, method, ...), so they subsume the union check.
if (typeof msg.method === 'string') {
const defName =
spec.methodDefs.get(msg.method) ??
('id' in msg ? 'JSONRPCRequest' : 'JSONRPCNotification');
return validateAgainst(defName, message);
}
if (msg.error !== undefined && msg.error !== null) {
const code = (msg.error as Record<string, unknown>).code;
const defName =
(typeof code === 'number' ? spec.errorDefs.get(code) : undefined) ??
firstDef('JSONRPCErrorResponse', 'JSONRPCError');
return validateAgainst(defName, message);
}
if (msg.result !== undefined) {
// SEP-2322 (MRTR): any request may be answered with an InputRequiredResult
// instead of its method's result type; discriminate on resultType.
const inputRequired =
(msg.result as Record<string, unknown> | null)?.resultType ===
'input_required' && 'InputRequiredResult' in spec.defs;
const resultDefName = inputRequired
? 'InputRequiredResult'
: requestMethod !== undefined
? spec.resultDefs.get(requestMethod)
: undefined;
if (resultDefName) {
const typed = validateAgainst(resultDefName, msg.result).map(
(e) => `${e} (result of '${requestMethod}')`
);
if (typed.length > 0) return typed;
}
return validateAgainst(
firstDef('JSONRPCResultResponse', 'JSONRPCResponse'),
message
);
}
return [
'JSONRPCMessage: not a valid JSON-RPC request, notification, or response'
];
}
// Recorder: choke points record every message; runners (and the vitest hook)
// drain the accumulated violations per scenario / test. Suite runs execute
// scenarios concurrently, so each scenario gets its own recorder via
// AsyncLocalStorage; code outside any scope falls back to the global recorder.
interface RecorderState {
violations: WireSchemaViolation[];
observed: number;
}
const recorderStorage = new AsyncLocalStorage<RecorderState>();
const globalRecorder: RecorderState = { violations: [], observed: 0 };
function recorder(): RecorderState {
return recorderStorage.getStore() ?? globalRecorder;
}
/** Run `fn` with its own isolated wire recorder. Async work started inside
* `fn` (mock servers, transport hooks) records into that recorder, so
* concurrent scenarios in a suite run cannot steal or wipe each other's
* violations. */
export function withWireRecorder<T>(fn: () => Promise<T>): Promise<T> {
return recorderStorage.run({ violations: [], observed: 0 }, fn);
}
/** Validate a wire message and record any violation. Called by choke points. */
export function validateWireMessage(
specVersion: SpecVersion,
message: unknown,
info: WireMessageInfo
): void {
const state = recorder();
state.observed++;
const errors = wireSchemaErrors(specVersion, message, info.requestMethod);
if (errors.length > 0) {
state.violations.push({
origin: info.origin,
specVersion,
context: info.context,
errors,
message
});
}
}
export function resetWireValidation(): void {
const state = recorder();
state.violations = [];
state.observed = 0;
}
/** Return everything recorded since the last reset, and reset. */
export function takeWireViolations(): {
violations: WireSchemaViolation[];
observed: number;
} {
const state = recorder();
const result = { violations: state.violations, observed: state.observed };
resetWireValidation();
return result;
}
function violationDetails(v: WireSchemaViolation): Record<string, unknown> {
return {
origin: v.origin,
specVersion: v.specVersion,
context: v.context,
errors: v.errors,
message: v.message
};
}
export function formatWireViolation(v: WireSchemaViolation): string {
return (
`[${v.origin}] ${v.context} (spec ${v.specVersion}): ` +
`${v.errors.join('; ')} — message: ${JSON.stringify(v.message)}`
);
}
/** Drain the recorder and synthesize the per-scenario conformance checks. Returns `[]`
* when no wire traffic was observed (e.g. a scenario asserting only on raw HTTP). */
export function wireSchemaChecks(specVersion: SpecVersion): ConformanceCheck[] {
const { violations: all, observed: count } = takeWireViolations();
if (count === 0 && all.length === 0) return [];
const timestamp = new Date().toISOString();
const specReferences = [
{
id: 'MCP-Schema',
url: `https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/${schemaDirFor(specVersion)}/schema.json`
}
];
const checks: ConformanceCheck[] = [];
const implViolations = all.filter((v) => v.origin === 'implementation');
checks.push({
id: 'wire-schema-valid',
name: 'WireSchemaValid',
description:
'Every JSON-RPC message the implementation sent is valid per the spec JSON schema for the negotiated spec version',
status: implViolations.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences,
details: {
messagesValidated: count,
violations: implViolations.map(violationDetails)
},
errorMessage:
implViolations.length > 0
? implViolations.map(formatWireViolation).join('\n')
: undefined
});
const harnessViolations = all.filter((v) => v.origin === 'harness');
if (harnessViolations.length > 0) {
checks.push({
id: 'wire-schema-harness-error',
name: 'WireSchemaHarnessError',
description:
'HARNESS ERROR: the conformance harness itself sent a JSON-RPC message the spec JSON schema forbids. This is a bug in the harness or a scenario fixture, not in the implementation under test.',
status: 'FAILURE',
timestamp,
specReferences,
details: { violations: harnessViolations.map(violationDetails) },
errorMessage:
'HARNESS ERROR: ' +
harnessViolations.map(formatWireViolation).join('\n')
});
}
return checks;
}