Skip to content

Multi-version protocol types: per-version fact tables (candidate 2/3)#2844

Draft
maxisbey wants to merge 1 commit into
mainfrom
maxisbey/types-version-fact-tables
Draft

Multi-version protocol types: per-version fact tables (candidate 2/3)#2844
maxisbey wants to merge 1 commit into
mainfrom
maxisbey/types-version-fact-tables

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 #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 adds the 2026-07-28 protocol revision to mcp.types as one superset model set plus a version-aware wire boundary (mcp.types.wire.serialize_for / parse_as), keyed strictly by (model type, negotiated version). The mechanism is declarative fact tables: one data module (_version_facts.py) with five per-version blocks of strip/inject/refuse/mandate rows and method sets, applied by one small engine (_shaping.py, 479 lines). Everything version-keyed lives in those two files plus wire.py; the model definitions themselves are version-free.

Marker note: comments tagged OD-n / M-n in the source reference the decision register in this description (table below). They are review-phase markers — 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 "facts, not copies" answer: version differences are rows in a data table a reviewer can scan in one sitting, applied by a single engine, with no duplicated model definitions.

How Has This Been Tested?

  • Full suite: 2,157 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 plus the extension group. The fact tables themselves are additionally pinned against the per-version oracles (tests/types/test_version_facts_oracle.py).
  • tests/types/: boundary emission/parse pairs, registry + method-table equality, public-surface ratchet (mcp.types.__all__ = 196 names), engine and fact-table cross-checks, union resolution pins (incl. twelve ServerResult pins).
  • Import cost: no boundary/mechanism module is imported by import mcp or import mcp.types (subprocess-asserted; the boundary loads lazily on first use).

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 (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 — the superset payload models, including the 2026-07-28 additions (Discover, subscriptions, input-required results, error data) and the marked removed-method section.
  2. src/mcp/types/jsonrpc.py — version-independent JSON-RPC envelope.
  3. src/mcp/types/_spec_names.py — deliberate SDK-name vs schema-export-name divergences, with reason codes.
  4. src/mcp/types/_version_facts.py — the mechanism: five per-version fact blocks, oldest to newest; row kinds documented on their dataclasses.
  5. src/mcp/types/_shaping.py — the engine that applies the rows.
  6. src/mcp/types/wire.py — the public boundary: version registry re-export, per-version method tables, serialize_for/parse_as, two exceptions.
  7. src/mcp/types/__init__.py — public surface; the boundary loads lazily so import mcp.types does not pay for it.
  8. tests/types/ — boundary emission/parse pairs, registry + method-table equality, public-surface ratchet, import budget, engine and fact-table cross-checks, union resolution pins.
  9. tests/spec_oracles/ — generated per-version schema oracles + burn-down comparison; regenerated by scripts/update_spec_types.py.
  10. docs/migration.md — the tasks-types restoration note and release-notes leniency entries.

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

Decision Implemented Marker
OD-1 structuredContent interim: structured_content: Any = None, no sentinel (open question below) src/mcp/types/_types.py:1972
OD-2 removed-method types kept in mcp.types, grouped in a marked section src/mcp/types/_types.py:695
OD-3 tasks 2025-11-25 core task types restored; the four tasks/* methods stay out of unions and method tables; extension types not built src/mcp/types/_types.py:1538
OD-5 caching defaults inject ttlMs=0, cacheScope="private" when unset on 2026-07-28 emission src/mcp/types/_version_facts.py:676
OD-9 extra fields single extra="allow" carve-out on SubscriptionFilter src/mcp/types/_types.py:1156
OD-10 conformance recorded conformance-suite gaps (list below) this description
OD-11 outbound narrowing skipped — pass-through, no strips beyond the fact rows src/mcp/types/wire.py:96
OD-12 union adapters unchanged plain smart unions; the existing form is the disposition (no marker) src/mcp/types/_types.py:2588 ff.
OD-13 elicitation requestedSchema stays an untyped dict; vocabulary not modeled src/mcp/types/_spec_names.py:67
M-2 JSONRPCError.id existing shape kept (RequestId | None, no default) src/mcp/types/jsonrpc.py:207

OD-4 was superseded (no per-version naming beyond version strings); OD-6 (| None = None on newer-version-required fields, boundary injects/validates) is pervasive — neither carries a marker.

Key behavioral facts (shared across all three candidates)

  • Nothing is stripped or narrowed on old-version emission beyond the named fact rows; newer optional fields on known types pass through.
  • At 2026-07-28, a request that must carry the _meta identity triple refuses emission when the caller has not supplied clientInfo/clientCapabilities — the boundary injects only protocolVersion, never session identity.
  • The tool-content emission refusal ends at 2025-06-18 — the 2025-11-25 schema admits tool content; capability gating is session-layer.
  • User-set reserved io.modelcontextprotocol/* _meta keys pass through on emission at every version (never stripped).
  • serialize_for accepts only message bodies and envelope models; bare fragments (content blocks, capabilities, params) raise TypeError.
  • Result-bearing unions parse by ranked structural member selection: candidates are ranked by recognized top-level keys and validated best match first; the first success wins, and a body is rejected only when every structurally matching arm rejects it (surfacing the best-ranked arm's errors). This keeps the open-shaped empty-result arm from masking a better-matching member's validation failures, and routes wire-valid sampling-with-tools bodies correctly (the two sampling arms share one top-level key set).
  • Embedded input-request entries must carry method at 2026-07-28 (a method-less entry rejects with a missing error at the entry's own method key; earlier and unknown versions stay lenient); an unknown method value classifies 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 — this mechanism never reaches content values, now pinned by a boundary test.
  • The 12 tasks-capability sub-models are module-private in _types.py (they back the capabilities tasks field types but are not exported).

Open questions for the maintainer

  • OD-1 structuredContent interim: the sentinel design (wire-absent vs explicit-null structuredContent) is jointly unsatisfiable with the existing test suite and the types-only scope at this base; the branch ships the plain-None interim. The sentinel plus its carve-out set remains the post-review option.
  • Annotations.lastModified deferred: the 2025-11-25 field trips an existing listing-snapshot test; the burn-down allowlist carries the three findings as deliberate deviations. Landing the field plus the one snapshot flip remains the post-review option.
  • ServerResult resolution pin: appending the all-optional input-required arm flips one adversarial Discover-keyed frame to DiscoverResult; pinned in tests/types/test_unions.py. Reverting via a callable discriminator or arm exclusion remains the post-review 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 2026-07-28 tasks extension's own types are not built; only the 2025-11-25 core task types are restored (typed, never dispatched).
  • Session-layer halves are deferred by design: the sampling.tools and elicitation.url capability gates, the logLevel send condition, and drop-vs-log handling for version-invalid notifications.
  • Caller-supplied identity at 2026-07-28: for the request methods whose _meta must carry the triple (tools/call, prompts/list, server/discover, …), serialize_for injects only protocolVersion; if the caller or session has not pre-seeded clientInfo/clientCapabilities in params._meta, emission refuses rather than synthesizing identity.
  • Oracle regeneration tooling ships in-repo (scripts/update_spec_types.py).

AI Disclaimer

One superset type set in mcp.types plus declarative per-version wire-fact
tables interpreted by a single engine at the 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