Skip to content

Commit 90f91d4

Browse files
atesgoralclaude
andcommitted
Track Mcp-Session-Id and protocol version in HTTP client
Per the Streamable HTTP spec, if the server returns an Mcp-Session-Id header during `initialize`, the client MUST include it on subsequent requests. Capture the session ID and protocol version from the `initialize` response and attach them automatically on subsequent POSTs. Per the Protocol Version Header section of the spec, the client MUST also include `MCP-Protocol-Version` on all subsequent requests so the server can respond based on the negotiated protocol version. Map 404 responses to a new `SessionExpiredError` (a subclass of `RequestHandlerError` for backward compatibility) and clear local session state so callers can start a fresh session with a new `initialize` request, as required by the spec. - https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management - https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent baa093d commit 90f91d4

File tree

4 files changed

+275
-10
lines changed

4 files changed

+275
-10
lines changed

docs/building-clients.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ response = client.call_tool(
7474
)
7575
```
7676

77+
### Sessions
78+
79+
After a successful `initialize` request, the transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes the session ID on subsequent requests. Both are exposed on the transport:
80+
81+
```ruby
82+
http_transport.session_id # => "abc123..."
83+
http_transport.protocol_version # => "2025-11-25"
84+
```
85+
86+
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by sending a fresh `initialize` request.
87+
7788
### Authorization
7889

7990
Provide custom headers for authentication:

lib/mcp/client.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ def initialize(message, request, error_type: :internal_error, original_error: ni
3333
# server-returned JSON-RPC error, which is raised as `ServerError`.
3434
class ValidationError < StandardError; end
3535

36+
# Raised when the server responds 404 to a request containing a session ID,
37+
# indicating the session has expired. Inherits from `RequestHandlerError` for
38+
# backward compatibility with callers that rescue the generic error. Per spec,
39+
# clients MUST start a new session with a fresh `initialize` request in response.
40+
class SessionExpiredError < RequestHandlerError
41+
def initialize(message, request, original_error: nil)
42+
super(message, request, error_type: :not_found, original_error: original_error)
43+
end
44+
end
45+
3646
# Initializes a new MCP::Client instance.
3747
#
3848
# @param transport [Object] The transport object to use for communication with the server.

lib/mcp/client/http.rb

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,42 @@
11
# frozen_string_literal: true
22

3+
require_relative "../methods"
4+
35
module MCP
46
class Client
7+
# TODO: HTTP GET for SSE streaming is not yet implemented.
8+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
9+
# TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
10+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
511
class HTTP
612
ACCEPT_HEADER = "application/json, text/event-stream"
13+
SESSION_ID_HEADER = "Mcp-Session-Id"
14+
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
715

8-
attr_reader :url
16+
attr_reader :url, :session_id, :protocol_version
917

1018
def initialize(url:, headers: {}, &block)
1119
@url = url
1220
@headers = headers
1321
@faraday_customizer = block
22+
@session_id = nil
23+
@protocol_version = nil
1424
end
1525

26+
# Sends a JSON-RPC request and returns the parsed response body.
27+
# After a successful `initialize` handshake, the session ID and protocol
28+
# version returned by the server are captured and automatically included
29+
# on subsequent requests.
1630
def send_request(request:)
1731
method = request[:method] || request["method"]
1832
params = request[:params] || request["params"]
1933

20-
response = client.post("", request)
21-
parse_response_body(response, method, params)
34+
response = client.post("", request, session_headers)
35+
body = parse_response_body(response, method, params)
36+
37+
capture_session_info(method, response, body)
38+
39+
body
2240
rescue Faraday::BadRequestError => e
2341
raise RequestHandlerError.new(
2442
"The #{method} request is invalid",
@@ -41,12 +59,25 @@ def send_request(request:)
4159
original_error: e,
4260
)
4361
rescue Faraday::ResourceNotFound => e
44-
raise RequestHandlerError.new(
45-
"The #{method} request is not found",
46-
{ method: method, params: params },
47-
error_type: :not_found,
48-
original_error: e,
49-
)
62+
# Per spec, 404 is the session-expired signal only when the request
63+
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
64+
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
65+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
66+
if @session_id
67+
clear_session
68+
raise SessionExpiredError.new(
69+
"The #{method} request is not found",
70+
{ method: method, params: params },
71+
original_error: e,
72+
)
73+
else
74+
raise RequestHandlerError.new(
75+
"The #{method} request is not found",
76+
{ method: method, params: params },
77+
error_type: :not_found,
78+
original_error: e,
79+
)
80+
end
5081
rescue Faraday::UnprocessableEntityError => e
5182
raise RequestHandlerError.new(
5283
"The #{method} request is unprocessable",
@@ -83,6 +114,31 @@ def client
83114
end
84115
end
85116

117+
# Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
118+
# and `MCP-Protocol-Version` on all requests after `initialize`.
119+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
120+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
121+
def session_headers
122+
request_headers = {}
123+
request_headers[SESSION_ID_HEADER] = @session_id if @session_id
124+
request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
125+
request_headers
126+
end
127+
128+
def capture_session_info(method, response, body)
129+
return unless method.to_s == Methods::INITIALIZE
130+
131+
# Faraday normalizes header names to lowercase.
132+
session_id = response.headers[SESSION_ID_HEADER.downcase]
133+
@session_id ||= session_id unless session_id.nil? || session_id.empty?
134+
@protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
135+
end
136+
137+
def clear_session
138+
@session_id = nil
139+
@protocol_version = nil
140+
end
141+
86142
def require_faraday!
87143
require "faraday"
88144
rescue LoadError

test/mcp/client/http_test.rb

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def test_send_request_raises_forbidden_error
203203
assert_equal({ method: "tools/list", params: nil }, error.request)
204204
end
205205

206-
def test_send_request_raises_not_found_error
206+
def test_send_request_raises_not_found_error_on_404_without_session
207207
request = {
208208
jsonrpc: "2.0",
209209
id: "test_id",
@@ -218,11 +218,56 @@ def test_send_request_raises_not_found_error
218218
client.send_request(request: request)
219219
end
220220

221+
refute_kind_of(SessionExpiredError, error)
221222
assert_equal("The tools/list request is not found", error.message)
222223
assert_equal(:not_found, error.error_type)
223224
assert_equal({ method: "tools/list", params: nil }, error.request)
224225
end
225226

227+
def test_send_request_raises_session_expired_error_on_404_with_session
228+
stub_request(:post, url)
229+
.to_return(
230+
status: 200,
231+
headers: {
232+
"Content-Type" => "application/json",
233+
"Mcp-Session-Id" => "session-abc",
234+
},
235+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
236+
)
237+
238+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
239+
240+
stub_request(:post, url).to_return(status: 404)
241+
242+
error = assert_raises(SessionExpiredError) do
243+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
244+
end
245+
246+
assert_equal(:not_found, error.error_type)
247+
end
248+
249+
def test_session_expired_error_is_a_request_handler_error
250+
stub_request(:post, url)
251+
.to_return(
252+
status: 200,
253+
headers: {
254+
"Content-Type" => "application/json",
255+
"Mcp-Session-Id" => "session-abc",
256+
},
257+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
258+
)
259+
260+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
261+
262+
stub_request(:post, url).to_return(status: 404)
263+
264+
error = assert_raises(RequestHandlerError) do
265+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
266+
end
267+
268+
assert_kind_of(SessionExpiredError, error)
269+
end
270+
226271
def test_send_request_raises_unprocessable_entity_error
227272
request = {
228273
jsonrpc: "2.0",
@@ -413,6 +458,149 @@ def test_send_request_raises_error_for_sse_without_response
413458
assert_equal(:parse_error, error.error_type)
414459
end
415460

461+
def test_captures_session_id_and_protocol_version_on_initialize
462+
stub_request(:post, url)
463+
.to_return(
464+
status: 200,
465+
headers: {
466+
"Content-Type" => "application/json",
467+
"Mcp-Session-Id" => "session-abc",
468+
},
469+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
470+
)
471+
472+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
473+
474+
assert_equal("session-abc", client.session_id)
475+
assert_equal("2025-11-25", client.protocol_version)
476+
end
477+
478+
def test_includes_session_and_protocol_version_headers_after_initialize
479+
stub_request(:post, url)
480+
.to_return(
481+
status: 200,
482+
headers: {
483+
"Content-Type" => "application/json",
484+
"Mcp-Session-Id" => "session-abc",
485+
},
486+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
487+
)
488+
489+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
490+
491+
stub_request(:post, url)
492+
.with(
493+
headers: {
494+
"Mcp-Session-Id" => "session-abc",
495+
"MCP-Protocol-Version" => "2025-11-25",
496+
},
497+
)
498+
.to_return(
499+
status: 200,
500+
headers: { "Content-Type" => "application/json" },
501+
body: { result: { tools: [] } }.to_json,
502+
)
503+
504+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
505+
end
506+
507+
def test_does_not_send_protocol_version_header_before_initialize
508+
stub_request(:post, url)
509+
.with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") }
510+
.to_return(
511+
status: 200,
512+
headers: { "Content-Type" => "application/json" },
513+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
514+
)
515+
516+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
517+
end
518+
519+
def test_ignores_empty_session_id_header
520+
stub_request(:post, url)
521+
.to_return(
522+
status: 200,
523+
headers: {
524+
"Content-Type" => "application/json",
525+
"Mcp-Session-Id" => "",
526+
},
527+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
528+
)
529+
530+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
531+
532+
assert_nil(client.session_id)
533+
end
534+
535+
def test_session_id_not_overwritten_by_subsequent_responses
536+
stub_request(:post, url)
537+
.to_return(
538+
status: 200,
539+
headers: {
540+
"Content-Type" => "application/json",
541+
"Mcp-Session-Id" => "original-session",
542+
},
543+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
544+
)
545+
546+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
547+
548+
assert_equal("original-session", client.session_id)
549+
550+
stub_request(:post, url)
551+
.to_return(
552+
status: 200,
553+
headers: {
554+
"Content-Type" => "application/json",
555+
"Mcp-Session-Id" => "different-session",
556+
},
557+
body: { result: { tools: [] } }.to_json,
558+
)
559+
560+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
561+
562+
assert_equal("original-session", client.session_id)
563+
end
564+
565+
def test_stateless_server_without_session_id_header
566+
stub_request(:post, url)
567+
.to_return(
568+
status: 200,
569+
headers: { "Content-Type" => "application/json" },
570+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
571+
)
572+
573+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
574+
575+
assert_nil(client.session_id)
576+
assert_equal("2025-11-25", client.protocol_version)
577+
end
578+
579+
def test_clears_session_state_on_404
580+
stub_request(:post, url)
581+
.to_return(
582+
status: 200,
583+
headers: {
584+
"Content-Type" => "application/json",
585+
"Mcp-Session-Id" => "session-abc",
586+
},
587+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
588+
)
589+
590+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
591+
592+
assert_equal("session-abc", client.session_id)
593+
594+
stub_request(:post, url).to_return(status: 404)
595+
596+
assert_raises(SessionExpiredError) do
597+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
598+
end
599+
600+
assert_nil(client.session_id)
601+
assert_nil(client.protocol_version)
602+
end
603+
416604
private
417605

418606
def stub_request(method, url)

0 commit comments

Comments
 (0)