From e591cbb804a1bdb88d0eefcb74940557a038d3e7 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 12:24:33 +0900 Subject: [PATCH] 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. --- .../transports/streamable_http_transport.rb | 252 ++++++++++++- .../streamable_http_transport_test.rb | 338 ++++++++++++++++-- 2 files changed, 560 insertions(+), 30 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 3c87dc9f..4c19eac8 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -149,6 +149,21 @@ def initialize( # 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 @@ def handle_request(request) 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 @@ def send_ping_to_stream(stream) 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??=` 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 @@ def handle_post(request) 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 @@ def handle_post(request) 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 @@ def discover_request?(body) 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 @@ def validate_protocol_version_header(request) ) 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 diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 4912cebf..b7ba3297 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -369,7 +369,7 @@ def string 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 @@ def string 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 @@ def string 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 @@ def string ) 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 @@ def string 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 @@ def string 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 @@ def string ) 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 @@ def string 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 @@ def string ) 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 @@ def string 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 @@ def string ) 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 @@ def string ) 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 @@ def string ) 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 @@ def string 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 @@ def create_rack_request_without_accept(method, path, headers, body = nil) 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