Skip to content

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

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:reject_modern_removed_methods_before_era_lock
Aug 13, 2026
Merged

Reject modern-removed methods before the connection era locks#511
koic merged 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.
@koic
koic merged commit 451c2b3 into modelcontextprotocol:main Aug 13, 2026
11 checks passed
@koic
koic deleted the reject_modern_removed_methods_before_era_lock branch August 13, 2026 15:45
koic added a commit that referenced this pull request Aug 13, 2026
## Motivation and Context

`main` is red on every Ruby version: "modern non-cacheable results carry no cache hints" errors with
`undefined method 'key?' for nil`. The test reaches for `response[:result]` on a request that no longer
produces a result.

Two changes met on `main` without conflicting textually. #499 added the test and picked `ping` as its example of
a result that is not cacheable. #511 then made a modern-enveloped request naming a method SEP-2575 removed answer
with `-32601` even before the connection era locks, and `ping` is one of those methods, so the response now carries
an error rather than a result. Each branch was green against the `main` it was written on; the pairing only failed
once both had landed.

`ping` was the wrong example regardless. It is absent from the 2026-07-28 schema, so no modern request can name it
and receive a result of any shape. `tools/call` is the accurate stand-in: the spec types `CallToolResult` as
a plain `Result`, while the ones it types as `CacheableResult` are the discover, list, and read families.
The test now calls the tool it already registers, and a comment records why `ping` cannot come back here -
the same note #511 left where it replaced `ping` with `prompts/list` in the neighboring `resultType` test.

Only the test changes. The behavior both #499 and #511 implement is correct; the example the test chose was not.

## How Has This Been Tested?

The test errors on `main` and passes with this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
The rest of the suite was searched for the same pattern, a modern-enveloped request naming a method the modern lifecycle removed:
the only other occurrence is the assertion #511 added deliberately, which checks that such a request does not lock the era,
and which reads no result.

## Breaking Changes

None. No library code changes.
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