Skip to content

Enforce the modern lifecycle admission rules per SEP-2575 - #489

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:enforce_modern_request_admission
Open

Enforce the modern lifecycle admission rules per SEP-2575#489
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:enforce_modern_request_admission

Conversation

@koic

@koic koic commented Aug 7, 2026

Copy link
Copy Markdown
Member

Motivation and Context

The 2026-07-28 conformance requirements reject two admission gaps in the modern (stateless) lifecycle that the current implementation answers with the wrong signal:

  • The RPCs the modern lifecycle removes (initialize, ping, logging/setLevel, resources/subscribe, resources/unsubscribe) were still dispatched, or answered incidental errors (initialize answered -32022, resources/subscribe a capability error); the requirement is -32601 Method not found with HTTP 404.
  • server/discover was fully envelope-exempt, while the requirements validate its envelope like every other modern request.

The per-request _meta validation itself (-32602 naming the offending keys, optional clientInfo, era classification on the reserved protocolVersion key alone) landed separately in #491; this change builds on that shape and completes the admission rules:

  • Server#handle_request rejects Methods::MODERN_REMOVED_METHODS on a modern-era session with -32601 before the capability check, since the method does not exist in that era at all. The Streamable HTTP modern path maps -32601 to 404 and -32602 to 400 through its existing status ladder.
  • The dual-era sniff treats an initialize carrying the modern envelope as modern traffic (a modern client naming a removed method) instead of the legacy-distinctive handshake, and server/discover keeps its envelope exemption outside the modern era only. The envelope-less rejection on a modern-locked session adopts Align modern envelope validation with the finalized spec #491's wire shape, -32602 with the detailed message.

One deliberate divergence from the Python SDK: its dual-era stream loop answers an initialize on a modern-locked connection with -32022 carrying the supported-version list, while its HTTP path serves the 404/-32601 shape. The conformance requirement phrases removed methods transport-independently, so this SDK answers -32601 on every transport, stdio included, for uniform era semantics.

The stdio era-lock tests used a modern ping as the era-distinctive request; they now use tools/list, since ping no longer exists in the modern lifecycle.

How Has This Been Tested?

bundle exec rake test passes with zero failures and RuboCop reports no offenses. New and updated tests cover each admission rule on Server#handle, the Streamable HTTP modern path, and the stdio era lock (all five removed methods per transport, the envelope requirement on server/discover, and the envelope-carrying initialize routing).

Against the conformance fixture server, every admission check of the server-stateless scenario passes at --spec-version 2026-07-28: the per-request _meta validation, removed-method 404, discovery, capability, and version-negotiation checks are all green, and the only remaining failures are the subscriptions/listen checks that belong to separate work. The --requirements 2025-11-25 server leg passes 78/78, unchanged.

Breaking Changes

None for stable protocol versions: legacy requests are byte-for-byte unchanged, and a bare legacy _meta (progressToken, trace context) keeps its legacy classification. Within the modern lifecycle, previously incidental error shapes are replaced by the ones the 2026-07-28 conformance requirements mandate.

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

@koic koic changed the title Enforce the Modern Lifecycle Admission Rules per SEP-2575 Enforce the modern lifecycle admission rules per SEP-2575 Aug 7, 2026
@koic
koic force-pushed the enforce_modern_request_admission branch from 6e41c2d to 264cafd Compare August 7, 2026 17:24
atesgoral
atesgoral previously approved these changes Aug 7, 2026
@koic

koic commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Updated per the discussion in #491, now that it has merged: rebased onto main, dropped the duplicated lib/mcp/request_envelope.rb changes in favor of #491's validation shape, and replaced the leftover -32022 assertion for initialize on a modern-locked session with the -32601 form the 2026-07-28 conformance requirements mandate.

@seunghan91

Copy link
Copy Markdown

Ran the rebased branch (d34fb74) against a real dual-era deployment, as offered on #491.

Setup: a production Rails MCP server exposing StreamableHTTPTransport with stateless: true and enable_json_response: true, serving legacy 2025-03-26 and 2026-07-28 clients on one endpoint. Compared main (05c3585) against d34fb74 with an identical request set.

The initialize classification fix does what it should

On main, a request carrying the modern envelope claim with method: "initialize" is rejected as an era-lock violation even when it is the first request on a fresh connection:

main       400  -32600  "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`"
d34fb74    404  -32601  "Method not found: initialize is not part of the modern lifecycle (SEP-2575)"

The main message points at a prior negotiation that never happened — within a single request, initialize classifies the session as legacy, and the request's own modern claim is then rejected against that freshly-set lock. From the client side that is undiagnosable. This PR removes the path entirely and answers with the removed-method form instead, which is both correct per the frozen conformance requirements and actually actionable.

No regressions on the dual-era surface

Our spike suite is 29 request specs covering legacy handshake, modern sessionless dispatch, auth injection, base64 round-trip, and CAS error shapes. main and d34fb74 produce identical results — the same 2 failures on both, and both are our own client contract (we were sending Accept: application/json where the modern path requires application/json, text/event-stream), not anything this PR introduces.

Envelope validation from #491 is intact on the rebase:

request d34fb74
full modern envelope 200
envelope without clientInfo 200
claim key only, clientCapabilities missing 400 -32602, offending key named in message and data
unsupported version in envelope 400 -32022 + data.supported
server/discover 200

Two observations, neither blocking

1. The -32022 era-lock defense is unreachable under stateless: true. After a modern-envelope tools/call, a following initialize is served with 200 — no lock survives between requests. That is expected given how we deploy rather than a defect, but if sessionless SEP-2575 servers are a supported shape, the initialize-on-modern-locked defense is a no-op there and dual-era detection has to rely on response codes alone.

2. MCP-Protocol-Version: 2026-07-28 alone does not make a request modern. An initialize sent with the modern version header but no _meta is served as a legacy handshake (200, negotiating 2026-07-28). That follows from the claim-key rule you described on #491, so I assume it is deliberate — flagging it only because it means a client can hold a modern version header and a legacy lifecycle at the same time, and the header alone is not a safe signal for which era a peer is speaking.

Happy to re-run any of this if the branch moves.

@koic

koic commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Thanks for running this against a real dual-era deployment; the before/after on the misclassified initialize is exactly the intended fix, and it's good to see the identical results on the rest of the surface confirmed.

Both observations are deliberate. Under stateless: true, nothing survives between requests, so era enforcement is intentionally per-request; the era lock only exists where a connection or session does (stdio and session-backed HTTP). On a sessionless endpoint, the 404/-32601 removed-method response is itself the modern-side signal for dual-era detection.

Likewise, the 2026-07-28 value in MCP-Protocol-Version is intentionally not sufficient to determine the lifecycle on its own. The header does not determine the lifecycle by itself; the request body does, with the envelope claim key identifying modern traffic and a bare initialize remaining the legacy handshake. This is covered by the test "sessionless POST initialize with the dual-era header stays legacy and negotiates 2026-07-28", so the reliable era signal is the claim key rather than the header.

## Motivation and Context

The 2026-07-28 conformance requirements reject two admission gaps in the modern (stateless) lifecycle
that the current implementation answers with the wrong signal:

- The RPCs the modern lifecycle removes (`initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, `resources/unsubscribe`)
  were still dispatched, or answered incidental errors (`initialize` answered `-32022`, `resources/subscribe` a capability error);
  the requirement is `-32601` Method not found with HTTP 404.
- `server/discover` was fully envelope-exempt, while the requirements validate its envelope
  like every other modern request.

The per-request `_meta` validation itself (`-32602` naming the offending keys, optional `clientInfo`,
era classification on the reserved `protocolVersion` key alone) landed separately in modelcontextprotocol#491;
this change builds on that shape and completes the admission rules:

- `Server#handle_request` rejects `Methods::MODERN_REMOVED_METHODS` on a modern-era session
  with `-32601` before the capability check, since the method does not exist in that era at all.
  The Streamable HTTP modern path maps `-32601` to 404 and `-32602` to 400 through its existing status ladder.
- The dual-era sniff treats an `initialize` carrying the modern envelope as modern traffic (a modern client naming a removed method)
  instead of the legacy-distinctive handshake, and `server/discover` keeps its envelope exemption outside the modern era only.
  The envelope-less rejection on a modern-locked session adopts modelcontextprotocol#491's wire shape, `-32602` with the detailed message.

One deliberate divergence from the Python SDK: its dual-era stream loop answers an `initialize` on
a modern-locked connection with `-32022` carrying the supported-version list, while its HTTP path serves
the 404/-32601 shape. The conformance requirement phrases removed methods transport-independently,
so this SDK answers `-32601` on every transport, stdio included, for uniform era semantics.

The stdio era-lock tests used a modern `ping` as the era-distinctive request; they now use `tools/list`,
since `ping` no longer exists in the modern lifecycle.

## How Has This Been Tested?

`bundle exec rake test` passes with zero failures and RuboCop reports no offenses.
New and updated tests cover each admission rule on `Server#handle`, the Streamable HTTP modern path, and the stdio era lock
(all five removed methods per transport, the envelope requirement on `server/discover`, and the envelope-carrying `initialize` routing).

Against the conformance fixture server, every admission check of the `server-stateless` scenario passes at
`--spec-version 2026-07-28`: the per-request `_meta` validation, removed-method 404, discovery, capability,
and version-negotiation checks are all green, and the only remaining failures are the `subscriptions/listen`
checks that belong to separate work. The `--requirements 2025-11-25` server leg passes 78/78, unchanged.

## Breaking Changes

None for stable protocol versions: legacy requests are byte-for-byte unchanged, and a bare legacy `_meta`
(`progressToken`, trace context) keeps its legacy classification. Within the modern lifecycle,
previously incidental error shapes are replaced by the ones the 2026-07-28 conformance requirements mandate.
@koic
koic force-pushed the enforce_modern_request_admission branch from d34fb74 to 633a5fa Compare August 8, 2026 09:52
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.

3 participants