Reject modern-removed methods before the connection era locks

## 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 commit is contained in:
Koichi ITO 2026-08-13 01:14:51 +09:00
commit bd702d3e51
3 changed files with 96 additions and 4 deletions

View file

@ -565,9 +565,9 @@ module MCP
end
# SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found
# there regardless of the envelope or the server's declared capabilities: in this era the method
# does not exist at all, which is why the check precedes `ensure_capability!`.
if session.respond_to?(:era) && session.era == :modern && Methods::MODERN_REMOVED_METHODS.include?(method)
# there regardless of the server's declared capabilities: in this era the method does not exist
# at all, which is why the check precedes `ensure_capability!`.
if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session)
raise RequestHandlerError.new(
"Method not found: #{method} is not part of the modern lifecycle (SEP-2575)",
request,
@ -677,6 +677,23 @@ module MCP
}
end
# Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it.
# A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is
# the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds,
# which would otherwise let the first request of a connection reach a method the modern lifecycle does not have.
# The Python SDK gates the same way, on the envelope of each request rather than on connection state that
# is only settled afterwards.
#
# A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there
# with the lifecycle violation, which names the cause better than Method not found.
def modern_request?(request, session)
era = session.respond_to?(:era) ? session.era : nil
return true if era == :modern
return false unless era.nil?
RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
end
# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is

View file

@ -598,6 +598,29 @@ module MCP
assert_equal :modern, session_era
end
test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do
# The era locks only after a response succeeds, so this frame reaches the server unlocked.
# SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern.
responses = run_transport_session([
{
jsonrpc: "2.0",
method: "resources/subscribe",
id: 1,
params: {
uri: "https://example.invalid/resource",
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
"io.modelcontextprotocol/clientCapabilities": {},
},
},
},
])
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code)
refute_equal :legacy, session_era
end
test "does not lock an era when the era-distinctive request fails" do
# An unsupported envelope version fails with -32022, so the connection stays unlocked
# and a legacy initialize can still succeed afterwards.

View file

@ -374,6 +374,56 @@ module MCP
end
end
test "#handle rejects removed lifecycle methods carrying the envelope before the era locks" do
# `StdioTransport` locks the era only after a response succeeds, so the first request of a connection
# arrives with `era == nil`. The envelope on the request is what identifies it as modern there.
Methods::MODERN_REMOVED_METHODS.each do |method|
session = ServerSession.new(server: @server, transport: mock)
response = @server.handle(modern_request(method, {}), session: session)
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
end
end
test "#handle does not let an enveloped initialize lock the era before it is settled" do
# Serving it would negotiate the legacy lifecycle for a request that declared the modern one,
# pinning the connection to the wrong era for its whole lifetime.
session = ServerSession.new(server: @server, transport: mock)
@server.handle(modern_request("initialize", initialize_params), session: session)
assert_nil session.era
refute_predicate session, :initialized?
end
test "#handle serves an enveloped method the modern lifecycle keeps before the era locks" do
session = ServerSession.new(server: @server, transport: mock)
response = @server.handle(modern_request("tools/list", {}), session: session)
refute_nil response[:result]
end
test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do
# Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope:
# the handshake still belongs to the legacy lifecycle and locks that era.
session = ServerSession.new(server: @server, transport: mock)
response = @server.handle(
{
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: initialize_params(protocolVersion: "2026-07-28"),
},
session: session,
)
assert_equal "2026-07-28", response.dig(:result, :protocolVersion)
assert_equal :legacy, session.era
end
test "#handle server/discover requires the envelope on a modern-locked session" do
# The 2026-07-28 conformance requirements reject an envelope-less `server/discover`
# on the modern era with -32602 (SEP-2575).
@ -4255,9 +4305,11 @@ module MCP
test "modern results carry resultType complete" do
server = Server.new(name: "result_type_test", tools: [result_type_tool])
# Every method here has to be one the modern lifecycle still has. `ping` does not qualify:
# SEP-2575 removed it, so a modern request naming it is answered with -32601, not a stamped result.
[
modern_request(Methods::TOOLS_LIST, {}),
modern_request(Methods::PING, {}),
modern_request(Methods::PROMPTS_LIST, {}),
modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }),
].each do |request|
response = server.handle(request)