Class: MCP::Client::HTTP
- Inherits:
-
Object
- Object
- MCP::Client::HTTP
- Defined in:
- lib/mcp/client/http.rb
Defined Under Namespace
Classes: InsecureURLError, OAuthURLGuard
Constant Summary collapse
- ACCEPT_HEADER =
"application/json, text/event-stream"- SSE_ACCEPT_HEADER =
"text/event-stream"- SESSION_ID_HEADER =
"Mcp-Session-Id"- PROTOCOL_VERSION_HEADER =
"MCP-Protocol-Version"- METHOD_HEADER =
"Mcp-Method"- NAME_HEADER =
"Mcp-Name"- LAST_EVENT_ID_HEADER =
"Last-Event-ID"- DEFAULT_RECONNECTION_DELAY_MS =
SEP-1699 reconnection tuning: the SSE
retry:field from the server takes precedence; this default applies when the server sent none. Both values match the Python SDK (DEFAULT_RECONNECTION_DELAY_MS,MAX_RECONNECTION_ATTEMPTS); the TypeScript SDK uses an exponential backoff that theretry:field likewise overrides. 1000- MAX_RECONNECTION_ATTEMPTS =
2- SSE_LISTENER_READ_TIMEOUT =
How long the standalone GET listening stream may stay idle before the read times out and the connection is counted as a failure and retried. Matches the Python SDK's
sse_read_timeoutdefault of 5 minutes; without this, the adapter's default read timeout (60 seconds for Net::HTTP) would recycle quiet streams too eagerly. 300
Instance Attribute Summary collapse
-
#oauth ⇒ Object
readonly
Returns the value of attribute oauth.
-
#protocol_version ⇒ Object
readonly
Returns the value of attribute protocol_version.
-
#server_info ⇒ Object
readonly
Returns the value of attribute server_info.
-
#session_id ⇒ Object
readonly
Returns the value of attribute session_id.
-
#url ⇒ Object
readonly
Returns the value of attribute url.
Instance Method Summary collapse
-
#close ⇒ Object
Terminates the session by sending an HTTP DELETE to the MCP endpoint with the current
Mcp-Session-Idheader, and clears locally tracked session state afterward. -
#connect(client_info: nil, protocol_version: nil, capabilities: {}) ⇒ Hash
Performs the MCP
initializehandshake: sends aninitializerequest followed by the requirednotifications/initializednotification. -
#connected? ⇒ Boolean
Returns true once
connecthas completed the full handshake (initializeresponse received andnotifications/initializedsent). -
#initialize(url:, headers: {}, oauth: nil, &block) ⇒ HTTP
constructor
A new instance of HTTP.
-
#on_server_request(method, &handler) ⇒ Object
Registers a handler for a server-to-client request (e.g.
elicitation/create) delivered on an SSE stream. -
#send_notification(notification:) ⇒ Object
Sends a JSON-RPC notification (no response expected).
-
#send_request(request:) ⇒ Object
Sends a JSON-RPC request and returns the parsed response body.
Constructor Details
#initialize(url:, headers: {}, oauth: nil, &block) ⇒ HTTP
Returns a new instance of HTTP.
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/mcp/client/http.rb', line 172 def initialize(url:, headers: {}, oauth: nil, &block) if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url) # Mask credentials (userinfo) and query parameters before quoting the URL in the error message # so they cannot leak into logs. safe_url = MCP::Client::OAuth::Discovery.canonicalize_origin_and_path(url) raise InsecureURLError, "MCP URL #{safe_url.inspect} must use https or be a loopback http URL when an oauth provider is set; " \ "sending bearer tokens over plain http to a remote host would leak them on the wire." end @url = url @headers = headers @faraday_customizer = block @oauth = oauth # Snapshot the canonical URL at construction time. This single value # serves two related roles, both of which need to see the query string: # # - As the RFC 8707 `resource` claim sent on the authorization and # token requests (and as the base for PRM discovery URLs) - # matching the TS / Python SDKs' `resourceUrlFromServerUrl` / # `resource_url_from_server_url` so multi-tenant servers that scope # by `?tenant=...` round-trip correctly. # - As the comparison value for the URL guard middleware. Comparing # query strings as well as origin + path is required so a Faraday # middleware that rewrites `env.url.query` to a different tenant # cannot send the bearer token to the wrong audience while # the resource binding on the OAuth side stays correct. # # Saved only when `oauth:` is set so non-OAuth transports keep their # existing behavior. @oauth_server_url = oauth ? MCP::Client::OAuth::Discovery.canonicalize_url(url) : nil @session_id = nil @protocol_version = nil @server_info = nil @connected = false @server_request_handlers = {} @listener_thread = nil end |
Instance Attribute Details
#oauth ⇒ Object (readonly)
Returns the value of attribute oauth.
170 171 172 |
# File 'lib/mcp/client/http.rb', line 170 def oauth @oauth end |
#protocol_version ⇒ Object (readonly)
Returns the value of attribute protocol_version.
170 171 172 |
# File 'lib/mcp/client/http.rb', line 170 def protocol_version @protocol_version end |
#server_info ⇒ Object (readonly)
Returns the value of attribute server_info.
170 171 172 |
# File 'lib/mcp/client/http.rb', line 170 def server_info @server_info end |
#session_id ⇒ Object (readonly)
Returns the value of attribute session_id.
170 171 172 |
# File 'lib/mcp/client/http.rb', line 170 def session_id @session_id end |
#url ⇒ Object (readonly)
Returns the value of attribute url.
170 171 172 |
# File 'lib/mcp/client/http.rb', line 170 def url @url end |
Instance Method Details
#close ⇒ Object
Terminates the session by sending an HTTP DELETE to the MCP endpoint
with the current Mcp-Session-Id header, and clears locally tracked
session state afterward. No-op when no session has been established.
Per spec, the server MAY respond with HTTP 405 Method Not Allowed when it does not support client-initiated termination, and returns 404 for a session it has already terminated. Both mean the session is gone — the desired end state. Other errors surface to the caller; local session state is cleared either way. https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
464 465 466 467 468 469 470 471 472 473 474 475 476 477 |
# File 'lib/mcp/client/http.rb', line 464 def close unless @session_id clear_session return end begin client.delete("", nil, session_headers) rescue Faraday::ClientError => e raise unless [404, 405].include?(e.response&.dig(:status)) ensure clear_session end end |
#connect(client_info: nil, protocol_version: nil, capabilities: {}) ⇒ Hash
Performs the MCP initialize handshake: sends an initialize request
followed by the required notifications/initialized notification.
The server's InitializeResult (protocol version, capabilities, server
info, instructions) is cached on the transport and returned.
Idempotent: a second call returns the cached InitializeResult without
contacting the server. After close, state is cleared and connect
will handshake again.
https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/mcp/client/http.rb', line 245 def connect(client_info: nil, protocol_version: nil, capabilities: {}) return @server_info if connected? client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION response = send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, id: SecureRandom.uuid, method: MCP::Methods::INITIALIZE, params: { protocolVersion: protocol_version, capabilities: capabilities, clientInfo: client_info, }, }) if response.is_a?(Hash) && response.key?("error") clear_session error = response["error"] raise RequestHandlerError.new( "Server initialization failed: #{error["message"]}", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end unless response.is_a?(Hash) && response["result"].is_a?(Hash) clear_session raise RequestHandlerError.new( "Server initialization failed: missing result in response", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end @server_info = response["result"] negotiated_protocol_version = @server_info["protocolVersion"] unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version) clear_session raise RequestHandlerError.new( "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end begin send_request(request: { jsonrpc: JsonRpcHandler::Version::V2_0, method: MCP::Methods::NOTIFICATIONS_INITIALIZED, }) rescue StandardError clear_session raise end @connected = true start_listening if @server_request_handlers.any? @server_info end |
#connected? ⇒ Boolean
Returns true once connect has completed the full handshake
(initialize response received and notifications/initialized sent).
Returns false before the first handshake and after close.
310 311 312 |
# File 'lib/mcp/client/http.rb', line 310 def connected? @connected end |
#on_server_request(method, &handler) ⇒ Object
Registers a handler for a server-to-client request (e.g. elicitation/create) delivered on an SSE stream.
The handler receives the request's params (a Hash with string keys, possibly empty) and its return value is
sent back to the server as the JSON-RPC result.
The handler may raise MCP::Client::ServerRequestError to answer with a specific JSON-RPC error code.
Requests for methods without a registered handler are answered with a JSON-RPC "method not found" (-32601) error.
Registering a handler opens a standalone GET SSE listening stream (once connected), since servers send requests
that are not tied to a client request on that stream - matching the TypeScript and Python SDK clients,
which start listening after the initialize handshake.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
220 221 222 223 224 225 |
# File 'lib/mcp/client/http.rb', line 220 def on_server_request(method, &handler) raise ArgumentError, "A handler block is required" unless handler @server_request_handlers[method.to_s] = handler start_listening if connected? end |
#send_notification(notification:) ⇒ Object
Sends a JSON-RPC notification (no response expected). Used by Client#cancel to deliver
notifications/cancelled for an in-flight request. The server acknowledges with HTTP 202 Accepted
per the Streamable HTTP spec.
440 441 442 443 444 445 446 447 448 449 450 451 452 |
# File 'lib/mcp/client/http.rb', line 440 def send_notification(notification:) method = notification[:method] || notification["method"] client.post("", notification, session_headers) nil rescue Faraday::Error => e raise RequestHandlerError.new( "Failed to send #{method} notification", { method: method }, error_type: :internal_error, original_error: e, ) end |
#send_request(request:) ⇒ Object
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.
If a block is given, it is invoked just before Faraday's post is called.
Faraday's synchronous post does not expose a post-write / pre-response hook,
so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens
inside post. MCP::Client#dispatch_with_cancellation uses this yield to release
the cancel-dispatch thread, which then issues a separate notifications/cancelled POST
that may overlap with the original request on the network. The spec covers this:
the sender has issued the request and still believes it in-progress, and receivers MAY ignore
a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 |
# File 'lib/mcp/client/http.rb', line 327 def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] oauth_retried = false step_up_retried = false begin # The response is consumed incrementally so that an SSE stream the server holds open # (or closes early per SEP-1699) can be handled; `initialize` streams are read to EOF # so the response object (and its `Mcp-Session-Id` header) is always available for capture. stream = SSEStream.new( abortable: method.to_s != MCP::Methods::INITIALIZE, on_request: ->() { dispatch_server_request() }, ) yield if block_given? response = begin client.post("", request, session_headers.merge((method, params))) do |req| req..on_data = stream.on_data end rescue StreamAbort nil end body = resolve_response_body(stream, response, method, params) capture_session_info(method, response, body) if response body rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", { method: method, params: params }, error_type: :bad_request, original_error: e, ) rescue Faraday::UnauthorizedError => e # Run the OAuth flow at most once per `send_request` invocation. # The `oauth_retried` flag lives outside the `begin` so it survives `retry`, # ensuring a server returning 401 indefinitely raises rather than loops. if @oauth && !oauth_retried oauth_retried = true run_oauth_flow!(unauthorized_error: e) retry end raise RequestHandlerError.new( "You are unauthorized to make #{method} requests", { method: method, params: params }, error_type: :unauthorized, original_error: e, ) rescue Faraday::ForbiddenError => e # OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in # the Bearer challenge means the existing access token is valid # but lacks scopes the server now requires for this operation. # Re-run the full authorization flow with the escalated scope from # the challenge and retry once. A plain 403 without the challenge is # surfaced unchanged. if @oauth && !step_up_retried && insufficient_scope_challenge?(e) step_up_retried = true run_step_up_flow!(forbidden_error: e) retry end raise RequestHandlerError.new( "You are forbidden to make #{method} requests", { method: method, params: params }, error_type: :forbidden, original_error: e, ) rescue Faraday::ResourceNotFound => 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", { method: method, params: params }, error_type: :unprocessable_entity, original_error: e, ) rescue Faraday::Error => e raise RequestHandlerError.new( "Internal error handling #{method} request", { method: method, params: params }, error_type: :internal_error, original_error: e, ) end end |