Merge pull request #325 from atesgoral/ag/client-session-tracking
Track Mcp-Session-Id and protocol version in HTTP client
This commit is contained in:
commit
0770e27c67
4 changed files with 275 additions and 10 deletions
|
|
@ -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
|
### Authorization
|
||||||
|
|
||||||
Provide custom headers for authentication:
|
Provide custom headers for authentication:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,16 @@ module MCP
|
||||||
# server-returned JSON-RPC error, which is raised as `ServerError`.
|
# server-returned JSON-RPC error, which is raised as `ServerError`.
|
||||||
class ValidationError < StandardError; end
|
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.
|
# Initializes a new MCP::Client instance.
|
||||||
#
|
#
|
||||||
# @param transport [Object] The transport object to use for communication with the server.
|
# @param transport [Object] The transport object to use for communication with the server.
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,42 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "../methods"
|
||||||
|
|
||||||
module MCP
|
module MCP
|
||||||
class Client
|
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
|
class HTTP
|
||||||
ACCEPT_HEADER = "application/json, text/event-stream"
|
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)
|
def initialize(url:, headers: {}, &block)
|
||||||
@url = url
|
@url = url
|
||||||
@headers = headers
|
@headers = headers
|
||||||
@faraday_customizer = block
|
@faraday_customizer = block
|
||||||
|
@session_id = nil
|
||||||
|
@protocol_version = nil
|
||||||
end
|
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:)
|
def send_request(request:)
|
||||||
method = request[:method] || request["method"]
|
method = request[:method] || request["method"]
|
||||||
params = request[:params] || request["params"]
|
params = request[:params] || request["params"]
|
||||||
|
|
||||||
response = client.post("", request)
|
response = client.post("", request, session_headers)
|
||||||
parse_response_body(response, method, params)
|
body = parse_response_body(response, method, params)
|
||||||
|
|
||||||
|
capture_session_info(method, response, body)
|
||||||
|
|
||||||
|
body
|
||||||
rescue Faraday::BadRequestError => e
|
rescue Faraday::BadRequestError => e
|
||||||
raise RequestHandlerError.new(
|
raise RequestHandlerError.new(
|
||||||
"The #{method} request is invalid",
|
"The #{method} request is invalid",
|
||||||
|
|
@ -41,12 +59,25 @@ module MCP
|
||||||
original_error: e,
|
original_error: e,
|
||||||
)
|
)
|
||||||
rescue Faraday::ResourceNotFound => e
|
rescue Faraday::ResourceNotFound => e
|
||||||
raise RequestHandlerError.new(
|
# Per spec, 404 is the session-expired signal only when the request
|
||||||
"The #{method} request is not found",
|
# actually carried an `Mcp-Session-Id`. A 404 without a session attached
|
||||||
{ method: method, params: params },
|
# (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
|
||||||
error_type: :not_found,
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||||
original_error: e,
|
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
|
rescue Faraday::UnprocessableEntityError => e
|
||||||
raise RequestHandlerError.new(
|
raise RequestHandlerError.new(
|
||||||
"The #{method} request is unprocessable",
|
"The #{method} request is unprocessable",
|
||||||
|
|
@ -83,6 +114,31 @@ module MCP
|
||||||
end
|
end
|
||||||
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!
|
def require_faraday!
|
||||||
require "faraday"
|
require "faraday"
|
||||||
rescue LoadError
|
rescue LoadError
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ module MCP
|
||||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_send_request_raises_not_found_error
|
def test_send_request_raises_not_found_error_on_404_without_session
|
||||||
request = {
|
request = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id: "test_id",
|
id: "test_id",
|
||||||
|
|
@ -218,11 +218,56 @@ module MCP
|
||||||
client.send_request(request: request)
|
client.send_request(request: request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
refute_kind_of(SessionExpiredError, error)
|
||||||
assert_equal("The tools/list request is not found", error.message)
|
assert_equal("The tools/list request is not found", error.message)
|
||||||
assert_equal(:not_found, error.error_type)
|
assert_equal(:not_found, error.error_type)
|
||||||
assert_equal({ method: "tools/list", params: nil }, error.request)
|
assert_equal({ method: "tools/list", params: nil }, error.request)
|
||||||
end
|
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
|
def test_send_request_raises_unprocessable_entity_error
|
||||||
request = {
|
request = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
@ -413,6 +458,149 @@ module MCP
|
||||||
assert_equal(:parse_error, error.error_type)
|
assert_equal(:parse_error, error.error_type)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def stub_request(method, url)
|
def stub_request(method, url)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue