Skip to content

Commit e591cbb

Browse files
committed
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.
1 parent f4d939d commit e591cbb

2 files changed

Lines changed: 560 additions & 30 deletions

File tree

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 247 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ def initialize(
149149
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
150150
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
151151

152+
# JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575).
153+
NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze
154+
155+
# JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404
156+
# (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors,
157+
# stays 200, matching the Python SDK's status ladder.
158+
MODERN_BAD_REQUEST_CODES = [
159+
ErrorCodes::HEADER_MISMATCH,
160+
ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
161+
ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
162+
JsonRpcHandler::ErrorCode::PARSE_ERROR,
163+
JsonRpcHandler::ErrorCode::INVALID_REQUEST,
164+
JsonRpcHandler::ErrorCode::INVALID_PARAMS,
165+
].freeze
166+
152167
# Rack app interface. This transport can be mounted as a Rack app.
153168
def call(env)
154169
handle_request(Rack::Request.new(env))
@@ -158,6 +173,41 @@ def handle_request(request)
158173
rebinding_error = validate_dns_rebinding(request)
159174
return rebinding_error if rebinding_error
160175

176+
# Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
177+
# every supported list routes to the sessionless modern path, so an unknown future version receives
178+
# the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
179+
# Requests without the header, or with a stable-only version, take the existing paths untouched.
180+
# An empty header value is malformed rather than a version claim, so it stays on the legacy path
181+
# and fails legacy header validation as before.
182+
#
183+
# 2026-07-28 serves both lifecycles of the dual-era model, so for that header value the version
184+
# alone cannot decide the era: an `Mcp-Session-Id` binds the request to an established legacy session
185+
# (POST requests, the GET SSE stream, and DELETE termination keep working), and a session-less POST whose
186+
# body is `initialize` is the legacy-distinctive handshake. Everything else under a dual-era header is
187+
# sessionless modern traffic (`server/discover`, envelope-carrying requests, and envelope-missing requests
188+
# that get the modern path's error shape).
189+
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
190+
if header_version && !header_version.empty? && !stable_only_version?(header_version)
191+
unless MCP::Configuration.modern_protocol_version?(header_version)
192+
return handle_modern(request, header_version)
193+
end
194+
195+
if extract_session_id(request).nil?
196+
return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST"
197+
198+
# The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here,
199+
# bounded, and hands the string to whichever path serves the request.
200+
body_string = read_bounded_body(request)
201+
return payload_too_large_response if body_string.nil?
202+
203+
unless legacy_handshake_body?(body_string)
204+
return handle_modern(request, header_version, body_string: body_string)
205+
end
206+
207+
return handle_post(request, body_string: body_string)
208+
end
209+
end
210+
161211
case request.env["REQUEST_METHOD"]
162212
when "POST"
163213
handle_post(request)
@@ -457,16 +507,169 @@ def send_ping_to_stream(stream)
457507
stream.flush
458508
end
459509

460-
def handle_post(request)
510+
# Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
511+
# a single POST/JSON exchange with no session. The modern path never consults
512+
# `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
513+
# an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`)
514+
# and DELETE (session termination) have no modern meaning.
515+
def handle_modern(request, header_version, body_string: nil)
516+
return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST"
517+
518+
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE)
519+
return accept_error if accept_error
520+
521+
content_type_error = validate_content_type(request)
522+
return content_type_error if content_type_error
523+
524+
if body_string.nil?
525+
body_string = read_bounded_body(request)
526+
return payload_too_large_response if body_string.nil?
527+
end
528+
529+
begin
530+
body = parse_request_body(body_string)
531+
rescue InvalidJsonError
532+
return invalid_json_response
533+
end
534+
535+
unless body.is_a?(Hash)
536+
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
537+
end
538+
539+
# The version check precedes everything else that depends on request content,
540+
# so a client probing with an unknown future version always receives the `-32022` signal
541+
# (with the supported list to select from) rather than an incidental error.
542+
unless MCP::Configuration.modern_protocol_version?(header_version)
543+
return json_rpc_error_response(
544+
status: 400,
545+
code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
546+
message: "Unsupported protocol version",
547+
data: {
548+
supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
549+
requested: header_version,
550+
},
551+
id: body[:id],
552+
)
553+
end
554+
555+
if extract_session_id(request)
556+
return json_rpc_error_response(
557+
status: 400,
558+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
559+
message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
560+
id: body[:id],
561+
)
562+
end
563+
564+
mismatch_error = validate_modern_headers(request, body, header_version)
565+
return mismatch_error if mismatch_error
566+
567+
response = @server.handle(body, session: modern_session)
568+
569+
# `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
570+
return handle_accepted if response.nil?
571+
572+
[modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
573+
rescue StandardError => e
574+
MCP.configuration.exception_reporter.call(e, { request: body_string })
575+
json_rpc_error_response(
576+
status: 500,
577+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
578+
message: "Internal server error",
579+
)
580+
end
581+
582+
# Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header
583+
# MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match
584+
# the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks
585+
# converge on enforcement.
586+
def validate_modern_headers(request, body, header_version)
587+
params = body[:params]
588+
meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
589+
if meta_version && meta_version != header_version
590+
return header_mismatch_response(
591+
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
592+
body[:id],
593+
)
594+
end
595+
596+
method_header = request.env["HTTP_MCP_METHOD"]
597+
if method_header && method_header != body[:method]
598+
return header_mismatch_response(
599+
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
600+
body[:id],
601+
)
602+
end
603+
604+
name_header = request.env["HTTP_MCP_NAME"]
605+
if name_header && NAME_BEARING_METHODS.include?(body[:method])
606+
body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
607+
decoded_name = decode_header_value(name_header)
608+
if body_name && decoded_name != body_name
609+
return header_mismatch_response(
610+
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
611+
body[:id],
612+
)
613+
end
614+
end
615+
616+
nil
617+
end
618+
619+
def header_mismatch_response(message, id)
620+
json_rpc_error_response(
621+
status: 400,
622+
code: ErrorCodes::HEADER_MISMATCH,
623+
message: "Header mismatch: #{message}",
624+
id: id,
625+
)
626+
end
627+
628+
# Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64?<base64>?=` decodes to
629+
# its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport
630+
# requires faraday, which servers do not depend on.
631+
def decode_header_value(value)
632+
match = value.match(/\A=\?base64\?(.*)\?=\z/m)
633+
return value unless match
634+
635+
match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
636+
rescue ArgumentError
637+
value
638+
end
639+
640+
# Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to
641+
# the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing
642+
# keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions
643+
# via the `session_id.nil?` branch.
644+
def modern_session
645+
ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern)
646+
end
647+
648+
def modern_http_status(response)
649+
error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil
650+
if error_code.nil?
651+
200
652+
elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND
653+
404
654+
elsif MODERN_BAD_REQUEST_CODES.include?(error_code)
655+
400
656+
else
657+
200
658+
end
659+
end
660+
661+
def handle_post(request, body_string: nil)
461662
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
462663
accept_error = validate_accept_header(request, required_types)
463664
return accept_error if accept_error
464665

465666
content_type_error = validate_content_type(request)
466667
return content_type_error if content_type_error
467668

468-
body_string = read_bounded_body(request)
469-
return payload_too_large_response if body_string.nil?
669+
if body_string.nil?
670+
body_string = read_bounded_body(request)
671+
return payload_too_large_response if body_string.nil?
672+
end
470673

471674
session_id = extract_session_id(request)
472675

@@ -483,6 +686,28 @@ def handle_post(request)
483686
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
484687
end
485688

689+
# Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs,
690+
# so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only.
691+
# Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session
692+
# already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras
693+
# (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version,
694+
# which violates the header/body match requirement and would fall through the legacy path via
695+
# the header default; reject that as a header mismatch.
696+
if RequestEnvelope.modern?(body[:params])
697+
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
698+
if header_version && MCP::Configuration.modern_protocol_version?(header_version)
699+
return invalid_request_response(
700+
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
701+
request_id: body[:id],
702+
)
703+
end
704+
705+
return header_mismatch_response(
706+
"MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope",
707+
body[:id],
708+
)
709+
end
710+
486711
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
487712
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
488713
# This matches the TypeScript and Python SDKs.
@@ -748,6 +973,21 @@ def discover_request?(body)
748973
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
749974
end
750975

976+
# A version negotiable only through the legacy handshake, with no modern meaning.
977+
# Dual-era versions (2026-07-28) appear in both lists and need further disambiguation.
978+
def stable_only_version?(version)
979+
MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
980+
!MCP::Configuration.modern_protocol_version?(version)
981+
end
982+
983+
# Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body is legacy-distinctive.
984+
# Unparsable or non-object bodies go to the modern path, whose error responses cover them.
985+
def legacy_handshake_body?(body_string)
986+
initialize_request?(parse_request_body(body_string))
987+
rescue InvalidJsonError
988+
false
989+
end
990+
751991
def validate_protocol_version_header(request)
752992
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
753993
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@@ -760,8 +1000,10 @@ def validate_protocol_version_header(request)
7601000
)
7611001
end
7621002

763-
def json_rpc_error_response(status:, code:, message:)
764-
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
1003+
def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
1004+
error = { code: code, message: message }
1005+
error[:data] = data if data
1006+
body = { jsonrpc: "2.0", id: id, error: error }
7651007
[status, { "content-type" => "application/json" }, [body.to_json]]
7661008
end
7671009

0 commit comments

Comments
 (0)