Skip to content

Commit 991926a

Browse files
committed
Lowercase response header names in StreamableHTTPTransport
## Motivation and Context The Rack 3 SPEC requires response header names to be lowercase. https://github.com/rack/rack/blob/main/SPEC.rdoc says in its "The Headers" section: > Header keys must not contain uppercase ASCII characters (A-Z). https://github.com/rack/rack/blob/main/SPEC.rdoc#the-headers However, `StreamableHTTPTransport` emitted capitalized names such as `Content-Type` and `Mcp-Session-Id`. Any `Rack::Lint`-wrapped stack, including the default development mode of rackup, rejects every response from the transport with a `LintError` 500. Lowercasing the names restores Rack 3 compliance. HTTP header names are case-insensitive on the wire, so HTTP clients are unaffected; only in-process consumers that read the raw Rack headers hash see the new keys. Lowercase is also the only representation valid across HTTP versions: RFC 9113 makes uppercase field names a protocol error on HTTP/2, so proxies and h2-capable servers already require it. The TypeScript SDK sets `headers['mcp-session-id']` and the Python SDK defines `MCP_SESSION_ID_HEADER = "mcp-session-id"`, so lowercase also matches what the reference SDKs emit; the MCP spec's `Mcp-Session-Id` casing is prose convention, not a wire requirement. Examples and documentation deliberately keep the spec's `Mcp-Session-Id` casing where it does not name a Rack response hash key: curl invocations and `examples/streamable_http_client.rb` set request headers, which the Rack response rule does not govern; the rack-cors `expose:` entry carries a header name as an `Access-Control-Expose-Headers` value, which browsers match case-insensitively; and README/docs prose follows the MCP specification's documentation convention. Only code reading the raw Rack response headers hash, a case-sensitive Hash lookup, had to switch to lowercase keys. AGENTS.md now records the lowercase rule under the code style guidelines, since the MCP spec's `Mcp-Session-Id` prose casing invites reintroducing capitalized names in future transport work. ## How Has This Been Tested? Added a regression test asserting that header names are lowercase in initialize, GET SSE, DELETE, and parse error responses. Existing tests that read the raw headers hash were updated to the new keys. Verified with rake test, rake rubocop, and rake conformance. ## Breaking Changes Code that reads the raw Rack response headers hash returned by `handle_request`, or by middleware around the mounted transport, with exact-case keys such as `"Mcp-Session-Id"` must switch to the lowercase keys. Clients speaking HTTP are unaffected because header names are case-insensitive on the wire.
1 parent cc219a2 commit 991926a

5 files changed

Lines changed: 196 additions & 154 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ This is the official Ruby SDK for the Model Context Protocol (MCP), implementing
3232
- Use frozen string literals
3333
- Follow Ruby community conventions
3434
- Keep dependencies minimal
35+
- Use lowercase HTTP response header names (e.g. `mcp-session-id`); the Rack 3 SPEC requires this, and the MCP spec's `Mcp-Session-Id` casing is prose convention only
3536

3637
## Commit message conventions
3738

‎examples/streamable_http_server.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def call(env)
105105
@mcp_logger.error("Response error: #{parsed["error"]["message"]}")
106106
else
107107
@mcp_logger.info("Response: success (id: #{parsed["id"]})")
108-
@sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}") if headers["Mcp-Session-Id"]
108+
@sse_logger.info("Session created: #{headers["mcp-session-id"]}") if headers["mcp-session-id"]
109109
end
110110
rescue JSON::ParserError
111111
@mcp_logger.warn("Invalid JSON in response")

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ class StreamableHTTPTransport < Transport
1919
class InvalidJsonError < StandardError; end
2020

2121
SSE_HEADERS = {
22-
"Content-Type" => "text/event-stream",
23-
"Cache-Control" => "no-cache",
24-
"Connection" => "keep-alive",
22+
"content-type" => "text/event-stream",
23+
"cache-control" => "no-cache",
24+
"connection" => "keep-alive",
2525
}.freeze
2626

2727
# Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
@@ -566,7 +566,7 @@ def handle_get(request)
566566
end
567567

568568
def handle_delete(request)
569-
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
569+
success_response = [200, { "content-type" => "application/json" }, [{ success: true }.to_json]]
570570

571571
if @stateless
572572
protocol_version_error = validate_protocol_version_header(request)
@@ -762,7 +762,7 @@ def validate_protocol_version_header(request)
762762

763763
def json_rpc_error_response(status:, code:, message:)
764764
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
765-
[status, { "Content-Type" => "application/json" }, [body.to_json]]
765+
[status, { "content-type" => "application/json" }, [body.to_json]]
766766
end
767767

768768
def notification?(body)
@@ -870,10 +870,10 @@ def handle_initialization(request, body_string, body)
870870
end
871871

872872
headers = {
873-
"Content-Type" => "application/json",
873+
"content-type" => "application/json",
874874
}
875875

876-
headers["Mcp-Session-Id"] = session_id if session_id
876+
headers["mcp-session-id"] = session_id if session_id
877877

878878
[200, headers, [response]]
879879
end
@@ -915,7 +915,7 @@ def handle_regular_request(body_string, session_id, related_request_id: nil)
915915
# which would produce an empty body the client cannot parse as JSON.
916916
return handle_accepted if response.nil?
917917

918-
[200, { "Content-Type" => "application/json" }, [response]]
918+
[200, { "content-type" => "application/json" }, [response]]
919919
end
920920
end
921921

@@ -1148,7 +1148,7 @@ def invalid_request_response(message, request_id: nil)
11481148
message: message,
11491149
},
11501150
}
1151-
[400, { "Content-Type" => "application/json" }, [body.to_json]]
1151+
[400, { "content-type" => "application/json" }, [body.to_json]]
11521152
end
11531153

11541154
def session_already_connected_response

‎test/mcp/server/transports/streamable_http_notification_integration_test.rb‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
2828
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
2929
)
3030
init_response = @transport.handle_request(init_request)
31-
session_id = init_response[1]["Mcp-Session-Id"]
31+
session_id = init_response[1]["mcp-session-id"]
3232

3333
# Connect with SSE
3434
io = StringIO.new
@@ -77,7 +77,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
7777
{ jsonrpc: "2.0", method: "initialize", id: "123", params: initialize_params }.to_json,
7878
)
7979
init_response1 = @transport.handle_request(init_request1)
80-
session_id1 = init_response1[1]["Mcp-Session-Id"]
80+
session_id1 = init_response1[1]["mcp-session-id"]
8181

8282
init_request2 = create_rack_request(
8383
"POST",
@@ -86,7 +86,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
8686
{ jsonrpc: "2.0", method: "initialize", id: "456", params: initialize_params }.to_json,
8787
)
8888
init_response2 = @transport.handle_request(init_request2)
89-
session_id2 = init_response2[1]["Mcp-Session-Id"]
89+
session_id2 = init_response2[1]["mcp-session-id"]
9090

9191
# Connect both sessions with SSE
9292
io1 = StringIO.new
@@ -132,7 +132,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
132132
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
133133
)
134134
init_response = @transport.handle_request(init_request)
135-
session_id = init_response[1]["Mcp-Session-Id"]
135+
session_id = init_response[1]["mcp-session-id"]
136136

137137
# Connect with SSE
138138
io = StringIO.new
@@ -168,7 +168,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
168168
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
169169
)
170170
init_response = @transport.handle_request(init_request)
171-
session_id = init_response[1]["Mcp-Session-Id"]
171+
session_id = init_response[1]["mcp-session-id"]
172172

173173
# Connect with SSE
174174
io = StringIO.new
@@ -212,7 +212,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
212212
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
213213
)
214214
init_response = @transport.handle_request(init_request)
215-
session_id = init_response[1]["Mcp-Session-Id"]
215+
session_id = init_response[1]["mcp-session-id"]
216216

217217
# Connect with SSE
218218
io = StringIO.new

0 commit comments

Comments
 (0)