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:`.
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.
def call(env)
handle_request(Rack::Request.new(env))
@ -158,6 +173,41 @@ module MCP
rebinding_error = validate_dns_rebinding(request)
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"]
when "POST"
handle_post(request)
@ -457,7 +507,158 @@ module MCP
stream.flush
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
accept_error = validate_accept_header(request, required_types)
return accept_error if accept_error
@ -465,8 +666,10 @@ module MCP
content_type_error = validate_content_type(request)
return content_type_error if content_type_error
body_string = read_bounded_body(request)
return payload_too_large_response if body_string.nil?
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)
@ -483,6 +686,28 @@ module MCP
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
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 JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
# This matches the TypeScript and Python SDKs.
@ -748,6 +973,21 @@ module MCP
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
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)
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)
@ -760,8 +1000,10 @@ module MCP
)
end
def json_rpc_error_response(status:, code:, message:)
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
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]]
end

View file

@ -369,7 +369,7 @@ module MCP
response = @transport.handle_request(request)
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({}, @transport.instance_variable_get(:@sessions))
end
@ -661,7 +661,7 @@ module MCP
assert_equal 200, response[0]
body = JSON.parse(response[2][0])
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
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"]
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(
"POST",
"/",
@ -1852,7 +1871,10 @@ module MCP
)
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
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({ "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])
assert_equal "2.0", body["jsonrpc"]
assert_nil body["id"]
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
assert_includes body["error"]["message"], "1999-01-01"
assert_includes body["error"]["message"], Configuration::LATEST_STABLE_PROTOCOL_VERSION
assert_equal(-32022, body["error"]["code"])
assert_equal "1999-01-01", body.dig("error", "data", "requested")
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, body.dig("error", "data", "supported")
end
test "POST request with malformed MCP-Protocol-Version returns 400" do
@ -1965,7 +1988,8 @@ module MCP
assert_equal 400, response[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
test "POST request with supported MCP-Protocol-Version succeeds" do
@ -2056,10 +2080,10 @@ module MCP
)
response = @transport.send(:validate_protocol_version_header, request)
assert_equal 400, response[0]
assert_equal(400, response[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
MCP::Configuration.send(:remove_const, :SUPPORTED_STABLE_PROTOCOL_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"]
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(
"POST",
"/",
@ -2141,10 +2167,7 @@ module MCP
)
response = @transport.handle_request(request)
assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
assert_equal 405, response[0]
end
test "GET request without MCP-Protocol-Version header succeeds" do
@ -2167,7 +2190,9 @@ module MCP
assert_equal 200, response[0]
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(
"POST",
"/",
@ -2187,13 +2212,10 @@ module MCP
)
response = @transport.handle_request(request)
assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
assert_equal 405, response[0]
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)
request = create_rack_request(
@ -2203,10 +2225,11 @@ module MCP
)
response = stateless_transport.handle_request(request)
assert_equal 400, response[0]
assert_equal 405, response[0]
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(
"DELETE",
"/",
@ -2217,7 +2240,7 @@ module MCP
)
response = @transport.handle_request(request)
assert_equal 404, response[0]
assert_equal 405, response[0]
end
test "stateless mode allows requests without session IDs, responding with no session ID" do
@ -5268,6 +5291,245 @@ module MCP
transport.close
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
def initialize_test_session(id: "init")
@ -5336,6 +5598,32 @@ module MCP
Rack::Request.new(env)
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