Merge pull request #489 from koic/enforce_modern_request_admission

Enforce the modern lifecycle admission rules per SEP-2575
This commit is contained in:
Koichi ITO 2026-08-09 03:34:29 +09:00 • committed by GitHub
commit b98b783a5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 144 additions and 39 deletions

View file

@ -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"

View file

@ -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

View file

@ -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

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