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:
commit
d51d4a0f91
2 changed files with 560 additions and 30 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue