Reject modern-removed methods before the connection era locks - #511
Open
koic wants to merge 1 commit into
Open
Conversation
## Motivation and Context
SEP-2575 removes `initialize`, `ping`, `logging/setLevel`, `resources/subscribe`,
and `resources/unsubscribe` from the modern lifecycle, and the server answers them with `-32601` there.
The check consulted `ServerSession#era` alone, but `StdioTransport` locks the era in `lock_modern_era_on_success`,
which runs after a response is produced. The first frame of a stdio connection therefore reached
the server with `era == nil`, and a request carrying the modern `_meta` envelope was served instead of refused.
All five methods leaked, one request per connection:
```
initialize a full InitializeResult
ping {resultType: "complete"}
logging/setLevel {resultType: "complete"}
resources/subscribe {resultType: "complete"}
resources/unsubscribe {resultType: "complete"}
```
`initialize` is the worst of the five because it also mutates the session: serving it negotiated
the legacy lifecycle for a request that had declared the modern one, pinning the connection to
`:legacy` and marking it initialized. The Streamable HTTP transport was never affected,
since it routes sessionless modern traffic to `handle_modern` and rejects a modern envelope on every other path.
The era is the wrong thing to consult on its own: a request declares which lifecycle it belongs to
through its own envelope, independently of connection state that settles later. The Python SDK gates
on exactly that, testing each request's `_meta` before dispatch rather than a negotiated connection
mode. `modern_request?` now does the same here, treating a locked `:modern` era as authoritative and
falling back to the request's envelope while the era is still undetermined. A legacy-locked session is left alone:
`lift_request_envelope` already answers a modern envelope there with the lifecycle violation,
which names the cause better than Method not found.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` cover all five removed methods being refused with `-32601` when
they carry the envelope on an era-less session, an enveloped `initialize` no longer locking the era
or marking the session initialized, a method the modern lifecycle keeps still being served under the same
conditions, and a legacy `initialize` negotiating 2026-07-28 still succeeding and locking `:legacy` - negotiating
a dual-era version through the handshake is not the same as carrying the envelope. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives the case end to end,
sending an enveloped `resources/subscribe` as a connection's first frame.
One existing assertion changed. "modern results carry resultType complete" used `ping` as one of its
stamped examples, which only passed because of this defect; `ping` is absent from the 2026-07-28 schema,
so a modern request naming it is refused rather than stamped. It now uses `prompts/list`, with a note
on why `ping` cannot appear there. The existing legacy counterpart, which pings without an envelope,
is unchanged and still expects no stamp.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The conformance suite exercises
the HTTP transport, which this does not change, so its baseline is unaffected.
## Breaking Changes
None for a conforming client. A request that both carries the 2026-07-28 envelope and names a method
that revision removed now receives `-32601` instead of a result, which is the answer the spec already
required once the era was known.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
SEP-2575 removes
initialize,ping,logging/setLevel,resources/subscribe, andresources/unsubscribefrom the modern lifecycle, and the server answers them with-32601there. The check consultedServerSession#eraalone, butStdioTransportlocks the era inlock_modern_era_on_success, which runs after a response is produced. The first frame of a stdio connection therefore reached the server withera == nil, and a request carrying the modern_metaenvelope was served instead of refused. All five methods leaked, one request per connection:initializeis the worst of the five because it also mutates the session: serving it negotiated the legacy lifecycle for a request that had declared the modern one, pinning the connection to:legacyand marking it initialized. The Streamable HTTP transport was never affected, since it routes sessionless modern traffic tohandle_modernand rejects a modern envelope on every other path.The era is the wrong thing to consult on its own: a request declares which lifecycle it belongs to through its own envelope, independently of connection state that settles later. The Python SDK gates on exactly that, testing each request's
_metabefore dispatch rather than a negotiated connection mode.modern_request?now does the same here, treating a locked:modernera as authoritative and falling back to the request's envelope while the era is still undetermined. A legacy-locked session is left alone:lift_request_envelopealready answers a modern envelope there with the lifecycle violation, which names the cause better than Method not found.How Has This Been Tested?
New tests in
test/mcp/server_test.rbcover all five removed methods being refused with-32601when they carry the envelope on an era-less session, an envelopedinitializeno longer locking the era or marking the session initialized, a method the modern lifecycle keeps still being served under the same conditions, and a legacyinitializenegotiating 2026-07-28 still succeeding and locking:legacy- negotiating a dual-era version through the handshake is not the same as carrying the envelope. A test intest/mcp/server/transports/stdio_transport_test.rbdrives the case end to end, sending an envelopedresources/subscribeas a connection's first frame.One existing assertion changed. "modern results carry resultType complete" used
pingas one of its stamped examples, which only passed because of this defect;pingis absent from the 2026-07-28 schema, so a modern request naming it is refused rather than stamped. It now usesprompts/list, with a note on whypingcannot appear there. The existing legacy counterpart, which pings without an envelope, is unchanged and still expects no stamp.bundle exec rake(tests, RuboCop, and conformance baseline) passes. The conformance suite exercises the HTTP transport, which this does not change, so its baseline is unaffected.Breaking Changes
None for a conforming client. A request that both carries the 2026-07-28 envelope and names a method that revision removed now receives
-32601instead of a result, which is the answer the spec already required once the era was known.Types of changes
Checklist