Serve the Sessionless Modern Path over Streamable HTTP per SEP-2575

## Motivation and Context

Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first,
like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside
every supported list enters a new sessionless modern path, while requests without the header (or with
a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path
means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }`
instead of a generic invalid-request error.

Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model,
and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates
by request shape: 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 sessionless POST
whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over
the classic handshake connects unchanged. 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).
The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request,
since Rack 3 inputs need not be rewindable.

The modern path (`handle_modern`) is a single POST/JSON exchange:

- GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405.
- It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`,
  and rejects requests carrying one with HTTP 400.
- Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path.
- Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against
  the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent.
  `Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII.
- HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400,
  `-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else
  including internal errors stays 200.
- Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`,
  whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of
  broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`.

The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple,
which would previously fall through the legacy path via the header default. Session-bound under
a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because
the session already negotiated the legacy lifecycle (mirroring the stdio era lock).
With the header missing or naming a stable-only version, it violates the header/body match
requirement and is rejected as a header mismatch (`-32020`).

Intentional behavior changes for previously-erroneous requests, all following from header-primary routing:
an unknown header version now yields `-32022` with data instead of the legacy `-32600` message
(`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version,
or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed
on the legacy path. Existing tests were updated to codify these.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list
for unknown header versions, `-32020` for header/body version, `Mcp-Method`,
and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection,
405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`),
`-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without
an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header.

Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy
and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path,
a session-bound POST carrying the modern envelope is rejected as `-32600`,
and an oversized modern POST returns 413.

## Breaking Changes

None for conforming clients: requests without the header or with a stable-only version
are byte-identical to before, and legacy clients negotiating 2026-07-28 over
the classic handshake connect unchanged. Requests that were already rejected change error shape
(see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
This commit is contained in:
Koichi ITO 2026-07-02 12:24:33 +09:00
commit e591cbb804
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