Skip to content

Multi-version protocol types: per-version delta packages (candidate 3/3)#2845

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

Multi-version protocol types: per-version delta packages (candidate 3/3)#2845
maxisbey wants to merge 1 commit into
mainfrom
maxisbey/types-version-delta-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 #2843 (complete per-version model packages) and #2844 (per-version fact tables); 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 adds versioned wire-shape support for protocol revisions 2024-11-05 through 2026-07-28: mcp.types stays one superset model set (the "monolith"), and each revision gets a wire-shape module that defines only what changed since the previous revision, importing everything else from the module that last defined it. The mechanism is "delta modules + one boundary": serialize_for/parse_as in src/mcp/types/wire.py re-validate payloads through the negotiated revision's models, loaded lazily on first boundary use. The mechanism lives in src/mcp/types/v2024_11_05/ … v2026_07_28/ and src/mcp/types/wire.py; everything else is shared surface.

Marker note: comments of the form # OD-<n> alternative: … (and # M-<n> in mcp.types.jsonrpc) mark reviewed design decisions at the spot where they bite; the convention is explained in the mcp.types._types module docstring, and the register below maps each id to its location. They are records, not TODOs.

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 "delta" answer: each revision's module IS the reviewable diff against the previous revision, so future small revisions cost a small module rather than a full copy.

How Has This Been Tested?

  • Full suite: 2,197 passed / 4 skipped / 1 xfailed; coverage 100.00% (branch) with strict-no-cover clean; pyright strict 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 revisions plus the extension and SDK directions. Each oracle module doubles as the flattened one-page view of its revision.
  • tests/types/: per-revision surface pins against the oracles (test_version_surfaces.py), boundary emission/parse behavior, version registry and method tables, public-surface ratchet (mcp.types.__all__ = 196 names), result-union resolution pins.
  • Import cost: no per-revision module is in sys.modules after import mcp (subprocess-asserted); total import time grows ~+30 ms vs main, of which the version mechanism itself contributes ~+1.4 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 byte-identical.

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 — the superset payload types; the module docstring explains the superset principle and the # OD-<n> alternative: marker convention.
  2. src/mcp/types/jsonrpc.py — version-independent JSON-RPC envelope.
  3. src/mcp/types/_versions.py — per-version method tables (plain data).
  4. src/mcp/types/_spec_names.py — the SDK-name ↔ schema-name divergence record.
  5. src/mcp/types/_wire_base.py — the wire-shape model base (closed-by-default rationale).
  6. src/mcp/types/v2024_11_05/__init__.py — the full base revision; its header documents the delta layout.
  7. src/mcp/types/v2025_03_26/__init__.py — the smallest delta; the template in action. Then v2025_06_18, v2025_11_25, v2026_07_28 (the last leads with its recursive JSON alias pair).
  8. src/mcp/types/wire.pyserialize_for (shape rules, validate, re-dump, restore walk) and parse_as (superset parse, mandates, result-union selection).
  9. tests/types/ — boundary, parse, registry, surface, and import-budget pins.
  10. tests/spec_oracles/ — generated per-version oracles + burn-down gate; each oracle module is also the flattened one-page view of that revision.

Decision register (targets of the in-source OD-n / M-n markers)

  • OD-1 structured content: plain Any = None (key absent when unset; interim, see open questions) — src/mcp/types/_types.py:1741
  • OD-2 removed-revision types stay in flat mcp.types, grouped section — src/mcp/types/_types.py:645
  • OD-3 tasks: 2025-11-25 task types restored types-only; extension package not built — src/mcp/types/_types.py:759
  • OD-5 caching defaults: don't-cache pair injected on 2026-07-28 emission — src/mcp/types/wire.py:311
  • OD-9 extra fields: single extra="allow" carve-out on SubscriptionFiltersrc/mcp/types/_types.py:1266
  • OD-11 no outbound narrowing of opened schemas/content on older revisions — src/mcp/types/wire.py:299
  • OD-12 the four public adapters stay plain smart unions — src/mcp/types/_types.py:2381, 2396, 2414, 2435
  • OD-13 elicitation requestedSchema stays an untyped dict — src/mcp/types/_spec_names.py:111
  • M-2 JSONRPCError.id keeps the required-nullable v2 shape — src/mcp/types/jsonrpc.py:189

Key behavioral facts (shared across all three candidates)

  • Emission strips ONLY the documented six-key ledger; no narrowing of caller values beyond it.
  • At 2026-07-28 the boundary injects only protocolVersion and refuses a request missing caller-supplied clientInfo/clientCapabilities in params._meta — identity is session-owned by design.
  • Tool-content emission bound is ≤ 2025-06-18 (the 2025-11-25 schema admits tool content, with the array-content sampling carrier); capability gating is session-layer.
  • Reserved io.modelcontextprotocol/* _meta keys pass through verbatim at every revision.
  • serialize_for refuses bare fragments (content blocks, capabilities, params) with TypeError.
  • Result-bearing unions parse by ranked structural member selection (first success wins; all-fail surfaces the best-ranked arm's errors).
  • The emission alignment walk is keys-only: re-validation through a revision module decides only which keys survive; every emitted leaf value comes from the superset dump, so a revision module's numeric kind never rewrites a caller's value (1.0 stays 1.0).
  • Number-render widening: the elicitation form-answer values (2025-06-18/2025-11-25/2026-07-28) and the NumberSchema bounds/default (the two older revisions that model them) carry a float arm — the pinned schema rendering says integer where schema.ts says number; each widening is pinned per position, and a closure sweep fails any remaining int-only field that is not pinned intended-integer.
  • Embedded input-request entries must carry method at 2026-07-28 (missing at the entry's own method key; earlier and unknown revisions stay lenient); unknown method values classify as invalid params at every revision.
  • Null-valued elicitation content entries (constructible for v1.x compatibility, typed by no schema version) pass through verbatim at every revision that models elicitation; the boundary withholds them from the revision module's re-validation and the alignment walk restores them.

Open questions for the maintainer

  • structuredContent interim: structured_content: Any = None, no Unset sentinel, no wrap serializer — the sentinel form breaks 7 existing tests at this base. The sentinel + carve-out path remains the post-review option.
  • Annotations.last_modified deferred in the superset model set (it trips an existing listing-snapshot test); the per-revision modules from 2025-06-18 on model lastModified exactly as their schemas do.
  • ServerResult resolution pin: the Discover-keyed adversarial frame resolves to DiscoverResult (pinned in tests/types/test_wire_parse.py); reverting 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.
  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

  • Honest cost: only the 2025-03-26 delta is small. Three of the four transitions rewrite 50–90% of the surface, so the committed-source saving vs full per-revision packages is roughly 27–32% — and "read one revision as a single page" requires the oracle module (the flattened per-version view, shipped in tests/spec_oracles/) or an import-following editor. The genuine advantage is review-shape: the next small revision's delta module IS the reviewable change.
  • The tasks extension (mcp/extensions/tasks/) is not built; its types remain provisional upstream.
  • Session-layer halves are out of scope: method-table enforcement, the sampling-tools and elicitation-url capability gates, the per-request logLevel send condition (listed at src/mcp/types/wire.py:77-86).
  • Caller-supplied identity at 2026-07-28 (see behavioral facts above) is designed; the session layer pre-seeds identity.
  • Tooling asymmetry: the delta-split generator is not committed (the per-revision modules carry pathless provenance and are pinned to their schemas by tests/types/test_version_surfaces.py); the oracle generator is committed (scripts/update_spec_types.py).

AI Disclaimer

One superset type set in mcp.types plus per-version packages that define
only what changed against the previous version and re-export the rest;
serialization validates dumps through the negotiated version's models.
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