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- MIN_RECONNECTION_DELAY_MS =
Floor on the effective reconnection delay.
listen_for_server_requeststreats a graceful close as success and resetsconsecutive_failures, soretry: 0never reaches the attempt cap and reconnects in a tight loop. Waiting longer than the server asked for is explicitly allowed: theretryfield the spec points at is the one defined by WHATWG HTML, whose reconnection algorithm reads "Wait a delay equal to the reconnection time of the event source. Optionally, wait some more." Waiting less is what the spec's MUST rules out, and nothing here ever does that.https://html.spec.whatwg.org/multipage/server-sent-events.html#reconnection-time
100- MAX_RECONNECTION_WAIT =
Budget in seconds for
await_response_after_disconnect: it gates every wait between reconnection attempts, and whatever is left of it becomes the read timeout of each resumed stream. That method runs on the calling thread, so without a deadline a server answering with a largeretry:parks a thread of the embedding application for as long as it likes;MAX_RECONNECTION_ATTEMPTScaps how many times the client reconnects, not how long it waits for each. A delay that would run past the deadline is not shortened - the client stops reconnecting instead, the same kind of decision the attempt cap already makes, so the server'sretry:is always honored in full or not acted on at all.Matches
SSE_LISTENER_READ_TIMEOUT, this client's other "how long to wait on a quiet SSE stream" value.listen_for_server_requestshas no such deadline: it runs on a thread this client owns and is meant to poll indefinitely, so a longretry:there idles the SDK's own listener rather than the application. 300- 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- MAX_MESSAGE_BYTES =
Upper bound in bytes on a single JSON-RPC message from the server - an SSE event or a JSON response body - buffered in memory while reading a response. Without a bound, a server that never terminates an SSE event (or never ends a JSON body) grows the buffer indefinitely. Matches the 4 MiB default of
MCP::Client::Stdio::MAX_LINE_BYTESand the server transports' request cap. 4 * 1024 * 1024
- MAX_MCP_PARAM_TOOLS =
Upper bound on the tools whose
x-mcp-headerdeclarations are retained forMcp-Param-*mirroring (SEP-2243). Past the cap, newly listed tools mirror nothing (the spec's guidance to send without custom headers), so a server rotating tool names acrosstools/listresponses cannot grow the registry without bound. 1000
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: {}, mode: :legacy) ⇒ 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), or once the modern lifecycle was adopted viaserver/discover. -
#initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, max_reconnection_wait: MAX_RECONNECTION_WAIT, &block) ⇒ HTTP
constructor
A new instance of HTTP.
-
#modern? ⇒ Boolean
Whether the transport operates in the stateless modern lifecycle (SEP-2575): no handshake was performed and every request carries the
_metaenvelope. -
#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, max_message_bytes: MAX_MESSAGE_BYTES, max_reconnection_wait: MAX_RECONNECTION_WAIT, &block) ⇒ HTTP
Returns a new instance of HTTP.
242 243 244 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 |
# File 'lib/mcp/client/http.rb', line 242 def initialize( url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, max_reconnection_wait: MAX_RECONNECTION_WAIT, &block ) # `nil` or a non-positive value would make the buffering unbounded and silently # disable the protection, so reject it up front. unless .is_a?(Integer) && > 0 raise ArgumentError, "max_message_bytes must be a positive Integer" end unless max_reconnection_wait.is_a?(Numeric) && max_reconnection_wait > 0 raise ArgumentError, "max_reconnection_wait must be a positive number" end 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 @max_message_bytes = @max_reconnection_wait = max_reconnection_wait # 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 @modern_client_info = nil @modern_capabilities = nil @mcp_param_declarations = {} end |
Instance Attribute Details
#oauth ⇒ Object (readonly)
Returns the value of attribute oauth.
240 241 242 |
# File 'lib/mcp/client/http.rb', line 240 def oauth @oauth end |
#protocol_version ⇒ Object (readonly)
Returns the value of attribute protocol_version.
240 241 242 |
# File 'lib/mcp/client/http.rb', line 240 def protocol_version @protocol_version end |
#server_info ⇒ Object (readonly)
Returns the value of attribute server_info.
240 241 242 |
# File 'lib/mcp/client/http.rb', line 240 def server_info @server_info end |
#session_id ⇒ Object (readonly)
Returns the value of attribute session_id.
240 241 242 |
# File 'lib/mcp/client/http.rb', line 240 def session_id @session_id end |
#url ⇒ Object (readonly)
Returns the value of attribute url.
240 241 242 |
# File 'lib/mcp/client/http.rb', line 240 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
546 547 548 549 550 551 552 553 554 555 556 557 558 559 |
# File 'lib/mcp/client/http.rb', line 546 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: {}, mode: :legacy) ⇒ 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
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 |
# File 'lib/mcp/client/http.rb', line 342 def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) return @server_info if connected? # Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake. MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } case mode when :legacy connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) when :modern connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) when :auto connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) else raise ArgumentError, "mode must be :legacy, :modern, or :auto" end end |
#connected? ⇒ Boolean
Returns true once connect has completed the full handshake
(initialize response received and notifications/initialized sent),
or once the modern lifecycle was adopted via server/discover.
Returns false before the first handshake and after close.
372 373 374 |
# File 'lib/mcp/client/http.rb', line 372 def connected? @connected end |
#modern? ⇒ Boolean
Whether the transport operates in the stateless modern lifecycle (SEP-2575):
no handshake was performed and every request carries the _meta envelope.
364 365 366 |
# File 'lib/mcp/client/http.rb', line 364 def modern? !@modern_client_info.nil? 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
312 313 314 315 316 317 |
# File 'lib/mcp/client/http.rb', line 312 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.
522 523 524 525 526 527 528 529 530 531 532 533 534 |
# File 'lib/mcp/client/http.rb', line 522 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
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 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 |
# File 'lib/mcp/client/http.rb', line 389 def send_request(request:) # Modern requests (never notifications, whose `_meta` has no envelope) carry # the SEP-2575 triple; the `MCP-Protocol-Version` header from `session_headers` # matches it by construction. if modern? && (request[:id] || request["id"]) request = ModernEnvelope.stamp( request, protocol_version: @protocol_version, client_info: @modern_client_info, capabilities: @modern_capabilities, ) end 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, max_message_bytes: @max_message_bytes, 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 capture_mcp_param_declarations(method, params, body) body rescue MessageTooLargeError => e raise RequestHandlerError.new( e., { method: method, params: params }, error_type: :internal_error, ) 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 |