Merge pull request #479 from koic/http_modern_path

Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575
This commit is contained in:
Koichi ITO 2026-08-04 00:26:12 +09:00 • committed by GitHub
commit d51d4a0f91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 560 additions and 30 deletions

View file

@ -149,6 +149,21 @@ module MCP
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`. # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
# JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575).
NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze
# JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404
# (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors,
# stays 200, matching the Python SDK's status ladder.
MODERN_BAD_REQUEST_CODES = [
ErrorCodes::HEADER_MISMATCH,
ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
JsonRpcHandler::ErrorCode::PARSE_ERROR,
JsonRpcHandler::ErrorCode::INVALID_REQUEST,
JsonRpcHandler::ErrorCode::INVALID_PARAMS,
].freeze
# Rack app interface. This transport can be mounted as a Rack app. # Rack app interface. This transport can be mounted as a Rack app.
def call(env) def call(env)
handle_request(Rack::Request.new(env)) handle_request(Rack::Request.new(env))
@ -158,6 +173,41 @@ module MCP
rebinding_error = validate_dns_rebinding(request) rebinding_error = validate_dns_rebinding(request)
return rebinding_error if rebinding_error return rebinding_error if rebinding_error
# Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
# every supported list routes to the sessionless modern path, so an unknown future version receives
# the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
# Requests without the header, or with a stable-only version, take the existing paths untouched.
# An empty header value is malformed rather than a version claim, so it stays on the legacy path
# and fails legacy header validation as before.
#
# 2026-07-28 serves both lifecycles of the dual-era model, so for that header value the version
# alone cannot decide the era: an `Mcp-Session-Id` binds the request to an established legacy session
# (POST requests, the GET SSE stream, and DELETE termination keep working), and a session-less POST whose
# body is `initialize` is the legacy-distinctive handshake. Everything else under a dual-era header is
# sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests
# that get the modern path's error shape).
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
if header_version && !header_version.empty? && !stable_only_version?(header_version)
unless MCP::Configuration.modern_protocol_version?(header_version)
return handle_modern(request, header_version)
end
if extract_session_id(request).nil?
return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST"
# The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here,
# bounded, and hands the string to whichever path serves the request.
body_string = read_bounded_body(request)
return payload_too_large_response if body_string.nil?
unless legacy_handshake_body?(body_string)
return handle_modern(request, header_version, body_string: body_string)
end
return handle_post(request, body_string: body_string)
end
end
case request.env["REQUEST_METHOD"] case request.env["REQUEST_METHOD"]
when "POST" when "POST"
handle_post(request) handle_post(request)
@ -457,7 +507,158 @@ module MCP
stream.flush stream.flush
end end
def handle_post(request) # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
# a single POST/JSON exchange with no session. The modern path never consults
# `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
# an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`)
# and DELETE (session termination) have no modern meaning.
def handle_modern(request, header_version, body_string: nil)
return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST"
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE)
return accept_error if accept_error
content_type_error = validate_content_type(request)
return content_type_error if content_type_error
if body_string.nil?
body_string = read_bounded_body(request)
return payload_too_large_response if body_string.nil?
end
begin
body = parse_request_body(body_string)
rescue InvalidJsonError
return invalid_json_response
end
unless body.is_a?(Hash)
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
end
# The version check precedes everything else that depends on request content,
# so a client probing with an unknown future version always receives the `-32022` signal
# (with the supported list to select from) rather than an incidental error.
unless MCP::Configuration.modern_protocol_version?(header_version)
return json_rpc_error_response(
status: 400,
code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
message: "Unsupported protocol version",
data: {
supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
requested: header_version,
},
id: body[:id],
)
end
if extract_session_id(request)
return json_rpc_error_response(
status: 400,
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
id: body[:id],
)
end
mismatch_error = validate_modern_headers(request, body, header_version)
return mismatch_error if mismatch_error
response = @server.handle(body, session: modern_session)
# `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
return handle_accepted if response.nil?
[modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
rescue StandardError => e
MCP.configuration.exception_reporter.call(e, { request: body_string })
json_rpc_error_response(
status: 500,
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
message: "Internal server error",
)
end
# Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header
# MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match
# the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks
# converge on enforcement.
def validate_modern_headers(request, body, header_version)
params = body[:params]
meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
if meta_version && meta_version != header_version
return header_mismatch_response(
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
body[:id],
)
end
method_header = request.env["HTTP_MCP_METHOD"]
if method_header && method_header != body[:method]
return header_mismatch_response(
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
body[:id],
)
end
name_header = request.env["HTTP_MCP_NAME"]
if name_header && NAME_BEARING_METHODS.include?(body[:method])
body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
decoded_name = decode_header_value(name_header)
if body_name && decoded_name != body_name
return header_mismatch_response(
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
body[:id],
)
end
end
nil
end
def header_mismatch_response(message, id)
json_rpc_error_response(
status: 400,
code: ErrorCodes::HEADER_MISMATCH,
message: "Header mismatch: #{message}",
id: id,
)
end
# Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64?<base64>?=` decodes to
# its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport
# requires faraday, which servers do not depend on.
def decode_header_value(value)
match = value.match(/\A=\?base64\?(.*)\?=\z/m)
return value unless match
match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
rescue ArgumentError
value
end
# Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to
# the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing
# keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions
# via the `session_id.nil?` branch.
def modern_session
ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern)
end
def modern_http_status(response)
error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil
if error_code.nil?
200
elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND
404
elsif MODERN_BAD_REQUEST_CODES.include?(error_code)
400
else
200
end
end
def handle_post(request, body_string: nil)
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
accept_error = validate_accept_header(request, required_types) accept_error = validate_accept_header(request, required_types)
return accept_error if accept_error return accept_error if accept_error
@ -465,8 +666,10 @@ module MCP
content_type_error = validate_content_type(request) content_type_error = validate_content_type(request)
return content_type_error if content_type_error return content_type_error if content_type_error
body_string = read_bounded_body(request) if body_string.nil?
return payload_too_large_response if body_string.nil? body_string = read_bounded_body(request)
return payload_too_large_response if body_string.nil?
end
session_id = extract_session_id(request) session_id = extract_session_id(request)
@ -483,6 +686,28 @@ module MCP
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object") return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
end end
# Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs,
# so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only.
# Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session
# already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras
# (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version,
# which violates the header/body match requirement and would fall through the legacy path via
# the header default; reject that as a header mismatch.
if RequestEnvelope.modern?(body[:params])
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
if header_version && MCP::Configuration.modern_protocol_version?(header_version)
return invalid_request_response(
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
request_id: body[:id],
)
end
return header_mismatch_response(
"MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope",
body[:id],
)
end
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize` # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored. # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
# This matches the TypeScript and Python SDKs. # This matches the TypeScript and Python SDKs.
@ -748,6 +973,21 @@ module MCP
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
end end
# A version negotiable only through the legacy handshake, with no modern meaning.
# Dual-era versions (2026-07-28) appear in both lists and need further disambiguation.
def stable_only_version?(version)
MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
!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.
def legacy_handshake_body?(body_string)
initialize_request?(parse_request_body(body_string))
rescue InvalidJsonError
false
end
def validate_protocol_version_header(request) def validate_protocol_version_header(request)
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value) return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@ -760,8 +1000,10 @@ module MCP
) )
end end
def json_rpc_error_response(status:, code:, message:) def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } } error = { code: code, message: message }
error[:data] = data if data
body = { jsonrpc: "2.0", id: id, error: error }
[status, { "content-type" => "application/json" }, [body.to_json]] [status, { "content-type" => "application/json" }, [body.to_json]]
end end

View file

@ -369,7 +369,7 @@ module MCP
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 202, response[0] assert_equal 202, response[0]
refute response[1].key?("Mcp-Session-Id"), "no session id should leak from an id-less init" refute response[1].key?("mcp-session-id"), "no session id should leak from an id-less init"
assert_equal [], response[2] assert_equal [], response[2]
assert_equal({}, @transport.instance_variable_get(:@sessions)) assert_equal({}, @transport.instance_variable_get(:@sessions))
end end
@ -661,7 +661,7 @@ module MCP
assert_equal 200, response[0] assert_equal 200, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions") assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
refute response[1].key?("Mcp-Session-Id") refute response[1].key?("mcp-session-id")
end end
test "allows server/discover POST without session ID in stateless mode" do test "allows server/discover POST without session ID in stateless mode" do
@ -1840,7 +1840,26 @@ module MCP
assert_equal "text/event-stream", response[1]["content-type"] assert_equal "text/event-stream", response[1]["content-type"]
end end
test "POST initialize request ignores MCP-Protocol-Version header" do test "POST initialize request ignores MCP-Protocol-Version header among legacy versions" do
# Per SEP-2575 header-primary routing, stable-only header values stay on the legacy path
# where `initialize` treats the body `params.protocolVersion` as authoritative.
# An unknown header value routes to the modern path (see the test below), and under
# the dual-era 2026-07-28 header a sessionless `initialize` stays legacy as well.
request = create_rack_request(
"POST",
"/",
{
"CONTENT_TYPE" => "application/json",
"HTTP_MCP_PROTOCOL_VERSION" => "2024-11-05",
},
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
)
response = @transport.handle_request(request)
assert_equal 200, response[0]
end
test "POST initialize request with a non-legacy MCP-Protocol-Version header is rejected as modern traffic" do
request = create_rack_request( request = create_rack_request(
"POST", "POST",
"/", "/",
@ -1852,7 +1871,10 @@ module MCP
) )
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 200, response[0] assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32022, body.dig("error", "code"))
assert_equal "1900-01-01", body.dig("error", "data", "requested")
end end
test "POST initialize request negotiates body protocolVersion when header is an older supported version" do test "POST initialize request negotiates body protocolVersion when header is an older supported version" do
@ -1932,12 +1954,13 @@ module MCP
assert_equal 400, response[0] assert_equal 400, response[0]
assert_equal({ "content-type" => "application/json" }, response[1]) assert_equal({ "content-type" => "application/json" }, response[1])
# Per SEP-2575 header-primary routing, a non-legacy version is modern traffic and
# receives `-32022` with the supported list instead of the legacy `-32600` message.
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_equal "2.0", body["jsonrpc"] assert_equal "2.0", body["jsonrpc"]
assert_nil body["id"] assert_equal(-32022, body["error"]["code"])
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal "1999-01-01", body.dig("error", "data", "requested")
assert_includes body["error"]["message"], "1999-01-01" assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported")
assert_includes body["error"]["message"], Configuration::LATEST_STABLE_PROTOCOL_VERSION
end end
test "POST request with malformed MCP-Protocol-Version returns 400" do test "POST request with malformed MCP-Protocol-Version returns 400" do
@ -1965,7 +1988,8 @@ module MCP
assert_equal 400, response[0] assert_equal 400, response[0]
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_includes body["error"]["message"], "not-a-version" assert_equal(-32022, body["error"]["code"])
assert_equal "not-a-version", body.dig("error", "data", "requested")
end end
test "POST request with supported MCP-Protocol-Version succeeds" do test "POST request with supported MCP-Protocol-Version succeeds" do
@ -2056,10 +2080,10 @@ module MCP
) )
response = @transport.send(:validate_protocol_version_header, request) response = @transport.send(:validate_protocol_version_header, request)
assert_equal 400, response[0] assert_equal(400, response[0])
body = JSON.parse(response[2][0]) body = JSON.parse(response[2][0])
assert_includes body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION assert_includes(body["error"]["message"], MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION)
ensure ensure
MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS) MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_VERSIONS)
MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, original_versions) MCP::Configuration.const_set(:SUPPORTED_STABLE_PROTOCOL_VERSIONS, original_versions)
@ -2121,7 +2145,9 @@ module MCP
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"] assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
end end
test "GET request with unsupported MCP-Protocol-Version returns 400" do test "GET request with a non-legacy MCP-Protocol-Version returns 405" do
# Per SEP-2575 header-primary routing, a non-legacy version is modern traffic,
# and the modern lifecycle removed the GET listening stream.
init_request = create_rack_request( init_request = create_rack_request(
"POST", "POST",
"/", "/",
@ -2141,10 +2167,7 @@ module MCP
) )
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 400, response[0] assert_equal 405, response[0]
body = JSON.parse(response[2][0])
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
end end
test "GET request without MCP-Protocol-Version header succeeds" do test "GET request without MCP-Protocol-Version header succeeds" do
@ -2167,7 +2190,9 @@ module MCP
assert_equal 200, response[0] assert_equal 200, response[0]
end end
test "DELETE request with unsupported MCP-Protocol-Version returns 400" do test "DELETE request with a non-legacy MCP-Protocol-Version returns 405" do
# Per SEP-2575 header-primary routing, a non-legacy version is modern traffic,
# and the modern lifecycle has no DELETE (there is no session to terminate).
init_request = create_rack_request( init_request = create_rack_request(
"POST", "POST",
"/", "/",
@ -2187,13 +2212,10 @@ module MCP
) )
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 400, response[0] assert_equal 405, response[0]
body = JSON.parse(response[2][0])
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
end end
test "DELETE request with unsupported MCP-Protocol-Version returns 400 in stateless mode" do test "DELETE request with a non-legacy MCP-Protocol-Version returns 405 in stateless mode" do
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
request = create_rack_request( request = create_rack_request(
@ -2203,10 +2225,11 @@ module MCP
) )
response = stateless_transport.handle_request(request) response = stateless_transport.handle_request(request)
assert_equal 400, response[0] assert_equal 405, response[0]
end end
test "DELETE request validates session before MCP-Protocol-Version" do test "DELETE request with an unknown session and a non-legacy MCP-Protocol-Version returns 405" do
# Header-primary routing (SEP-2575) decides the era before any session validation.
request = create_rack_request( request = create_rack_request(
"DELETE", "DELETE",
"/", "/",
@ -2217,7 +2240,7 @@ module MCP
) )
response = @transport.handle_request(request) response = @transport.handle_request(request)
assert_equal 404, response[0] assert_equal 405, response[0]
end end
test "stateless mode allows requests without session IDs, responding with no session ID" do test "stateless mode allows requests without session IDs, responding with no session ID" do
@ -5268,6 +5291,245 @@ module MCP
transport.close transport.close
end end
test "modern POST serves a single sessionless JSON exchange" do
@server.define_tool(name: "echo_tool") do
Tool::Response.new([{ type: "text", text: "ok" }])
end
response = @transport.handle_request(modern_rack_request(
modern_body("tools/call", { name: "echo_tool", arguments: {} }),
headers: { "HTTP_MCP_METHOD" => "tools/call", "HTTP_MCP_NAME" => "echo_tool" },
))
assert_equal 200, response[0]
refute response[1].key?("mcp-session-id")
body = JSON.parse(response[2][0])
assert_equal "ok", body.dig("result", "content", 0, "text")
end
test "modern POST with an unsupported header version returns 400 with -32022 and the supported list" do
response = @transport.handle_request(modern_rack_request(
modern_body("ping", {}, version: "2027-01-01"),
version: "2027-01-01",
))
assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32022, body.dig("error", "code"))
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported")
assert_equal "2027-01-01", body.dig("error", "data", "requested")
end
test "modern POST with a header/body version mismatch returns 400 with -32020" do
# Header names the supported modern version; the body envelope claims another.
response = @transport.handle_request(modern_rack_request(
modern_body("ping", {}, version: "2027-01-01"),
))
assert_equal 400, response[0]
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
end
test "modern POST with an Mcp-Method header mismatch returns 400 with -32020" do
response = @transport.handle_request(modern_rack_request(
modern_body("ping", {}),
headers: { "HTTP_MCP_METHOD" => "tools/call" },
))
assert_equal 400, response[0]
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
end
test "modern POST decodes the base64 Mcp-Name sentinel and enforces the match" do
# `Mcp-Name` mirrors `params.uri` for `resources/read`; a non-ASCII value arrives
# wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`.
uri = "file:///日本語.txt"
encoded = "=?base64?#{[uri].pack("m0")}?="
matched = @transport.handle_request(modern_rack_request(
modern_body("resources/read", { uri: uri }),
headers: { "HTTP_MCP_NAME" => encoded },
))
mismatched = @transport.handle_request(modern_rack_request(
modern_body("resources/read", { uri: "file:///other.txt" }),
headers: { "HTTP_MCP_NAME" => encoded },
))
assert_equal 200, matched[0]
assert_equal 400, mismatched[0]
assert_equal(-32020, JSON.parse(mismatched[2][0]).dig("error", "code"))
end
test "modern POST rejects an Mcp-Session-Id header" do
response = @transport.handle_request(modern_rack_request(
modern_body("ping", {}),
headers: { "HTTP_MCP_SESSION_ID" => "some-session" },
))
assert_equal 400, response[0]
assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code"))
end
test "modern GET and DELETE are not allowed" do
get_request = create_rack_request(
"GET",
"/",
{ "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" },
)
delete_request = create_rack_request(
"DELETE",
"/",
{ "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" },
)
assert_equal 405, @transport.handle_request(get_request)[0]
assert_equal 405, @transport.handle_request(delete_request)[0]
end
test "modern POST maps an unknown method to 404 with -32601" do
response = @transport.handle_request(modern_rack_request(modern_body("no/such_method", {})))
assert_equal 404, response[0]
assert_equal(-32601, JSON.parse(response[2][0]).dig("error", "code"))
end
test "modern POST requires the _meta envelope" do
response = @transport.handle_request(modern_rack_request(
{ jsonrpc: "2.0", method: "ping", id: 1 }.to_json,
))
assert_equal 400, response[0]
assert_equal(-32600, JSON.parse(response[2][0]).dig("error", "code"))
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)
Tool::Response.new([{ type: "text", text: "ok" }])
end
response = @transport.handle_request(modern_rack_request(
modern_body("tools/call", { name: "guarded_tool", arguments: {} }),
))
assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32021, body.dig("error", "code"))
assert_equal({ "elicitation" => { "form" => {} } }, body.dig("error", "data", "requiredCapabilities"))
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,
))
assert_equal 200, response[0]
refute response[1].key?("mcp-session-id")
body = JSON.parse(response[2][0])
assert_equal Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS, body.dig("result", "supportedVersions")
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.
session_id = initialize_test_session
response = @transport.handle_request(create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
modern_body("ping", {}),
))
assert_equal 400, response[0]
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
end
test "sessionless POST initialize with the dual-era header stays legacy and negotiates 2026-07-28" do
# The 2026-07-28 header alone cannot route the era: a sessionless `initialize` body is
# the legacy-distinctive handshake, so a client sending both connects over the legacy lifecycle.
request = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" },
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params(protocolVersion: "2026-07-28") }.to_json,
)
response = @transport.handle_request(request)
assert_equal 200, response[0]
refute_nil response[1]["mcp-session-id"]
assert_equal "2026-07-28", JSON.parse(response[2][0]).dig("result", "protocolVersion")
end
test "session-bound POST with the dual-era header stays on the legacy path" do
session_id = initialize_test_session
response = @transport.handle_request(create_rack_request(
"POST",
"/",
{
"CONTENT_TYPE" => "application/json",
"HTTP_MCP_SESSION_ID" => session_id,
"HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28",
},
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
))
assert_equal 200, response[0]
end
test "session-bound DELETE with the dual-era header terminates the legacy session" do
session_id = initialize_test_session
response = @transport.handle_request(create_rack_request(
"DELETE",
"/",
{
"HTTP_MCP_SESSION_ID" => session_id,
"HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28",
},
))
assert_equal 200, response[0]
end
test "session-bound POST with the dual-era header carrying the modern envelope is rejected as -32600" do
# The session already negotiated the legacy lifecycle, and a connection can never change eras
# (mirroring the stdio era lock).
session_id = initialize_test_session
response = @transport.handle_request(create_rack_request(
"POST",
"/",
{
"CONTENT_TYPE" => "application/json",
"HTTP_MCP_SESSION_ID" => session_id,
"HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28",
},
modern_body("ping", {}),
))
assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32600, body.dig("error", "code"))
assert_includes body.dig("error", "message"), "legacy lifecycle"
end
test "modern POST larger than max_request_bytes returns 413" do
transport = StreamableHTTPTransport.new(@server, max_request_bytes: 64)
request = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28" },
modern_body("ping", { filler: "A" * 200 }),
)
response = transport.handle_request(request)
assert_equal(413, response[0])
ensure
transport.close
end
private private
def initialize_test_session(id: "init") def initialize_test_session(id: "init")
@ -5336,6 +5598,32 @@ module MCP
Rack::Request.new(env) Rack::Request.new(env)
end end
# Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header.
def modern_rack_request(body, version: "2026-07-28", headers: {})
create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version }.merge(headers),
body,
)
end
# Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope.
def modern_body(method, params, version: "2026-07-28", capabilities: {})
{
jsonrpc: "2.0",
method: method,
id: 1,
params: params.merge(
_meta: {
"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
"io.modelcontextprotocol/clientCapabilities": capabilities,
},
),
}.to_json
end
end end
end end
end end