From 07226b7cf16e682bf5a1aa3dcf0a4c04d006f9e3 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Sun, 19 Apr 2026 23:29:00 -0400 Subject: [PATCH] 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 --- docs/building-clients.md | 11 ++ lib/mcp/client.rb | 10 ++ lib/mcp/client/http.rb | 74 ++++++++++++-- test/mcp/client/http_test.rb | 190 ++++++++++++++++++++++++++++++++++- 4 files changed, 275 insertions(+), 10 deletions(-) diff --git a/docs/building-clients.md b/docs/building-clients.md index 48352eed..272ec32e 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -74,6 +74,17 @@ response = client.call_tool( ) ``` +### Sessions + +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: + +```ruby +http_transport.session_id # => "abc123..." +http_transport.protocol_version # => "2025-11-25" +``` + +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. + ### Authorization Provide custom headers for authentication: diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index e7c2b8cd..31057ed9 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -33,6 +33,16 @@ def initialize(message, request, error_type: :internal_error, original_error: ni # server-returned JSON-RPC error, which is raised as `ServerError`. class ValidationError < StandardError; end + # Raised when the server responds 404 to a request containing a session ID, + # indicating the session has expired. Inherits from `RequestHandlerError` for + # backward compatibility with callers that rescue the generic error. Per spec, + # clients MUST start a new session with a fresh `initialize` request in response. + class SessionExpiredError < RequestHandlerError + def initialize(message, request, original_error: nil) + super(message, request, error_type: :not_found, original_error: original_error) + end + end + # Initializes a new MCP::Client instance. # # @param transport [Object] The transport object to use for communication with the server. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index d0350af1..9b85d7dc 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1,24 +1,42 @@ # frozen_string_literal: true +require_relative "../methods" + module MCP class Client + # TODO: HTTP GET for SSE streaming is not yet implemented. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server + # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery class HTTP ACCEPT_HEADER = "application/json, text/event-stream" + SESSION_ID_HEADER = "Mcp-Session-Id" + PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" - attr_reader :url + attr_reader :url, :session_id, :protocol_version def initialize(url:, headers: {}, &block) @url = url @headers = headers @faraday_customizer = block + @session_id = nil + @protocol_version = nil end + # Sends a JSON-RPC request and returns the parsed response body. + # After a successful `initialize` handshake, the session ID and protocol + # version returned by the server are captured and automatically included + # on subsequent requests. def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] - response = client.post("", request) - parse_response_body(response, method, params) + response = client.post("", request, session_headers) + body = parse_response_body(response, method, params) + + capture_session_info(method, response, body) + + body rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", @@ -41,12 +59,25 @@ def send_request(request:) original_error: e, ) rescue Faraday::ResourceNotFound => e - raise RequestHandlerError.new( - "The #{method} request is not found", - { method: method, params: params }, - error_type: :not_found, - original_error: e, - ) + # Per spec, 404 is the session-expired signal only when the request + # actually carried an `Mcp-Session-Id`. A 404 without a session attached + # (e.g. wrong URL or a stateless server) surfaces as a generic not-found. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + if @session_id + clear_session + raise SessionExpiredError.new( + "The #{method} request is not found", + { method: method, params: params }, + original_error: e, + ) + else + raise RequestHandlerError.new( + "The #{method} request is not found", + { method: method, params: params }, + error_type: :not_found, + original_error: e, + ) + end rescue Faraday::UnprocessableEntityError => e raise RequestHandlerError.new( "The #{method} request is unprocessable", @@ -83,6 +114,31 @@ def client end end + # Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one) + # and `MCP-Protocol-Version` on all requests after `initialize`. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header + def session_headers + request_headers = {} + request_headers[SESSION_ID_HEADER] = @session_id if @session_id + request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version + request_headers + end + + def capture_session_info(method, response, body) + return unless method.to_s == Methods::INITIALIZE + + # Faraday normalizes header names to lowercase. + session_id = response.headers[SESSION_ID_HEADER.downcase] + @session_id ||= session_id unless session_id.to_s.empty? + @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil + end + + def clear_session + @session_id = nil + @protocol_version = nil + end + def require_faraday! require "faraday" rescue LoadError diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 8ec04350..a4dcbc8f 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -203,7 +203,7 @@ def test_send_request_raises_forbidden_error assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_send_request_raises_not_found_error + def test_send_request_raises_not_found_error_on_404_without_session request = { jsonrpc: "2.0", id: "test_id", @@ -218,11 +218,56 @@ def test_send_request_raises_not_found_error client.send_request(request: request) end + refute_kind_of(SessionExpiredError, error) assert_equal("The tools/list request is not found", error.message) assert_equal(:not_found, error.error_type) assert_equal({ method: "tools/list", params: nil }, error.request) end + def test_send_request_raises_session_expired_error_on_404_with_session + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url).to_return(status: 404) + + error = assert_raises(SessionExpiredError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_equal(:not_found, error.error_type) + end + + def test_session_expired_error_is_a_request_handler_error + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url).to_return(status: 404) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_kind_of(SessionExpiredError, error) + end + def test_send_request_raises_unprocessable_entity_error request = { jsonrpc: "2.0", @@ -413,6 +458,149 @@ def test_send_request_raises_error_for_sse_without_response assert_equal(:parse_error, error.error_type) end + def test_captures_session_id_and_protocol_version_on_initialize + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("session-abc", client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + + def test_includes_session_and_protocol_version_headers_after_initialize + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:post, url) + .with( + headers: { + "Mcp-Session-Id" => "session-abc", + "MCP-Protocol-Version" => "2025-11-25", + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + def test_does_not_send_protocol_version_header_before_initialize + stub_request(:post, url) + .with { |req| !req.headers.keys.map(&:downcase).include?("mcp-protocol-version") } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + end + + def test_ignores_empty_session_id_header + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_nil(client.session_id) + end + + def test_session_id_not_overwritten_by_subsequent_responses + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "original-session", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("original-session", client.session_id) + + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "different-session", + }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + + assert_equal("original-session", client.session_id) + end + + def test_stateless_server_without_session_id_header + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_nil(client.session_id) + assert_equal("2025-11-25", client.protocol_version) + end + + def test_clears_session_state_on_404 + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2025-11-25" } }.to_json, + ) + + client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("session-abc", client.session_id) + + stub_request(:post, url).to_return(status: 404) + + assert_raises(SessionExpiredError) do + client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + private def stub_request(method, url)