Merge pull request #489 from koic/enforce_modern_request_admission
Enforce the modern lifecycle admission rules per SEP-2575
This commit is contained in:
commit
b98b783a5f
6 changed files with 144 additions and 39 deletions
|
|
@ -21,6 +21,20 @@ module MCP
|
||||||
TOOLS_CALL = "tools/call"
|
TOOLS_CALL = "tools/call"
|
||||||
TOOLS_LIST = "tools/list"
|
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"
|
ROOTS_LIST = "roots/list"
|
||||||
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
|
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
|
||||||
ELICITATION_CREATE = "elicitation/create"
|
ELICITATION_CREATE = "elicitation/create"
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,18 @@ module MCP
|
||||||
return
|
return
|
||||||
end
|
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
|
begin
|
||||||
Methods.ensure_capability!(method, capabilities)
|
Methods.ensure_capability!(method, capabilities)
|
||||||
rescue Methods::MissingRequiredCapabilityError => e
|
rescue Methods::MissingRequiredCapabilityError => e
|
||||||
|
|
@ -669,14 +681,13 @@ module MCP
|
||||||
# an invalid request because a connection can never change eras.
|
# an invalid request because a connection can never change eras.
|
||||||
def lift_request_envelope(params, method:, session:)
|
def lift_request_envelope(params, method:, session:)
|
||||||
return if Methods.notification?(method)
|
return if Methods.notification?(method)
|
||||||
return if method == Methods::SERVER_DISCOVER
|
|
||||||
|
|
||||||
era = session.respond_to?(:era) ? session.era : nil
|
era = session.respond_to?(:era) ? session.era : nil
|
||||||
|
|
||||||
if era == :modern && method == Methods::INITIALIZE
|
# Outside the modern era, `server/discover` stays envelope-exempt so legacy connections can probe capabilities
|
||||||
requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil
|
# before any negotiation. Under the modern era every request carries the envelope, discovery included:
|
||||||
raise UnsupportedProtocolVersionError.new(requested, params)
|
# the conformance suite's 2026-07-28 requirements reject an envelope-less `server/discover` with `-32602` (SEP-2575).
|
||||||
end
|
return if method == Methods::SERVER_DISCOVER && era != :modern
|
||||||
|
|
||||||
if RequestEnvelope.modern?(params)
|
if RequestEnvelope.modern?(params)
|
||||||
if era == :legacy
|
if era == :legacy
|
||||||
|
|
|
||||||
|
|
@ -1041,10 +1041,16 @@ module MCP
|
||||||
!MCP::Configuration.modern_protocol_version?(version)
|
!MCP::Configuration.modern_protocol_version?(version)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body is legacy-distinctive.
|
# Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body
|
||||||
# Unparsable or non-object bodies go to the modern path, whose error responses cover them.
|
# 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)
|
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
|
rescue InvalidJsonError
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -555,7 +555,7 @@ module MCP
|
||||||
test "locks the legacy era on a successful initialize and rejects a later modern envelope" do
|
test "locks the legacy era on a successful initialize and rejects a later modern envelope" do
|
||||||
responses = run_transport_session([
|
responses = run_transport_session([
|
||||||
initialize_request(id: 1),
|
initialize_request(id: 1),
|
||||||
modern_ping_request(id: 2),
|
modern_tools_list_request(id: 2),
|
||||||
])
|
])
|
||||||
|
|
||||||
refute responses[0].key?(:error)
|
refute responses[0].key?(:error)
|
||||||
|
|
@ -568,7 +568,7 @@ module MCP
|
||||||
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
|
# the legacy handshake locks `:legacy`, so a later modern envelope is still rejected.
|
||||||
responses = run_transport_session([
|
responses = run_transport_session([
|
||||||
initialize_request(id: 1, protocol_version: "2026-07-28"),
|
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)
|
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)
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, responses[1].dig(:error, :code)
|
||||||
end
|
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([
|
responses = run_transport_session([
|
||||||
{ jsonrpc: "2.0", method: "server/discover", id: 1 },
|
{ jsonrpc: "2.0", method: "server/discover", id: 1 },
|
||||||
initialize_request(id: 2),
|
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 Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[0].dig(:result, :supportedVersions)
|
||||||
assert_equal :modern, session_era
|
assert_equal :modern, session_era
|
||||||
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, responses[1].dig(:error, :code)
|
# SEP-2575 removes `initialize` from the modern lifecycle, so a modern-locked connection
|
||||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, responses[1].dig(:error, :data, :supported)
|
# answers it with Method not found.
|
||||||
|
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[1].dig(:error, :code)
|
||||||
refute responses[2].key?(:error)
|
refute responses[2].key?(:error)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "locks the modern era on a successful request carrying the modern envelope" do
|
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)
|
refute responses[0].key?(:error)
|
||||||
assert_equal :modern, session_era
|
assert_equal :modern, session_era
|
||||||
|
|
@ -601,7 +602,7 @@ module MCP
|
||||||
# An unsupported envelope version fails with -32022, so the connection stays unlocked
|
# An unsupported envelope version fails with -32022, so the connection stays unlocked
|
||||||
# and a legacy initialize can still succeed afterwards.
|
# and a legacy initialize can still succeed afterwards.
|
||||||
responses = run_transport_session([
|
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),
|
initialize_request(id: 2),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -612,16 +613,29 @@ module MCP
|
||||||
|
|
||||||
test "requires the modern envelope after a modern era lock" do
|
test "requires the modern envelope after a modern era lock" do
|
||||||
responses = run_transport_session([
|
responses = run_transport_session([
|
||||||
modern_ping_request(id: 1),
|
modern_tools_list_request(id: 1),
|
||||||
{ jsonrpc: "2.0", method: "ping", id: 2 },
|
{ jsonrpc: "2.0", method: "tools/list", id: 2 },
|
||||||
])
|
])
|
||||||
|
|
||||||
refute responses[0].key?(:error)
|
refute responses[0].key?(:error)
|
||||||
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code)
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_PARAMS, responses[1].dig(:error, :code)
|
||||||
end
|
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
|
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
|
error = assert_raises(RuntimeError) do
|
||||||
@transport.send_request("roots/list")
|
@transport.send_request("roots/list")
|
||||||
|
|
@ -643,10 +657,12 @@ module MCP
|
||||||
}
|
}
|
||||||
end
|
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",
|
jsonrpc: "2.0",
|
||||||
method: "ping",
|
method: "tools/list",
|
||||||
id: id,
|
id: id,
|
||||||
params: {
|
params: {
|
||||||
_meta: {
|
_meta: {
|
||||||
|
|
|
||||||
|
|
@ -5421,13 +5421,57 @@ module MCP
|
||||||
|
|
||||||
test "modern POST requires the _meta envelope" do
|
test "modern POST requires the _meta envelope" do
|
||||||
response = @transport.handle_request(modern_rack_request(
|
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 400, response[0]
|
||||||
assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code"))
|
assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code"))
|
||||||
end
|
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
|
test "modern POST maps a missing client capability to 400 with -32021" do
|
||||||
@server.define_tool(name: "guarded_tool") do |server_context:|
|
@server.define_tool(name: "guarded_tool") do |server_context:|
|
||||||
server_context.require_client_capability!(:elicitation, :form)
|
server_context.require_client_capability!(:elicitation, :form)
|
||||||
|
|
@ -5607,10 +5651,8 @@ module MCP
|
||||||
assert_empty reported
|
assert_empty reported
|
||||||
end
|
end
|
||||||
|
|
||||||
test "modern POST serves server/discover without an envelope" do
|
test "modern POST serves server/discover carrying the envelope" do
|
||||||
response = @transport.handle_request(modern_rack_request(
|
response = @transport.handle_request(modern_rack_request(modern_body("server/discover", {})))
|
||||||
{ jsonrpc: "2.0", method: "server/discover", id: 1 }.to_json,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert_equal 200, response[0]
|
assert_equal 200, response[0]
|
||||||
refute response[1].key?("mcp-session-id")
|
refute response[1].key?("mcp-session-id")
|
||||||
|
|
@ -5618,6 +5660,17 @@ module MCP
|
||||||
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
|
||||||
end
|
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
|
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
|
# Without header-primary routing evidence, the body-level triple would silently fall through
|
||||||
# the legacy path via the header default.
|
# the legacy path via the header default.
|
||||||
|
|
|
||||||
|
|
@ -360,28 +360,33 @@ module MCP
|
||||||
assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion"
|
assert_includes response.dig(:error, :message), "io.modelcontextprotocol/protocolVersion"
|
||||||
end
|
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)
|
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||||
|
|
||||||
response = @server.handle(
|
Methods::MODERN_REMOVED_METHODS.each_with_index do |method, index|
|
||||||
{
|
response = @server.handle(
|
||||||
jsonrpc: "2.0",
|
{ jsonrpc: "2.0", method: method, id: index + 1 },
|
||||||
method: "initialize",
|
session: session,
|
||||||
id: 1,
|
)
|
||||||
params: { protocolVersion: "2025-11-25", clientInfo: { name: "c", version: "1" } },
|
|
||||||
},
|
|
||||||
session: session,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code)
|
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
|
||||||
assert_equal "2025-11-25", response.dig(:error, :data, :requested)
|
assert_equal index + 1, response[:id], "expected the request id to be preserved for #{method}"
|
||||||
|
end
|
||||||
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)
|
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||||
|
|
||||||
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session)
|
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]
|
refute_nil response[:result]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue