Fix wire-validation attribution bugs and extend the schema erratum to 2025-06-18 (#421)

Follow-ups from the #399 review:

- Stateless mock: validate error replies with a placeholder id so the
  JSON-RPC-mandated id:null is not recorded as a harness violation, and
  skip inbound validation when express left the body unparsed (wrong or
  missing Content-Type) — both were false positives blamed on the wrong
  party. Same unparsed-body guard in the stateful mock.
- Stateful mock: validate client traffic against the protocol version
  the client declares via MCP-Protocol-Version, falling back to
  2025-03-26 when the header is absent (it only exists from 2025-06-18,
  whose spec says to assume 2025-03-26), instead of always using the
  run version.
- sdk-client: validate batches once as a whole array instead of also
  per element, which double-recorded every violation.
- Make specVersion required on connectToServer/connectStateful/
  createServerStateful; add isSpecVersion to types.ts.
- Extend the NumberSchema erratum to 2025-06-18 (same generator bug on
  minimum/maximum, modelcontextprotocol#3139) and add a tripwire test
  that forces erratum deletion once the schemas are re-vendored.
- Document the wire-schema-valid / wire-schema-harness-error check IDs
  in the README.
This commit is contained in:
Paul Carleton 2026-07-27 20:32:17 +01:00 • committed by GitHub
commit 5cc567c399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 201 additions and 39 deletions

View file

@ -1,4 +1,6 @@
import { createServer, type Server } from 'http';
import rawSchema2025_06_18 from '../spec-types/2025-06-18.schema.json';
import rawSchema2025_11_25 from '../spec-types/2025-11-25.schema.json';
import { DRAFT_PROTOCOL_VERSION } from '../types';
import { sendStatelessRequest } from '../connection/stateless';
import {
@ -506,4 +508,31 @@ describe('schema errata', () => {
)
).toEqual([]);
});
it('accepts fractional NumberSchema bounds at 2025-06-18 (same generator bug, no default there)', () => {
expect(
wireSchemaErrors(
'2025-06-18',
elicitWithScore({ type: 'number', minimum: 0.5, maximum: 99.5 })
)
).toEqual([]);
});
it('tripwire: delete applySchemaErrata once the vendored schemas are fixed (modelcontextprotocol#3139)', () => {
const numberSchema = (raw: Record<string, unknown>, defsKey: string) =>
(
raw[defsKey] as Record<
string,
{ properties: Record<string, { type: string }> }
>
).NumberSchema.properties;
expect(
numberSchema(rawSchema2025_11_25, '$defs').default.type,
'the re-vendored 2025-11-25 schema no longer needs the erratum — delete its applySchemaErrata branch'
).toBe('integer');
expect(
numberSchema(rawSchema2025_06_18, 'definitions').minimum.type,
'the re-vendored 2025-06-18 schema no longer needs the erratum — delete its applySchemaErrata branch'
).toBe('integer');
});
});

View file

@ -75,18 +75,19 @@ interface CompiledSpec {
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. */
/** Patch known generator bugs in released spec schema.json files at load time
* so validation matches the schema.ts source of truth; delete each branch (a
* test trips) once the dated schema is fixed 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.
if (specVersion !== '2025-11-25' && specVersion !== '2025-06-18') {
return schema;
}
// NumberSchema minimum/maximum (plus default at 2025-11-25) are generated
// as `integer`, contradicting schema.ts (`number`). Fixed for draft in
// modelcontextprotocol#2710; dated fixes proposed in modelcontextprotocol#3139.
const patched = structuredClone(schema);
const defs = (patched.$defs ?? patched.definitions) as Record<
string,