Skip to content

Multi-version protocol types: complete per-version model packages (candidate 1/3)#2843

Draft
maxisbey wants to merge 1 commit into
mainfrom
maxisbey/types-per-version-packages
Draft

Multi-version protocol types: complete per-version model packages (candidate 1/3)#2843
maxisbey wants to merge 1 commit into
mainfrom
maxisbey/types-per-version-packages

Conversation

@maxisbey

@maxisbey maxisbey commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Draft — not intended to merge as-is. This is one of three candidate implementations of multi-version protocol type support, pushed side by side so the approaches can be compared concretely. The companion drafts are #2844 (per-version fact tables) and #2845 (per-version delta packages); all three ship the same public surface and the same behavioral contract, differing in how the per-version knowledge is represented. Once a direction is chosen, the selected approach would be re-cut as a reviewable PR series.

This branch makes the type layer protocol-version-aware: the flat mcp.types surface grows the 2025-11-25 and 2026-07-28 spec additions as one version-superset model set (the "monolith"), and a new boundary — serialize_for(model, version) / parse_as(type, data, version) — emits and parses frames for a specific negotiated version. The mechanism is five committed per-version model packages (src/mcp/types/v2024_11_05v2026_07_28), generated from the pinned schemas and hand-validated, that the boundary re-validates against on emission. Everything else under src/mcp/types/ is the shared superset.

Marker note: comments in the source reading # Alternative considered: … mark reviewed design decisions at the spot where they bite; the decision register below maps each one to its location. They are review-phase records of considered alternatives, not TODOs, and are resolved before release.

Motivation and Context

The SDK currently models one protocol revision at a time, but a server or client negotiates a version per session and the 2026-07-28 revision changes wire shapes materially (required resultType, required _meta identity triple on some client requests, removed server→client requests, multi-round-trip (MRTR) input requests, subscriptions, discovery). This candidate explores the "full per-version packages" answer: every revision is a complete, separately readable model set, and version-correct emission falls out of re-validating through the negotiated revision's package.

How Has This Been Tested?

  • Full suite: 2,171 passed / 4 skipped / 1 xfailed; coverage 100.00% (branch) with strict-no-cover clean; pyright 0 errors; ruff format + check clean.
  • tests/spec_oracles/: generated per-version schema oracles (pinned to public spec-repo SHA 6d44151…) compared against the SDK types, with a burn-down allowlist whose entries each carry a self-contained reason; green for all five versions.
  • tests/types/: package↔schema parity pins, boundary emission/parse behavior, version registry and method tables, public-surface ratchet (mcp.types.__all__ = 196 names), and round-trip byte-identity checks.
  • Import cost: import mcp / import mcp.types load none of the per-version packages or the boundary (asserted in a subprocess); total import time grows ~+30 ms vs main, of which the version mechanism itself contributes ~+2 ms (the rest is the larger shared model set).

Breaking Changes

None new on this branch: docs/migration.md gains feature notes only, and LATEST_PROTOCOL_VERSION / DEFAULT_NEGOTIATED_VERSION / SUPPORTED_PROTOCOL_VERSIONS are unchanged (pinned with v1-idiom spot checks in tests/types/test_public_surface.py).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

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

Reading order

  1. src/mcp/types/_types.py (~2,800 lines) — the superset model set: every protocol construct once, all versions' fields, version bounds in docstrings.
  2. src/mcp/types/jsonrpc.py — version-independent envelope; the one open question (JSONRPCError.id requiredness) is marked at the field.
  3. src/mcp/types/_spec_names.py — the divergence map: SDK↔schema renames, deliberately-not-modeled names, SDK-only names.
  4. src/mcp/types/_versions.py — per-version method tables, pure data.
  5. src/mcp/types/_wire_base.py — the two wire-model base classes.
  6. src/mcp/types/v2025_11_25/__init__.py — read in full next to the 2025-11-25 schema; one file per version, every alias/requiredness explicit.
  7. src/mcp/types/v2026_07_28/__init__.py — skim for the newest-revision delta (required resultType, required _meta triple, no server→client requests).
  8. src/mcp/types/wire.py — the boundary: shape rules, strip ledger, merge-and-align, parse mandates, member selection.
  9. tests/types/ — parity pin (package == schema), boundary/parse/registry facts, public-surface ratchet, import budget.
  10. scripts/update_spec_types.py — the generator; regeneration from the pinned schemas is byte-identical for the 2024-11-05 and 2025-03-26 packages (--check); the 2025-06-18, 2025-11-25, and 2026-07-28 packages carry annotated hand deviations that --check reports as drift and the parity test pins: the JSONValue alias widened to all six JSON types, and float arms on the fields where the pinned schema rendering says "integer" for a schema.ts number (ElicitResult.content, NumberSchema bounds/default).

Decision register (targets of the in-source # Alternative considered: markers)

Decision As built Marker
structuredContent CallToolResult.structured_content: Any = None (interim; no sentinel) _types.py:1938
Removed-in-2026-07-28 types stay in flat mcp.types, marked section _types.py:729
Tasks 2025-11-25 task types restored types-only; no extension package _types.py:867
Caching defaults injected on 2026-07-28 emission (ttlMs=0, cacheScope="private") wire.py:311
Extra fields models stay extra="ignore"; single allow carve-out SubscriptionFilter _types.py:1412
Outbound narrowing none beyond the documented strip ledger wire.py:299
Union adapters the four public adapters stay plain smart unions _types.py:2736,2751,2769,2790
Elicitation requestedSchema stays an untyped dict _spec_names.py:88
JSONRPCError.id keeps the v2-base shape (RequestId | None, no default) jsonrpc.py:207 (plain comment at the field, not the # Alternative considered: form)

Key behavioral facts (shared across all three candidates)

  • Emission strips ONLY the documented ledger (fields the target version lacks); no schema-purity narrowing of caller values.
  • At 2026-07-28 the boundary injects only protocolVersion and refuses a client request missing caller-supplied clientInfo/clientCapabilities in params._meta — identity is session-owned by design.
  • Tool-content blocks in sampling results emit from 2025-11-25 (that schema admits them); capability gating is session-layer.
  • Reserved io.modelcontextprotocol/* _meta keys pass through verbatim at every released version.
  • serialize_for accepts message bodies and envelope models only; bare fragments raise TypeError.
  • Result-union parses use ranked structural member selection: every arm matching more of the payload's top-level keys than the base result is a candidate, candidates are validated best match first and the first success wins (fallback EmptyResult; rejects surface the best-matching arm's errors).
  • The emission alignment walk is keys-only — re-validation through a version package decides which keys survive; every emitted leaf value comes from the superset dump (a package's numeric kind never rewrites a caller's value).
  • Embedded input-request entries must carry method at 2026-07-28 (rejecting with missing at the entry's own method key; earlier and unknown versions stay lenient); unknown method values classify as invalid params at every version.
  • Null-valued elicitation content entries (constructible for v1.x compatibility, typed by no schema version) pass through verbatim at every version that models elicitation; the boundary withholds them from package re-validation and the alignment walk restores them.

Open questions for the maintainer

  • structuredContent interim: plain Any = None ships because the sentinel design (wire-absent vs explicit-null) fails 7 existing tests at this base; the Unset sentinel + its compat carve-outs remains the post-review option.
  • Annotations.last_modified deferred: an existing listing-snapshot test pins the field's absence from the shared model set (the per-version packages model it); landing it plus that one snapshot flip is the post-review option.
  • ServerResult resolution pin: a frame carrying the complete server/discover result key set resolves to DiscoverResult on the raw public adapter (all typed/empty v2-base frames unchanged — twelve pins in tests/types/test_wire_parse.py); reverting via a callable discriminator or arm exclusion remains an option.
  • ElicitResult.content | None value arm: null has no wire form at any schema version; the arm exists for v1.x constructor compatibility. Drop vs keep is an open API decision.

Conformance-suite gaps (surfaced per AGENTS.md, to raise on the conformance repo)

No conformance-suite scenario currently exists for most 2026-07-28 type features; the in-repo oracle and boundary tests substitute for now. Gaps identified:

  1. Generic resultType: "complete" emission on ordinary (non-MRTR) results, incl. the empty-result surface (only MRTR scenarios assert resultType).
  2. Superset-union membership / version-gated dispatch+emission as such.
  3. ClientCapabilities.extensions / ServerCapabilities.extensions — unasserted on both sides.
  4. structuredContent any-JSON-value widening incl. explicit null; inputSchema/outputSchema 2020-12 widening.
  5. prompts/list draft pagination/required-params scenario — none; per-method MRTR coverage of prompts/get (vs resources/read) unconfirmed — verify against the conformance repo before counting it.
  6. Roots draft behavior: no top-level roots/list on 2026-07-28 (embedded delivery); must not emit notifications/roots/list_changed on draft — none.
  7. Required-params/_meta materialization (PaginatedRequest half) — none identifiable.
  8. Subscriptions: resourceSubscriptions filter path; extension taskIds; removed-era-method -32601 — three gaps.
  9. MRTR-embedded sampling member payloads specifically — none.
  10. Tasks: zero scenarios (conformance issues @app.resource() does not accept ctx: Context #244/sse client read progress notification #261 unmerged) — family-wide gap.

Known limitations

  • The tasks extension package is not built; the restored 2025-11-25 task types are types-only (never dispatched), and the attach point is marked.
  • Session-layer halves are out of scope and listed in plain words in the comment block after the table re-exports in wire.py: capability-gated emission checks, log-level injection, capability-violation hygiene.
  • Caller-supplied identity at 2026-07-28 (see behavioral facts above) is designed, not a gap in the boundary.
  • Closed-model divergence: the generated packages validate with extra="ignore" where the schemas declare open objects; deliberate, stated in each package docstring; inbound leniency is unaffected (superset parse).
  • scripts/update_spec_types.py regenerates the five packages from the pinned public schema revisions; the three newest packages carry the annotated hand deviations listed in reading-order item 10.

AI Disclaimer

One superset type set in mcp.types plus a complete, hand-validated model
package per spec version; serialization validates dumps through the
negotiated version's models at a single wire boundary.
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