Skip to content

Reject modern-removed methods before the connection era locks - #511

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

Reject modern-removed methods before the connection era locks#511
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:reject_modern_removed_methods_before_era_lock

Conversation

@koic

@koic koic commented Aug 12, 2026

Copy link
Copy Markdown
Member

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.

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

## 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.
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