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>
This commit is contained in:
parent
baa093dfd5
commit
07226b7cf1
2 changed files with 275 additions and 10 deletions
|
|
@ -33,6 +33,16 @@ module MCP
|
|||
# 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.
|
||||
|
|
|
|||
|
|
@ -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 @@ module MCP
|
|||
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 @@ module MCP
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue