Skip to content

Commit 7cb64a4

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. Expose both via `session_id` and `protocol_version` readers on the transport. 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. https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent baa093d commit 7cb64a4

4 files changed

Lines changed: 186 additions & 7 deletions

File tree

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: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,38 @@
22

33
module MCP
44
class Client
5+
# TODO: HTTP GET for SSE streaming is not yet implemented.
6+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
7+
# TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
8+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
59
class HTTP
610
ACCEPT_HEADER = "application/json, text/event-stream"
11+
SESSION_ID_HEADER = "Mcp-Session-Id"
712

8-
attr_reader :url
13+
attr_reader :url, :session_id, :protocol_version
914

1015
def initialize(url:, headers: {}, &block)
1116
@url = url
1217
@headers = headers
1318
@faraday_customizer = block
19+
@session_id = nil
20+
@protocol_version = nil
1421
end
1522

23+
# Sends a JSON-RPC request and returns the parsed response body.
24+
# After a successful `initialize` handshake, the session ID and protocol
25+
# version returned by the server are captured and automatically included
26+
# on subsequent requests.
1627
def send_request(request:)
1728
method = request[:method] || request["method"]
1829
params = request[:params] || request["params"]
1930

20-
response = client.post("", request)
21-
parse_response_body(response, method, params)
31+
response = client.post("", request, session_headers)
32+
body = parse_response_body(response, method, params)
33+
34+
capture_session_info(method, response, body)
35+
36+
body
2237
rescue Faraday::BadRequestError => e
2338
raise RequestHandlerError.new(
2439
"The #{method} request is invalid",
@@ -41,10 +56,13 @@ def send_request(request:)
4156
original_error: e,
4257
)
4358
rescue Faraday::ResourceNotFound => e
44-
raise RequestHandlerError.new(
59+
# Per spec, the server MAY terminate the session at any time and MUST
60+
# respond with 404 to requests containing an expired session ID.
61+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
62+
clear_session
63+
raise SessionExpiredError.new(
4564
"The #{method} request is not found",
4665
{ method: method, params: params },
47-
error_type: :not_found,
4866
original_error: e,
4967
)
5068
rescue Faraday::UnprocessableEntityError => e
@@ -83,6 +101,25 @@ def client
83101
end
84102
end
85103

104+
def session_headers
105+
headers = {}
106+
headers[SESSION_ID_HEADER] = @session_id if @session_id
107+
headers
108+
end
109+
110+
def capture_session_info(method, response, body)
111+
return unless method.to_s == "initialize"
112+
113+
# Faraday normalizes header names to lowercase.
114+
@session_id ||= response.headers[SESSION_ID_HEADER.downcase]
115+
@protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
116+
end
117+
118+
def clear_session
119+
@session_id = nil
120+
@protocol_version = nil
121+
end
122+
86123
def require_faraday!
87124
require "faraday"
88125
rescue LoadError

test/mcp/client/http_test.rb

Lines changed: 123 additions & 2 deletions
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_session_expired_error_on_404
207207
request = {
208208
jsonrpc: "2.0",
209209
id: "test_id",
@@ -214,7 +214,7 @@ def test_send_request_raises_not_found_error
214214
.with(body: request.to_json)
215215
.to_return(status: 404)
216216

217-
error = assert_raises(RequestHandlerError) do
217+
error = assert_raises(SessionExpiredError) do
218218
client.send_request(request: request)
219219
end
220220

@@ -223,6 +223,17 @@ def test_send_request_raises_not_found_error
223223
assert_equal({ method: "tools/list", params: nil }, error.request)
224224
end
225225

226+
def test_session_expired_error_is_a_request_handler_error
227+
stub_request(:post, url).to_return(status: 404)
228+
229+
error = assert_raises(RequestHandlerError) do
230+
client.send_request(request: { method: "tools/list" })
231+
end
232+
233+
assert_kind_of(SessionExpiredError, error)
234+
assert_equal(:not_found, error.error_type)
235+
end
236+
226237
def test_send_request_raises_unprocessable_entity_error
227238
request = {
228239
jsonrpc: "2.0",
@@ -413,6 +424,116 @@ def test_send_request_raises_error_for_sse_without_response
413424
assert_equal(:parse_error, error.error_type)
414425
end
415426

427+
def test_captures_session_id_and_protocol_version_on_initialize
428+
stub_request(:post, url)
429+
.to_return(
430+
status: 200,
431+
headers: {
432+
"Content-Type" => "application/json",
433+
"Mcp-Session-Id" => "session-abc",
434+
},
435+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
436+
)
437+
438+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
439+
440+
assert_equal("session-abc", client.session_id)
441+
assert_equal("2025-11-25", client.protocol_version)
442+
end
443+
444+
def test_includes_session_header_after_initialize
445+
stub_request(:post, url)
446+
.to_return(
447+
status: 200,
448+
headers: {
449+
"Content-Type" => "application/json",
450+
"Mcp-Session-Id" => "session-abc",
451+
},
452+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
453+
)
454+
455+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
456+
457+
stub_request(:post, url)
458+
.with(headers: { "Mcp-Session-Id" => "session-abc" })
459+
.to_return(
460+
status: 200,
461+
headers: { "Content-Type" => "application/json" },
462+
body: { result: { tools: [] } }.to_json,
463+
)
464+
465+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
466+
end
467+
468+
def test_session_id_not_overwritten_by_subsequent_responses
469+
stub_request(:post, url)
470+
.to_return(
471+
status: 200,
472+
headers: {
473+
"Content-Type" => "application/json",
474+
"Mcp-Session-Id" => "original-session",
475+
},
476+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
477+
)
478+
479+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
480+
481+
assert_equal("original-session", client.session_id)
482+
483+
stub_request(:post, url)
484+
.to_return(
485+
status: 200,
486+
headers: {
487+
"Content-Type" => "application/json",
488+
"Mcp-Session-Id" => "different-session",
489+
},
490+
body: { result: { tools: [] } }.to_json,
491+
)
492+
493+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
494+
495+
assert_equal("original-session", client.session_id)
496+
end
497+
498+
def test_stateless_server_without_session_id_header
499+
stub_request(:post, url)
500+
.to_return(
501+
status: 200,
502+
headers: { "Content-Type" => "application/json" },
503+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
504+
)
505+
506+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
507+
508+
assert_nil(client.session_id)
509+
assert_equal("2025-11-25", client.protocol_version)
510+
end
511+
512+
def test_clears_session_state_on_404
513+
stub_request(:post, url)
514+
.to_return(
515+
status: 200,
516+
headers: {
517+
"Content-Type" => "application/json",
518+
"Mcp-Session-Id" => "session-abc",
519+
},
520+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
521+
)
522+
523+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
524+
525+
assert_equal("session-abc", client.session_id)
526+
527+
stub_request(:post, url).to_return(status: 404)
528+
529+
assert_raises(SessionExpiredError) do
530+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" })
531+
end
532+
533+
assert_nil(client.session_id)
534+
assert_nil(client.protocol_version)
535+
end
536+
416537
private
417538

418539
def stub_request(method, url)

0 commit comments

Comments
 (0)