Skip to content

feat(core)!: per-era wire codec interface#2294

Open
felixweinberger wants to merge 14 commits into
v2-2026-07-28from
fweinberger/codec-mechanics-and-break
Open

feat(core)!: per-era wire codec interface#2294
felixweinberger wants to merge 14 commits into
v2-2026-07-28from
fweinberger/codec-mechanics-and-break

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Splits the wire layer behind per-era codec modules and lands the second (and final planned) alpha break of the draft-revision groundwork, with its five-item migration ledger.

Motivation and Context

The draft revision changes what is legal on the wire (new required members, deleted vocabulary), which cannot be expressed while one shared schema set serves every revision. This PR re-homes the runtime method registries and schema resolution behind an internal per-era codec interface (packages/core/src/wire/), resolves the codec per exchange in the protocol funnels, re-binds the client's positional schema call sites to era-resolved method-keyed resolution — all behavior-preserving, evidenced by registry pins landed BEFORE the relocation and a byte-stable e2e matrix — and then makes the one breaking commit:

The alpha break (one commit, one major changeset, every item in docs/migration.md):

  1. EmptyResultSchema accepts→rejects stray {resultType} bodies (strict).
  2. content.default([]) removed from tool-result schemas — a task-shaped body on tools/call now fails loudly instead of parsing as a content-empty success (this also closes the carve-out pinned in feat(core)!: hide wire-only members from the public types; lift them to ctx.mcpReq #2293's review).
  3. Custom-handler _meta semantics: deleted → present-minus-reserved.
  4. specTypeSchemas re-scoped to the neutral model.
  5. Guard re-pointing (consumer-side neutral checks).

Also re-introduces MessageExtraInfo.classification — removed from #2293 per review because it had no consumer; its first consumer is this PR's funnel commit (inboundCodecFor).

How Has This Been Tested?

Registry pins green on both sides of the relocation; full e2e matrix byte-stable through every commit; all package suites green; conformance legs (0.2.0-alpha.3 pin) zero stale; api-report:check green with deliberately regenerated reports (the only public diffs are the ledgered items + the classification carrier's return). Integration fixtures updated in-PR for the content requirement.

Breaking Changes

Yes (alpha-scoped): the five ledgered items above. Major changeset + migration.md/migration-SKILL.md entries with before/after for each.

Types of changes

  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Review guide: the breaking commit (feat(core)!: cut resultType from the neutral schemas…) + migration.md first; the mechanics commits are verify-no-change reviews backed by the pins. The 2026-era codec itself follows in the stacked PR.

@felixweinberger felixweinberger requested a review from a team as a code owner June 12, 2026 18:16
@changeset-bot

changeset-bot Bot commented Jun 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c85ceb8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@modelcontextprotocol/core Major
@modelcontextprotocol/client Major
@modelcontextprotocol/server Major
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2294

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2294

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2294

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2294

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2294

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2294

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2294

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2294

commit: c85ceb8

@felixweinberger felixweinberger changed the title feat(core)!: per-era wire codec interface; the wire-schema alpha break feat(core)!: per-era wire codec interface Jun 15, 2026
…a wire-codec interface

Mechanical relocation: the request/result/notification schema maps and their
getters move verbatim from types/schemas.ts to wire/rev2025-11-25/registry.ts,
behind a WireCodec contract (wire/codec.ts) with era resolution, a derived
spec-method universe, and outbound lifecycle bootstrap pins (wire/bootstrap.ts).
The 2025-era codec's decode/encode are identity-shaped; nothing consults the
codec layer yet, and registry contents are pinned by reference by the
registryPins suite committed ahead of this change.

Rebase reconciliations (onto the post-#2293 base):
- The relocated result map carries the base's NARROWED runtime/typed-aligned
  content (plain per-method schemas keyed by RequestMethod, no task unions,
  no tasks/* entries) — review fix d542fd8 is upstream truth.
- LiftedWireMaterial.envelope is Partial<RequestMetaEnvelope> (review fix
  07f2384) in the codec contract too.
- NarrowResultKey shrinks to the sampling pair: with the aligned map,
  tools/call:plain and elicitation/create:plain were identical to their
  registry entries.
- typedMapAlignment.test.ts re-points its getResultSchema import to the
  registry's new home; API reports regenerated (the registry getters leave
  the bundled dts namespace).
- _onrequest resolves a per-request codec (classification > session > legacy),
  era-gates spec methods by registry membership (-32601 by absence, before
  handler lookup), enforces era envelope requiredness on the lifted material,
  binds the codec to the request context, and routes the response through the
  codec's encodeResult stamp seam.
- _onnotification gains the same per-message resolution and era gate
  (silent drop for era-mismatched spec notifications).
- setRequestHandler/setNotificationHandler spec paths resolve their schemas
  at dispatch time from the serving era's registry instead of capturing them
  at registration time.
- request()/notification()/ctx senders era-gate outbound spec methods with a
  typed local error (new SdkErrorCode.MethodNotSupportedByProtocolVersion)
  before anything reaches the transport; lifecycle messages keep their
  bootstrap era pins.
- the response funnel gains the codec decodeResult hop ahead of schema
  validation.

All resolution currently lands on the 2025-era codec, so behavior on existing
connections is unchanged; the 2026-era codec arrives separately.

Rebase reconciliations (onto the post-#2293 base):
- The MessageClassification carrier (and MessageExtraInfo.classification)
  moves INTO this commit: the base removed it (review fix 210aaab, zero
  producers/consumers there) and this funnel surgery is its first consumer
  (inboundCodecFor(extra?.classification)). Shapes recovered from the
  pre-rebase base; JSDoc updated — it IS consulted now. Its envelope member
  is Partial<RequestMetaEnvelope>, matching the lift typing (07f2384).
- codecForClassification arrives here too (it reads the carrier).
- The notification funnel keeps the base's envelope-keys-only lift scope
  (liftWireOnlyMaterial(message, 'notification') — review fix 8068629).
- API reports regenerated in this commit: the carrier's lines return
  (7 reports, +433 — it is again the reachability path for
  RequestMetaEnvelope and the JSON helper types in the subpath bundles).
…resolved method-keyed resolution

- Client/Server high-level methods (ping, complete, setLevel, prompts/*,
  resources/*, tools/*, roots/list, initialize) now go through the
  method-keyed request() path; the wire codec resolves the result schema at
  dispatch time.
- The three deliberately narrower surfaces (callTool plain CallToolResult,
  createMessage params-dependent, elicitInput plain ElicitResult) resolve
  their narrow schemas from the same codec instead of importing them
  positionally.
- Client.connect/reconnect and Server initialize bind the negotiated wire
  version, so the codec rides connection state.
- Both _wrapHandler validators resolve their request/result schemas from the
  per-request codec instead of module-level schema imports.

Same schema objects serve the 2025 era, so behavior is unchanged (full e2e
matrix byte-stable at 2282 passed / 188 expected-fail).

Rebase reconciliations (onto the post-#2293 base):
- tools/call and elicitation/create no longer go through narrowResultSchema:
  with the result map aligned to the typed map (d542fd8), their narrow
  keys were identical to the era registry entries in BOTH eras, so callTool
  and elicitInput use the method-keyed request() path and the handler wraps
  validate codec.resultSchema(method). _requestWithNarrowSchema remains for
  the sampling pair only (params-dependent schema choice).
- API reports regenerated (NarrowResultKey + the protected narrow sender
  enter the Protocol surface).
…abulary into the codec modules

The consumer-visible alpha break of the codec split, as one changeset
(codec-split-wire-break) with migration entries for every ledgered item:

- resultType removed from the base ResultSchema: the masking surface that
  accepted 2026 vocabulary on every legacy-leg parse is gone. EmptyResultSchema
  (strict) flips accept->reject for {resultType} bodies; the member now exists
  only in the 2026-era codec module.
- content.default([]) removed from CallToolResultSchema and
  ToolResultContentSchema: content-less results fail loudly instead of parsing
  as silent empty successes (the T6 width-leak root); handler results must
  include content (-32602 otherwise).
- custom (3-arg) handlers now receive _meta present-minus-reserved instead of
  having it deleted before params validation.
- RequestMetaEnvelopeSchema moved to wire/rev2026-07-28/schemas.ts (shape
  unchanged); the task message schemas and the era-faithful role unions moved
  to wire/rev2025-11-25/schemas.ts; the neutral role aggregates no longer
  carry task vocabulary (deprecated Task* types stay importable).
- specTypeSchemas re-scoped to the neutral model (task message validators and
  RequestMetaEnvelope left the public set; SpecTypeName narrowed); guards
  documented as consumer-side neutral-shape checks.
- codemod spec-schema map regenerated; API reports regenerated deliberately
  (the diff is the enumerated break: resultType schema members gone, moved
  declarations, SdkErrorCode/NarrowResultKey additions).

Every net-test update carries its ledger annotation inline. Full e2e matrix
and all three conformance legs are byte-stable.

Rebase reconciliations (onto the post-#2293 base):
- The @deprecated tags the base added to the full task wire surface
  (6ebe7e9) travel WITH the moved schemas into
  wire/rev2025-11-25/schemas.ts; the wireOnlyHiding scan test now sweeps
  both schema homes (combined >= 19, every export tagged).
- typedMapAlignment's tools/call carve-out pin FLIPS in this commit, as part
  of the content-default ledger item: with content required, a
  CreateTaskResult body on tools/call no longer parses as {content: []} —
  it is a typed INVALID_RESULT error like sampling/elicit. The 'Honest pin'
  comment records the flip.
- migration.md / migration-SKILL.md merge the base's review-fix wording
  (Partial envelope, notification-drop caveat, retry-name collision note)
  with this commit's era-codec entries; the classification bullet returns
  (the carrier is back since the funnel commit).
- mcpServerBehaviorPins.test.ts no longer exists on the base (pin
  consolidation); its content-default flip pin is superseded by the
  typedMapAlignment flip and the eraGates suite.
- codemod spec-schema map regenerated via the package prebuild (output
  byte-identical to the original commit's); API reports regenerated.
…nt member

The wire default([]) removal (ledgered; changeset codec-split-wire-break)
means handler results must carry content; the outputSchema-validation fakes
returned structuredContent only.
The negotiated wire-era binding is connection state, but it survived
close(): an instance that once negotiated 2026-07-28 could never re-run
a fresh initialize handshake, because the stale modern binding resolved
the outbound codec before the bootstrap pin and 'initialize' is
physically absent from the modern registry.

Clear the binding at the start of a fresh connect (no sessionId), so the
handshake rides the pre-negotiation bootstrap pin and the connection can
re-negotiate. The resume path is untouched: it re-binds the originally
negotiated version instead of clearing.
…f stranding the peer

encodeResult runs in the success arm of the response dispatch, where a
throw was uncatchable by the error arm and fell through to onerror
without ever answering the peer - the remote request hung until its
timeout. Wrap the encode hop: a throw now reports locally AND sends a
-32603 error response, and the connection stays serviceable. (The seam
gains ttlMs/cacheScope stamping content in M3.2, so this hardens before
that lands.)
A request to a genuinely unknown method that also lacked the era's
required _meta envelope answered -32602 (invalid params) instead of
-32601 (method not found): the envelope check ran before the
handler-existence check. Move it after — method existence outranks
parameter validity. The era gate (-32601 by registry absence) stays
first; the canonical precedence table for the full inbound validation
ladder arrives with the validation-ladder milestone.
The migration guide promises that a tools/call handler result without
content is rejected with -32602 'Invalid tools/call result' (the
content.default([]) affordance was removed). The producer exists in the
server's wrapped handler, but no server-side test pinned it — the
existing suites cover the client-decode side only. Pin both arms:
structured-only result answers -32602 on the wire; an authored-content
result passes through untouched.
protocol.ts redeclared the LiftedWireMaterial interface that wire/codec.ts
already exports as part of the codec contract. Import the canonical type
instead — the M4.1 driver extends this seam, and two structurally-equal
declarations invite silent drift.
…f side-table bindings

The negotiated protocol version is now a single private field on Protocol
(consolidating the per-role copies Client and Server kept), reached by the
role classes, tests, and the future server entry through a package-internal
accessor pair on the core internal barrel. Era resolution is a pure function
of that state: outbound traffic uses the bootstrap method pins only while the
version is unset, and everything on an established connection — requests,
responses, notifications, ctx.mcpReq.send/notify, and the instance-level
senders — resolves through the instance era. The WeakMap binding channels
(bindWireVersion/unbindWireVersion/outboundCodecFor/hasBoundWireVersion/
inboundCodecFor/bindRequestCodec/codecForContext) are deleted.

MessageExtraInfo.classification narrows to the edge-to-instance handoff: the
funnel validates a classified inbound message against the instance era and
treats a mismatch as an entry/routing error (typed -32004 rejection for
requests, a drop for notifications, plus onerror) instead of switching codecs
per message. Unclassified traffic on legacy instances is byte-identical to
before.

The client clears the stored version at the start of a fresh connect and sets
it at handshake completion (after the initialized notification), so a fresh
handshake after close always runs on the legacy era; the resume path keeps
the original negotiation. The server stores it at _oninitialize as before.

Era-gate tests are reshaped to the instance-era model (the modern arm uses
the internal hook the entry will use) and gain coverage for the
classification-mismatch handoff.
The params-dependent sampling result schema was the only surviving narrow
case, so it now goes through the existing explicit-schema path:
Server.createMessage passes CreateMessageResultSchema /
CreateMessageResultWithToolsSchema to _requestWithSchema, and the client-side
sampling handler wrapper picks the same pair directly. _requestWithSchema
gains the outbound era gate, so an explicit schema can never smuggle a
deleted spec method onto the wire (createMessage on a modern-era instance
still fails with the typed era error before the transport).

The NarrowResultKey type, the WireCodec.narrowResultSchema member, and the
per-era narrowResultSchemas tables are removed; the regenerated API reports
drop the protected _requestWithNarrowSchema member and the NarrowResultKey
type from the client and server declaration surfaces.
…data

The unsupported-protocol-version rejection sent for an inbound request whose
transport-edge protocol-version classification does not match the connection
reported only the single version the connection serves in data.supported.
The spec defines supported as the full list of protocol versions the receiver
supports, so the peer can choose a mutually supported version from the error
alone; report the instance's configured supportedProtocolVersions instead.
Adds a dispatch-level test pinning the error payload.
@felixweinberger felixweinberger force-pushed the fweinberger/codec-mechanics-and-break branch from 9cac3f5 to c85ceb8 Compare June 15, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant