Enforce the modern lifecycle admission rules per SEP-2575

## 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 #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.
This commit is contained in:
Koichi ITO 2026-08-08 01:37:18 +09:00
commit 633a5fa5ef
3 changed files with 144 additions and 39 deletions

View file

@ -555,7 +555,7 @@ module MCP
test "locks the legacy era on a successful initialize and rejects a later modern envelope" do
responses = run_transport_session([
initialize_request(id: 1),
modern_ping_request(id: 2),
modern_tools_list_request(id: 2),
])
refute responses[0].key?(:error)
@ -568,7 +568,7 @@ module MCP
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
responses = run_transport_session([
initialize_request(id: 1, protocol_version: "2026-07-28"),
modern_ping_request(id: 2),
modern_tools_list_request(id: 2),
])
assert_equal "2026-07-28", responses[0].dig(:result, :protocolVersion)
@ -576,22 +576,23 @@ module MCP
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
end
test "locks the modern era on a successful server/discover and rejects a later initialize with -32022" do
test "locks the modern era on a successful server/discover and rejects a later initialize with -32601" do
responses = run_transport_session([
{ jsonrpc: "2.0", method: "server/discover", id: 1 },
initialize_request(id: 2),
modern_ping_request(id: 3),
modern_tools_list_request(id: 3),
])
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
assert_equal :modern, session_era
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported)
# SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection
# answers it with Method not found.
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code)
refute responses[2].key?(:error)
end
test "locks the modern era on a successful request carrying the modern envelope" do
responses = run_transport_session([modern_ping_request(id: 1)])
responses = run_transport_session([modern_tools_list_request(id: 1)])
refute responses[0].key?(:error)
assert_equal :modern, session_era
@ -601,7 +602,7 @@ module MCP
# An unsupported envelope version fails with -32022, so the connection stays unlocked
# and a legacy initialize can still succeed afterwards.
responses = run_transport_session([
modern_ping_request(id: 1, version: "2027-01-01"),
modern_tools_list_request(id: 1, version: "2027-01-01"),
initialize_request(id: 2),
])
@ -612,16 +613,29 @@ module MCP
test "requires the modern envelope after a modern era lock" do
responses = run_transport_session([
modern_ping_request(id: 1),
{ jsonrpc: "2.0", method: "ping", id: 2 },
modern_tools_list_request(id: 1),
{ jsonrpc: "2.0", method: "tools/list", id: 2 },
])
refute responses[0].key?(:error)
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code)
end
test "answers removed lifecycle methods with -32601 after a modern era lock" do
removed_requests = Methods::MODERN_REMOVED_METHODS.each_with_index.map do |method, index|
{ jsonrpc: "2.0", method: method, id: index + 2 }
end
responses = run_transport_session([modern_tools_list_request(id: 1)] + removed_requests)
refute responses[0].key?(:error)
responses[1..].each_with_index do |response, index|
method = Methods::MODERN_REMOVED_METHODS[index]
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
end
end
test "#send_request raises on a modern-locked session" do
run_transport_session([modern_ping_request(id: 1)])
run_transport_session([modern_tools_list_request(id: 1)])
error = assert_raises(RuntimeError) do
@transport.send_request("roots/list")
@ -643,10 +657,12 @@ module MCP
}
end
def modern_ping_request(id:, version: "2026-07-28")
# `tools/list` stands in as the era-distinctive modern request: `ping` cannot,
# because SEP-2575 removes it from the modern lifecycle (-32601 there).
def modern_tools_list_request(id:, version: "2026-07-28")
{
jsonrpc: "2.0",
method: "ping",
method: "tools/list",
id: id,
params: {
_meta: {

View file

@ -5421,13 +5421,57 @@ module MCP
test "modern POST requires the _meta envelope" do
response = @transport.handle_request(modern_rack_request(
{ jsonrpc: "2.0", method: "ping", id: 1 }.to_json,
{ jsonrpc: "2.0", method: "tools/list", id: 1 }.to_json,
))
assert_equal 400, response[0]
assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code"))
end
test "modern POST rejects an envelope missing clientCapabilities with 400 and -32602" do
body = {
jsonrpc: "2.0",
method: "tools/list",
id: 1,
params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
}.to_json
response = @transport.handle_request(modern_rack_request(body))
assert_equal 400, response[0]
assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code"))
end
test "modern POST serves an envelope without the optional clientInfo" do
body = {
jsonrpc: "2.0",
method: "tools/list",
id: 1,
params: {
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}.to_json
response = @transport.handle_request(modern_rack_request(body))
assert_equal 200, response[0]
assert JSON.parse(response[2][0]).key?("result")
end
test "modern POST answers removed lifecycle methods with 404 and -32601" do
MCP::Methods::MODERN_REMOVED_METHODS.each do |method|
response = @transport.handle_request(modern_rack_request(modern_body(method, {})))
assert_equal 404, response[0], "expected 404 for #{method}"
body = JSON.parse(response[2][0])
assert_equal(-32601, body.dig("error", "code"), "expected -32601 for #{method}")
assert_equal 1, body["id"], "expected the request id to be preserved for #{method}"
end
end
test "modern POST maps a missing client capability to 400 with -32021" do
@server.define_tool(name: "guarded_tool") do |server_context:|
server_context.require_client_capability!(:elicitation, :form)
@ -5607,10 +5651,8 @@ module MCP
assert_empty reported
end
test "modern POST serves server/discover without an envelope" do
response = @transport.handle_request(modern_rack_request(
{ jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json,
))
test "modern POST serves server/discover carrying the envelope" do
response = @transport.handle_request(modern_rack_request(modern_body("server/discover", {})))
assert_equal 200, response[0]
refute response[1].key?("mcp-session-id")
@ -5618,6 +5660,17 @@ module MCP
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
end
test "modern POST rejects an envelope-less server/discover with 400 and -32602" do
# The 2026-07-28 conformance requirements validate the `_meta` envelope on `server/discover` like
# on every other modern request (SEP-2575).
response = @transport.handle_request(modern_rack_request(
{ jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json,
))
assert_equal 400, response[0]
assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code"))
end
test "legacy POST rejects a modern envelope body without the modern header as -32020" do
# Without header-primary routing evidence, the body-level triple would silently fall through
# the legacy path via the header default.

View file

@ -360,28 +360,33 @@ module MCP
assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion"
end
test "#handle rejects initialize on a modern-locked session with -32022" do
test "#handle rejects removed lifecycle methods on a modern-locked session with -32601" do
session = ServerSession.new(server: @server, transport: mock, era: :modern)
response = @server.handle(
{
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: { protocolVersion: "2025-11-25", clientInfo: { name: "c", version: "1" } },
},
session: session,
)
Methods::MODERN_REMOVED_METHODS.each_with_index do |method, index|
response = @server.handle(
{ jsonrpc: "2.0", method: method, id: index + 1 },
session: session,
)
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code)
assert_equal "2025-11-25", response.dig(:error, :data, :requested)
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
assert_equal index + 1, response[:id], "expected the request id to be preserved for #{method}"
end
end
test "#handle server/discover succeeds without an envelope on a modern-locked session" do
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).
session = ServerSession.new(server: @server, transport: mock, era: :modern)
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session)
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, response.dig(:error, :code)
end
test "#handle server/discover succeeds without an envelope outside the modern era" do
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 })
refute_nil response[:result]
end