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

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