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:
parent
4fac357343
commit
633a5fa5ef
6 changed files with 144 additions and 39 deletions
|
|
@ -21,6 +21,20 @@ module MCP
|
|||
TOOLS_CALL = "tools/call"
|
||||
TOOLS_LIST = "tools/list"
|
||||
|
||||
# RPC methods the stateless modern lifecycle removes (MCP 2026-07-28, SEP-2575):
|
||||
# `initialize` is replaced by the per-request `_meta` envelope plus `server/discover`,
|
||||
# `logging/setLevel` by the envelope's `logLevel` member, and `ping` and the resource
|
||||
# subscription pair by the connectionless model, which leaves nothing to keep alive
|
||||
# or subscribe on. A modern-era request naming one of these answers with `-32601`
|
||||
# Method not found (HTTP 404 on Streamable HTTP).
|
||||
MODERN_REMOVED_METHODS = [
|
||||
INITIALIZE,
|
||||
PING,
|
||||
LOGGING_SET_LEVEL,
|
||||
RESOURCES_SUBSCRIBE,
|
||||
RESOURCES_UNSUBSCRIBE,
|
||||
].freeze
|
||||
|
||||
ROOTS_LIST = "roots/list"
|
||||
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
|
||||
ELICITATION_CREATE = "elicitation/create"
|
||||
|
|
|
|||
|
|
@ -561,6 +561,18 @@ module MCP
|
|||
return
|
||||
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)
|
||||
raise RequestHandlerError.new(
|
||||
"Method not found: #{method} is not part of the modern lifecycle (SEP-2575)",
|
||||
request,
|
||||
error_type: :method_not_found,
|
||||
error_code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
|
||||
)
|
||||
end
|
||||
|
||||
begin
|
||||
Methods.ensure_capability!(method, capabilities)
|
||||
rescue Methods::MissingRequiredCapabilityError => e
|
||||
|
|
@ -669,14 +681,13 @@ module MCP
|
|||
# an invalid request because a connection can never change eras.
|
||||
def lift_request_envelope(params, method:, session:)
|
||||
return if Methods.notification?(method)
|
||||
return if method == Methods::SERVER_DISCOVER
|
||||
|
||||
era = session.respond_to?(:era) ? session.era : nil
|
||||
|
||||
if era == :modern && method == Methods::INITIALIZE
|
||||
requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil
|
||||
raise UnsupportedProtocolVersionError.new(requested, params)
|
||||
end
|
||||
# Outside the modern era, `server/discover` stays envelope-exempt so legacy connections can probe capabilities
|
||||
# before any negotiation. Under the modern era every request carries the envelope, discovery included:
|
||||
# the conformance suite's 2026-07-28 requirements reject an envelope-less `server/discover` with `-32602` (SEP-2575).
|
||||
return if method == Methods::SERVER_DISCOVER && era != :modern
|
||||
|
||||
if RequestEnvelope.modern?(params)
|
||||
if era == :legacy
|
||||
|
|
|
|||
|
|
@ -1041,10 +1041,16 @@ module MCP
|
|||
!MCP::Configuration.modern_protocol_version?(version)
|
||||
end
|
||||
|
||||
# Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body is legacy-distinctive.
|
||||
# Unparsable or non-object bodies go to the modern path, whose error responses cover them.
|
||||
# Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body
|
||||
# WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying
|
||||
# the envelope comes from a modern client naming a method its lifecycle removed, so it stays
|
||||
# on the modern path and answers with -32601/404 (SEP-2575).
|
||||
# Unparsable or non-object bodies go to the modern path, whose error responses cover them.
|
||||
def legacy_handshake_body?(body_string)
|
||||
initialize_request?(parse_request_body(body_string))
|
||||
body = parse_request_body(body_string)
|
||||
return false unless initialize_request?(body)
|
||||
|
||||
!RequestEnvelope.modern?(body[:params])
|
||||
rescue InvalidJsonError
|
||||
false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue