Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/building-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 65 additions & 9 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec has this as "MCP-Session-Id". Will standardize casing for existing server code + this new client code in a separate PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, it looks like this was updated in the spec at some point.
modelcontextprotocol/modelcontextprotocol#1325

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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
190 changes: 189 additions & 1 deletion test/mcp/client/http_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down