## Motivation and Context Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761 A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are both gated on the per-request `_meta` envelope, which such a connection never carries, so strictly validating clients reject every result as missing the REQUIRED `resultType`. The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish a session via `initialize` (`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry; the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too, and counter-offers `LATEST_HANDSHAKE_VERSION`. `initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list (`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know, is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on `2025-11-25`, where `resultType` does not exist and its absence is the correct shape, resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is no longer constructible, so the gates are correct by construction. The client side is symmetrized the same way, matching both reference SDKs: the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default, passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError` (validated before the stdio transport spawns its child process), and a server answering `initialize` with a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to the discovery probe and never downgrades it into the legacy fallback: the probe's failure is the real answer and propagates, instead of the fallback dying on the handshake guard. The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`), and the handshake list is derived from the stable and modern lists so the era partition cannot drift when the next revision lands. Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land on a deprecating version and the call site the warnings used to sit on had become unreachable. On the server the answer is removal: `Configuration#protocol_version` scopes the handshake, so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction. Accepting one would configure nothing (a modern version has no handshake to pin), and neither reference SDK carries a config value that is accepted but inert: Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions` is consumed by its own era's mechanism. With no path left onto a deprecating revision, the server-side deprecation warning plumbing became unreachable and is removed along with it. Keying those warnings on a modern-era session instead was tried and rejected, because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line would then carry a deprecation warning for doing exactly what the revision prescribes (a single test file produced over a thousand of them). A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered, and such a request continues to be served at the negotiated revision rather than rejected. Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`, through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with its consequence, which is that no `resultType` appears on those results. Two details follow from the pin being handshake-scoped. Reading it back when nothing is set now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era, so the getter no longer hands out a value its own writer rejects and round-tripping the setting keeps working; only `Server#validate!` reads that default, through three comparisons that answer the same for either value, so nothing else moves. And the rejection names its reason instead of listing the accepted versions, because this is the error an upgrade from a release that accepted the value lands on, which makes it the migration note a bare list would not be. `MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now, and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be a breaking change for no gain. The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper now use the handshake version as well, and the README section on `protocol_version` describes the pin as handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default, and `2026-07-28` reached through `server/discover` with no configuration needed. ## How Has This Been Tested? New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType` (the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer, the `ArgumentError` for an explicitly modern version on the legacy connect (asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult` on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities. Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version, the answer is an older one, and the connection speaks the answer afterwards (over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption from an echo, because every one of them had the server answer with the version the client had just offered. Both new tests were confirmed to fail when the client is made to report its own offer instead. Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked: the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer, and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires. The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version, or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths, since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests on the constructor and the setter, asserting the reason in the message rather than the list of accepted values. The default-pin test now also round-trips what it reads back into a fresh `Configuration`, which is what the old default could not do. The default move was checked for neutrality outside the suite as well, by building a server that uses every feature `Server#validate!` gates on that value (`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations) and confirming all of them are still accepted. ## Breaking Changes Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through the handshake are counter-offered `2025-11-25` and are served at that revision, matching the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now raises `ArgumentError` (the pin scopes the handshake, which cannot land there); the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway. Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`; nothing in this SDK behaves differently for either value, but code comparing that reader against a literal will see the change. The client no longer offers modern versions on the legacy handshake and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision; the client-side warning on a modern connect replaces them.
1219 lines
56 KiB
Ruby
1219 lines
56 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "securerandom"
|
|
require_relative "../../json_rpc_handler"
|
|
require_relative "../configuration"
|
|
require_relative "../methods"
|
|
require_relative "../protocol_deprecations"
|
|
require_relative "../version"
|
|
require_relative "mcp_param_headers"
|
|
require_relative "modern_envelope"
|
|
|
|
module MCP
|
|
class Client
|
|
class HTTP
|
|
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"
|
|
|
|
# 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 the `retry:` field likewise overrides.
|
|
DEFAULT_RECONNECTION_DELAY_MS = 1000
|
|
MAX_RECONNECTION_ATTEMPTS = 2
|
|
|
|
# Floor on the effective reconnection delay. `listen_for_server_requests` treats a graceful close as success
|
|
# and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and reconnects in a tight loop.
|
|
# Waiting longer than the server asked for is explicitly allowed: the `retry` field 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
|
|
MIN_RECONNECTION_DELAY_MS = 100
|
|
|
|
# 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 large `retry:` parks a thread of the embedding application for
|
|
# as long as it likes; `MAX_RECONNECTION_ATTEMPTS` caps 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's `retry:` 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_requests` has no such deadline: it runs on a thread this client owns and is meant
|
|
# to poll indefinitely, so a long `retry:` there idles the SDK's own listener rather than the application.
|
|
MAX_RECONNECTION_WAIT = 300
|
|
|
|
# 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_timeout` default of 5 minutes; without this, the adapter's default read timeout
|
|
# (60 seconds for Net::HTTP) would recycle quiet streams too eagerly.
|
|
SSE_LISTENER_READ_TIMEOUT = 300
|
|
|
|
# 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_BYTES`
|
|
# and the server transports' request cap.
|
|
MAX_MESSAGE_BYTES = 4 * 1024 * 1024
|
|
|
|
# Upper bound on the tools whose `x-mcp-header` declarations are retained for
|
|
# `Mcp-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 across
|
|
# `tools/list` responses cannot grow the registry without bound.
|
|
MAX_MCP_PARAM_TOOLS = 1000
|
|
|
|
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
# is trivially observed and stolen.
|
|
class InsecureURLError < ArgumentError; end
|
|
|
|
# Faraday request middleware that compares the outgoing request URL
|
|
# against the URL snapshotted at `MCP::Client::HTTP#initialize` time.
|
|
# Registered after the user's customizer so it sees `env.url` *after*
|
|
# any custom middleware has had a chance to rewrite it - closing
|
|
# the `Faraday env.url = URI("https://attacker...")` bypass that a plain
|
|
# `client.url_prefix` check would miss. The comparison includes
|
|
# the query string, so a middleware that rewrites `env.url.query` to
|
|
# a different tenant (e.g. `?tenant=evil`) is rejected as well; otherwise
|
|
# the audience-binding check on the OAuth side could be bypassed at
|
|
# the send step.
|
|
class OAuthURLGuard
|
|
def initialize(app, expected_url:)
|
|
@app = app
|
|
@expected_url = expected_url
|
|
end
|
|
|
|
def call(env)
|
|
effective = MCP::Client::OAuth::Discovery.canonicalize_url(env.url.to_s)
|
|
unless effective == @expected_url
|
|
# Surface the *canonicalized* form (no userinfo, no fragment) so
|
|
# credentials like `user:pass@` cannot leak into logs, stack
|
|
# traces, or exception reporters.
|
|
raise InsecureURLError,
|
|
"Effective request URL #{effective.inspect} does not match the URL " \
|
|
"validated at initialize time (#{@expected_url.inspect}); refusing to send a bearer token."
|
|
end
|
|
@app.call(env)
|
|
end
|
|
end
|
|
|
|
# Internal control-flow signal raised inside the streaming callback to stop reading
|
|
# an SSE stream once the awaited JSON-RPC response has arrived; servers may hold
|
|
# a stream open indefinitely (notably the standalone GET stream), so EOF cannot be relied on.
|
|
class StreamAbort < StandardError; end
|
|
private_constant :StreamAbort
|
|
|
|
# Raised inside the streaming callback when the server sends more bytes than
|
|
# `max_message_bytes` without completing a message. Translated into
|
|
# a `RequestHandlerError` (or a listener stop) where the request context is known.
|
|
class MessageTooLargeError < StandardError; end
|
|
private_constant :MessageTooLargeError
|
|
|
|
# Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams:
|
|
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
|
|
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
|
|
class SSEStream
|
|
attr_reader :buffer, :response, :last_event_id, :retry_ms
|
|
attr_accessor :abortable
|
|
|
|
def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
|
|
@abortable = abortable
|
|
@max_message_bytes = max_message_bytes
|
|
@on_request = on_request
|
|
@buffer = +""
|
|
@parser = nil
|
|
@response = nil
|
|
@last_event_id = nil
|
|
@retry_ms = nil
|
|
end
|
|
|
|
# Whether the server sent a SEP-1699 priming event (any event carrying an id),
|
|
# which marks the stream as resumable after a graceful close.
|
|
def primed?
|
|
!@last_event_id.nil?
|
|
end
|
|
|
|
# Faraday `on_data` streaming callback. SSE chunks are parsed incrementally;
|
|
# anything else (JSON bodies) accumulates in `buffer`. Both paths are bounded
|
|
# by `max_message_bytes`, checked after the awaited response had its chance to
|
|
# abort the stream so an over-limit tail cannot mask a response that already arrived.
|
|
def on_data
|
|
proc do |chunk, _received_bytes, env|
|
|
if event_stream?(env)
|
|
feed(chunk)
|
|
raise StreamAbort if @abortable && @response
|
|
|
|
if parser_buffered_bytes > @max_message_bytes
|
|
raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing"
|
|
end
|
|
else
|
|
@buffer << chunk
|
|
|
|
if @buffer.bytesize > @max_message_bytes
|
|
raise MessageTooLargeError, "Server response body exceeds #{@max_message_bytes} bytes"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# A fresh parser for a new HTTP connection (reconnection GET),
|
|
# so a partial line from the previous stream cannot corrupt the next one.
|
|
def reset_parser!
|
|
@parser = nil
|
|
end
|
|
|
|
# Parses an SSE body that was delivered outside the streaming callback:
|
|
# Faraday versions that do not pass `env` to `on_data` leave SSE chunks in `buffer`
|
|
# (consumed here so they are not parsed twice), and adapters without `on_data` support
|
|
# yield the whole body only via `fallback_body` (the Faraday `response.body`).
|
|
def ingest_pending!(fallback_body)
|
|
text = @buffer.empty? ? fallback_body : @buffer.slice!(0..-1)
|
|
feed(text) if text.is_a?(String) && !text.empty?
|
|
end
|
|
|
|
private
|
|
|
|
def event_stream?(env)
|
|
headers = env&.response_headers
|
|
content_type = headers && (headers["content-type"] || headers["Content-Type"])
|
|
!!content_type&.include?("text/event-stream")
|
|
end
|
|
|
|
def feed(chunk)
|
|
parser.feed(chunk) do |_type, data, id, reconnection_time|
|
|
@last_event_id = id if id && !id.empty?
|
|
@retry_ms = reconnection_time if reconnection_time
|
|
next if data.nil? || data.empty?
|
|
|
|
begin
|
|
parsed = JSON.parse(data)
|
|
rescue JSON::ParserError
|
|
next
|
|
end
|
|
|
|
next unless parsed.is_a?(Hash)
|
|
|
|
if parsed.key?("result") || parsed.key?("error")
|
|
@response ||= parsed
|
|
elsif parsed["method"] && parsed.key?("id")
|
|
# A server-to-client request (e.g. `elicitation/create`) delivered
|
|
# on the stream while the original request is still pending.
|
|
@on_request&.call(parsed)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Bytes the parser has buffered for a not-yet-dispatched event: the partial line
|
|
# and the accumulated `data:` field values (plus the small event type and last event id buffers).
|
|
# External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters)
|
|
# from consumed-and-retained ones, so the parser's own String buffers are measured instead;
|
|
# summing every String instance variable keeps the measurement valid if the parser renames or adds buffers.
|
|
# A parser rewrite that buffered elsewhere would make this return 0 and quietly lift the cap -
|
|
# the regression tests feeding an over-limit event guard against that.
|
|
def parser_buffered_bytes
|
|
parser.instance_variables.sum do |name|
|
|
value = parser.instance_variable_get(name)
|
|
value.is_a?(String) ? value.bytesize : 0
|
|
end
|
|
end
|
|
|
|
def parser
|
|
@parser ||= begin
|
|
require "event_stream_parser"
|
|
EventStreamParser::Parser.new
|
|
rescue LoadError
|
|
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
|
|
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
|
|
"See https://rubygems.org/gems/event_stream_parser for more details."
|
|
end
|
|
end
|
|
end
|
|
private_constant :SSEStream
|
|
|
|
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
|
|
|
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 max_message_bytes.is_a?(Integer) && max_message_bytes > 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_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
|
|
|
|
# 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
|
|
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
|
|
|
|
# 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.
|
|
#
|
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
|
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
|
|
# @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
|
|
# Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
|
|
# raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
|
|
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
|
|
# @return [Hash] The server's `InitializeResult`.
|
|
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
|
|
# or a malformed result.
|
|
# @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs
|
|
# the handshake below, `:modern` skips it and probes `server/discover`,
|
|
# and `:auto` probes `server/discover` first, falling back to the legacy handshake
|
|
# when the server does not serve a mutually supported modern version.
|
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
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
|
|
|
|
# Whether the transport operates in the stateless modern lifecycle (SEP-2575):
|
|
# no handshake was performed and every request carries the `_meta` envelope.
|
|
def modern?
|
|
!@modern_client_info.nil?
|
|
end
|
|
|
|
# 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`.
|
|
def connected?
|
|
@connected
|
|
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.
|
|
#
|
|
# 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
|
|
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: ->(message) { dispatch_server_request(message) },
|
|
)
|
|
|
|
yield if block_given?
|
|
|
|
response = begin
|
|
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
|
|
req.options.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.message,
|
|
{ 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
|
|
|
|
# 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.
|
|
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
|
|
|
|
# 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
|
|
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
|
|
|
|
private
|
|
|
|
attr_reader :headers
|
|
|
|
def connect_legacy(client_info:, protocol_version:, capabilities:)
|
|
protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_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"]
|
|
# A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake
|
|
# settles on a legacy version by definition, and the TypeScript and Python clients refuse
|
|
# a modern counter-offer the same way.
|
|
unless MCP::Configuration::SUPPORTED_HANDSHAKE_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
|
|
|
|
# Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version.
|
|
# No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`.
|
|
def connect_modern(client_info:, protocol_version:, capabilities:)
|
|
version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
|
|
unless MCP::Configuration.modern_protocol_version?(version)
|
|
raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version"
|
|
end
|
|
|
|
enter_modern_mode(protocol_version: version, client_info: client_info, capabilities: capabilities)
|
|
|
|
begin
|
|
result = probe_discover
|
|
rescue StandardError
|
|
leave_modern_mode
|
|
raise
|
|
end
|
|
|
|
supported = result["supportedVersions"]
|
|
unless supported.is_a?(Array) && supported.include?(version)
|
|
leave_modern_mode
|
|
raise RequestHandlerError.new(
|
|
"Server discovery failed: no mutually supported modern protocol version " \
|
|
"(server supports #{supported.inspect})",
|
|
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
# SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
|
|
# so the warning lives here now that the handshake cannot land on one.
|
|
MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
|
|
|
|
@server_info = result
|
|
@connected = true
|
|
@server_info
|
|
end
|
|
|
|
# Probes `server/discover` and adopts the modern lifecycle when the server serves
|
|
# a mutually supported modern version; otherwise falls back to the legacy handshake.
|
|
# The fallback intentionally covers a successful discovery without a mutual modern
|
|
# version as well: during the 2026-07-28 rollout a server may answer discovery while
|
|
# only serving legacy versions.
|
|
def connect_auto(client_info:, protocol_version:, capabilities:)
|
|
modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
|
|
connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
|
|
rescue RequestHandlerError
|
|
# An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
|
|
# so the probe's failure is the real answer and propagates.
|
|
raise if modern_pin
|
|
|
|
connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
|
|
end
|
|
|
|
def enter_modern_mode(protocol_version:, client_info:, capabilities:)
|
|
@modern_client_info = client_info
|
|
@modern_capabilities = capabilities || {}
|
|
# `session_headers` sends `@protocol_version` as the `MCP-Protocol-Version` header,
|
|
# which the modern lifecycle requires to match the `_meta`-carried version.
|
|
@protocol_version = protocol_version
|
|
end
|
|
|
|
def leave_modern_mode
|
|
@modern_client_info = nil
|
|
@modern_capabilities = nil
|
|
@protocol_version = nil
|
|
end
|
|
|
|
def probe_discover
|
|
response = send_request(request: {
|
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
id: SecureRandom.uuid,
|
|
method: MCP::Methods::SERVER_DISCOVER,
|
|
})
|
|
|
|
if response.is_a?(Hash) && response.key?("error")
|
|
error = response["error"]
|
|
raise RequestHandlerError.new(
|
|
"Server discovery failed: #{error["message"]}",
|
|
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
result = response.is_a?(Hash) ? response["result"] : nil
|
|
unless result.is_a?(Hash)
|
|
raise RequestHandlerError.new(
|
|
"Server discovery failed: missing result in response",
|
|
{ method: MCP::Methods::SERVER_DISCOVER },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def client
|
|
require_faraday!
|
|
@client ||= Faraday.new(url) do |faraday|
|
|
faraday.request(:json)
|
|
faraday.response(:json)
|
|
faraday.response(:raise_error)
|
|
|
|
faraday.headers["Accept"] = ACCEPT_HEADER
|
|
headers.each do |key, value|
|
|
faraday.headers[key] = value
|
|
end
|
|
|
|
@faraday_customizer&.call(faraday)
|
|
|
|
# Register the URL identity guard *after* the user's customizer
|
|
# so it sits closest to the adapter in the request stack. That way
|
|
# the guard sees `env.url` after any customizer-added middleware has had
|
|
# a chance to rewrite it, closing the `env.url = URI("https://...")`
|
|
# bypass that a `Faraday::Connection#url_prefix` check cannot detect.
|
|
faraday.use(OAuthURLGuard, expected_url: @oauth_server_url) if @oauth_server_url
|
|
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
|
|
if @oauth && (token = @oauth.access_token)
|
|
request_headers["Authorization"] = "Bearer #{token}"
|
|
end
|
|
request_headers
|
|
end
|
|
|
|
# Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries
|
|
# can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name`
|
|
# (tools, prompts) or, when absent, `params.uri` (resources).
|
|
#
|
|
# https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata
|
|
def request_metadata_headers(method, params)
|
|
return {} unless method
|
|
|
|
metadata_headers = { METHOD_HEADER => method }
|
|
if params.is_a?(Hash)
|
|
name = params[:name] || params["name"]
|
|
name = params[:uri] || params["uri"] unless name.is_a?(String)
|
|
metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
|
|
|
|
if method == MCP::Methods::TOOLS_CALL && (declarations = @mcp_param_declarations[params[:name] || params["name"]])
|
|
arguments = params[:arguments] || params["arguments"]
|
|
|
|
metadata_headers.merge!(McpParamHeaders.build(declarations, arguments))
|
|
end
|
|
end
|
|
|
|
metadata_headers
|
|
end
|
|
|
|
# Learns the `x-mcp-header` declarations of the tools a `tools/list` response advertises,
|
|
# so later `tools/call` requests can mirror the annotated arguments into `Mcp-Param-*` headers (SEP-2243).
|
|
# The custom headers exist on the modern lifecycle only, matching the TypeScript and Python SDKs,
|
|
# so legacy connections learn nothing. Only a valid, non-empty declaration set is kept:
|
|
# an invalid tool definition mirrors nothing,
|
|
# following the spec's guidance to send without custom headers when no reliable declarations are available.
|
|
def capture_mcp_param_declarations(method, params, body)
|
|
return unless modern?
|
|
return unless method.to_s == MCP::Methods::TOOLS_LIST && body.is_a?(Hash)
|
|
|
|
tools = body.dig("result", "tools")
|
|
return unless tools.is_a?(Array)
|
|
|
|
# An uncursored request answered without `nextCursor` is the complete tool universe,
|
|
# so knowledge about unlisted tools is stale; the registry is rebuilt from this listing,
|
|
# the same pruning the Python SDK applies to complete listings.
|
|
cursor = params.is_a?(Hash) && (params[:cursor] || params["cursor"])
|
|
complete = !cursor && body.dig("result", "nextCursor").nil?
|
|
registry = complete ? {} : @mcp_param_declarations
|
|
|
|
tools.each do |tool|
|
|
next unless tool.is_a?(Hash)
|
|
|
|
name = tool["name"]
|
|
next unless name.is_a?(String)
|
|
|
|
scan = McpParamHeaders.scan(tool["inputSchema"])
|
|
if scan[:valid] && !scan[:declarations].empty?
|
|
next if !registry.key?(name) && registry.size >= MAX_MCP_PARAM_TOOLS
|
|
|
|
registry[name] = scan[:declarations]
|
|
else
|
|
registry.delete(name)
|
|
end
|
|
end
|
|
|
|
@mcp_param_declarations = registry if complete
|
|
end
|
|
|
|
# A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
|
|
# which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
|
|
# `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
|
|
def encode_header_value(value)
|
|
return value if safe_header_value?(value)
|
|
|
|
"=?base64?#{[value].pack("m0")}?="
|
|
end
|
|
|
|
def safe_header_value?(value)
|
|
value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } &&
|
|
(value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) &&
|
|
!(value.start_with?("=?base64?") && value.end_with?("?="))
|
|
end
|
|
|
|
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
|
|
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
|
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
|
#
|
|
# If the provider already holds a refresh token, we try to exchange it
|
|
# for a fresh access token first; only when that fails (e.g., the refresh token was revoked)
|
|
# do we fall back to the full Authorization Code + PKCE + DCR flow.
|
|
#
|
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
|
|
def run_oauth_flow!(unauthorized_error:)
|
|
params = parse_www_authenticate_from_error(unauthorized_error)
|
|
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
|
|
return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
|
|
|
|
run_full_authorization_flow!(flow: flow, params: params)
|
|
end
|
|
|
|
# Drives a full Authorization Code + PKCE flow without first attempting
|
|
# to refresh the access token. Used for the MCP scope-selection-strategy
|
|
# step-up path: the provider already holds a valid access token,
|
|
# but the server returned a 403 with
|
|
# `WWW-Authenticate: ... error="insufficient_scope", scope="..."`
|
|
# per RFC 6750 Section 3.1. Refreshing the existing token would re-issue
|
|
# the same scope set the server already rejected, so the SDK must run
|
|
# a fresh authorization request. The request asks for the union of
|
|
# the currently granted scope and the newly demanded scope; otherwise
|
|
# the caller would lose previously held scopes and trigger another step-up
|
|
# on the next operation that needs them.
|
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy
|
|
def run_step_up_flow!(forbidden_error:)
|
|
params = parse_www_authenticate_from_error(forbidden_error)
|
|
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
|
|
params = params.merge("scope" => escalated_step_up_scope(params["scope"]))
|
|
|
|
run_full_authorization_flow!(flow: flow, params: params)
|
|
end
|
|
|
|
# Returns the space-separated union of the currently granted scope (read
|
|
# from the stored token response per RFC 6749 Section 5.1) and the scope
|
|
# demanded by the step-up challenge. Duplicates are collapsed; order
|
|
# follows first appearance so existing scopes precede the newly added
|
|
# ones. Returns `nil` when neither side carries a scope so
|
|
# `build_authorization_url` omits the `scope` parameter entirely.
|
|
def escalated_step_up_scope(challenge_scope)
|
|
tokens = @oauth.tokens
|
|
granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil
|
|
scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq
|
|
|
|
scopes.empty? ? nil : scopes.join(" ")
|
|
end
|
|
|
|
# True when the response on `forbidden_error` carries a Bearer challenge
|
|
# with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP
|
|
# scope-selection-strategy section. A 403 without that signal is not a
|
|
# step-up challenge and must not trigger re-authorization.
|
|
def insufficient_scope_challenge?(forbidden_error)
|
|
parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope"
|
|
end
|
|
|
|
def parse_www_authenticate_from_error(error)
|
|
response = error.response || {}
|
|
response_headers = response[:headers] || {}
|
|
header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
|
|
MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
|
|
end
|
|
|
|
def run_full_authorization_flow!(flow:, params:)
|
|
# Use the URL snapshotted at `initialize` time so a post-construction
|
|
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
|
|
# URL to an attacker-controlled host.
|
|
flow.run!(
|
|
server_url: @oauth_server_url,
|
|
resource_metadata_url: params["resource_metadata"],
|
|
scope: params["scope"],
|
|
)
|
|
end
|
|
|
|
# Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
|
|
# on success and falsy on either "no refresh token available" or "refresh attempt failed"
|
|
# (in which case the caller should run the full interactive flow).
|
|
def attempt_refresh(flow:, resource_metadata_url:)
|
|
return false unless refresh_token_available?
|
|
|
|
# Use the snapshotted URL for the same reason as `run_oauth_flow!` above:
|
|
# post-construction `@url` mutation must not redirect token-refresh
|
|
# discovery to an attacker-controlled host. Use the query-bearing form
|
|
# so the refresh request's RFC 8707 `resource` claim matches
|
|
# the original authorization request.
|
|
flow.refresh!(server_url: @oauth_server_url, resource_metadata_url: resource_metadata_url)
|
|
true
|
|
rescue MCP::Client::OAuth::Flow::InvalidGrantError
|
|
# The refresh token has been revoked or expired by the AS. Wipe it so
|
|
# the full interactive flow runs fresh on the retry.
|
|
@oauth.clear_tokens!
|
|
false
|
|
rescue MCP::Client::OAuth::Flow::AuthorizationError
|
|
# Transient failure (network, 5xx, AS metadata, etc.). Leave the refresh
|
|
# token in place; the next attempt may succeed and we avoid forcing
|
|
# the user through an interactive reauth for a recoverable error.
|
|
false
|
|
end
|
|
|
|
def refresh_token_available?
|
|
tokens = @oauth.tokens
|
|
return false unless tokens.is_a?(Hash)
|
|
|
|
!(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
|
|
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
|
|
stop_listening
|
|
@session_id = nil
|
|
@protocol_version = nil
|
|
@server_info = nil
|
|
@connected = false
|
|
@modern_client_info = nil
|
|
@modern_capabilities = nil
|
|
end
|
|
|
|
# Opens the standalone GET SSE listening stream on a background thread so server-to-client requests
|
|
# can arrive while the main thread is blocked on its own request (e.g. a tools/call awaiting elicitation).
|
|
# Like the TypeScript and Python SDK clients, the GET is fired without waiting for it to be established.
|
|
def start_listening
|
|
return if @listener_thread&.alive?
|
|
|
|
@listener_thread = Thread.new { listen_for_server_requests }
|
|
end
|
|
|
|
def stop_listening
|
|
@listener_thread&.kill
|
|
@listener_thread = nil
|
|
end
|
|
|
|
# Reconnection semantics match the TypeScript and Python SDK clients: a stream that was established and
|
|
# then closed gracefully is retried indefinitely after the server's `retry:` interval (SSE semantics),
|
|
# while consecutive connection failures - HTTP errors and idle read timeouts both count - stop the listener
|
|
# once they reach `MAX_RECONNECTION_ATTEMPTS`; a successful stream in between resets the count.
|
|
# A 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream.
|
|
def listen_for_server_requests
|
|
stream = SSEStream.new(
|
|
abortable: false,
|
|
max_message_bytes: @max_message_bytes,
|
|
on_request: ->(message) { dispatch_server_request(message) },
|
|
)
|
|
consecutive_failures = 0
|
|
|
|
loop do
|
|
begin
|
|
client.get("") do |request|
|
|
request.headers.update(session_headers)
|
|
request.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
request.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
request.options.read_timeout = SSE_LISTENER_READ_TIMEOUT
|
|
request.options.on_data = stream.on_data
|
|
end
|
|
|
|
consecutive_failures = 0
|
|
rescue MessageTooLargeError
|
|
# Reconnecting would let the server stream the same over-limit event again, so stop listening instead of retrying.
|
|
break
|
|
rescue Faraday::Error => e
|
|
break if e.response&.dig(:status) == 405
|
|
|
|
consecutive_failures += 1
|
|
|
|
break if consecutive_failures >= MAX_RECONNECTION_ATTEMPTS
|
|
end
|
|
|
|
stream.reset_parser!
|
|
|
|
sleep(reconnection_delay_seconds(stream))
|
|
end
|
|
end
|
|
|
|
# The reconnection delay in seconds: the server's `retry:` value when it sent one and
|
|
# the default otherwise, never shortened, raised to `MIN_RECONNECTION_DELAY_MS` when the server
|
|
# asked for less than that. A `retry:` that is negative or not a run of digits is not a value at all;
|
|
# the SSE parser drops it, so those arrive here as the default rather than as something to guard.
|
|
def reconnection_delay_seconds(stream)
|
|
delay_ms = stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS
|
|
|
|
[delay_ms, MIN_RECONNECTION_DELAY_MS].max / 1000.0
|
|
end
|
|
|
|
# Seconds left before `deadline`, floored just above zero so a budget consumed down to the last instant
|
|
# still asks the adapter for a timeout rather than for "no timeout".
|
|
def remaining_reconnection_budget(deadline)
|
|
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
|
|
end
|
|
|
|
def require_faraday!
|
|
require "faraday"
|
|
rescue LoadError
|
|
raise LoadError, "The 'faraday' gem is required to use the MCP client HTTP transport. " \
|
|
"Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
|
|
"See https://rubygems.org/gems/faraday for more details."
|
|
end
|
|
|
|
# Determines the logical JSON-RPC body of the exchange after the POST stream finished
|
|
# (or was aborted because the response already arrived).
|
|
def resolve_response_body(stream, response, method, params)
|
|
return stream.response if stream.response
|
|
|
|
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
|
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
return if response.status == 202
|
|
|
|
content_type = response.headers["Content-Type"]
|
|
|
|
if content_type&.include?("text/event-stream")
|
|
# Nothing was parsed during streaming: either the adapter does not support `on_data`
|
|
# (the whole body sits in `response.body`) or Faraday did not pass `env` to `on_data`
|
|
# (Faraday < 2.1; the SSE chunks sit in `buffer`). Parse whichever holds the body.
|
|
unless stream.primed?
|
|
stream.ingest_pending!(response.body)
|
|
return stream.response if stream.response
|
|
end
|
|
|
|
# SEP-1699: a graceful close after a priming event (an event with an id) but before
|
|
# the response means "reconnect via GET to resume".
|
|
return await_response_after_disconnect(stream, method, params) if stream.primed?
|
|
|
|
raise RequestHandlerError.new(
|
|
"No valid JSON-RPC response found in SSE stream",
|
|
{ method: method, params: params },
|
|
error_type: :parse_error,
|
|
)
|
|
elsif content_type&.include?("application/json")
|
|
return parse_json_buffer(stream.buffer, method, params) unless stream.buffer.empty?
|
|
|
|
# Adapters without `on_data` support deliver the body via `response.body`,
|
|
# already parsed by the json response middleware.
|
|
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
|
|
else
|
|
raise RequestHandlerError.new(
|
|
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
|
|
{ method: method, params: params },
|
|
error_type: :unsupported_media_type,
|
|
)
|
|
end
|
|
end
|
|
|
|
# Answers a server-to-client request received on an SSE stream by invoking the handler registered via
|
|
# `on_server_request` and POSTing its return value back as a JSON-RPC response; the server ACKs with
|
|
# 202 Accepted. Requests without a registered handler are answered with a "method not found" error.
|
|
# A handler may raise `ServerRequestError` to answer with a specific JSON-RPC error code
|
|
# (e.g. `-1` for a rejected sampling request); any other error is answered with an "internal error".
|
|
# In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler
|
|
# failures into JSON-RPC error responses.
|
|
def dispatch_server_request(message)
|
|
handler = @server_request_handlers[message["method"]]
|
|
response = if handler
|
|
begin
|
|
{
|
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
id: message["id"],
|
|
result: handler.call(message["params"] || {}),
|
|
}
|
|
rescue ServerRequestError => e
|
|
{
|
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
id: message["id"],
|
|
error: { code: e.code, message: e.message },
|
|
}
|
|
rescue StandardError => e
|
|
{
|
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
id: message["id"],
|
|
error: {
|
|
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
message: "Internal error handling #{message["method"]} request: #{e.message}",
|
|
},
|
|
}
|
|
end
|
|
else
|
|
{
|
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
id: message["id"],
|
|
error: {
|
|
code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
|
|
message: "Method not found: #{message["method"]}",
|
|
},
|
|
}
|
|
end
|
|
|
|
send_client_response(response)
|
|
end
|
|
|
|
def send_client_response(response)
|
|
client.post("", response, session_headers)
|
|
end
|
|
|
|
def parse_json_buffer(buffer, method, params)
|
|
return if buffer.empty?
|
|
|
|
JSON.parse(buffer)
|
|
rescue JSON::ParserError => e
|
|
raise RequestHandlerError.new(
|
|
"Failed to parse JSON response: #{e.message}",
|
|
{ method: method, params: params },
|
|
error_type: :parse_error,
|
|
)
|
|
end
|
|
|
|
# SEP-1699 resumability: the server closed the SSE stream after a priming event
|
|
# without delivering the response. Treat the graceful close like a network failure:
|
|
# wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
|
|
# a GET carrying `Last-Event-ID` so the server can replay the pending response on
|
|
# the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport`
|
|
# reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap).
|
|
#
|
|
# This runs on the caller's thread, so the attempts are bounded by `max_reconnection_wait` as well as
|
|
# by their count: the deadline gates each wait, and what is left of it becomes the read timeout of
|
|
# the resumed stream. A delay that would run past the deadline is never shortened: the client stops
|
|
# reconnecting instead, so the server's `retry:` is honored in full or not acted on at all.
|
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
|
|
def await_response_after_disconnect(stream, method, params)
|
|
stream.abortable = true
|
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @max_reconnection_wait
|
|
gave_up_waiting = false
|
|
|
|
MAX_RECONNECTION_ATTEMPTS.times do
|
|
delay = reconnection_delay_seconds(stream)
|
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay > deadline
|
|
gave_up_waiting = true
|
|
break
|
|
end
|
|
|
|
sleep(delay)
|
|
stream.reset_parser!
|
|
|
|
# Bound the resumed stream's idle time by what is left of the budget, rather than leaving it to
|
|
# whatever the Faraday adapter defaults to. `listen_for_server_requests` guards its own GET the same way;
|
|
# without this, a caller-supplied adapter with no default read timeout would let a server hold
|
|
# the connection open past the budget by simply sending nothing.
|
|
read_timeout = remaining_reconnection_budget(deadline)
|
|
|
|
reconnect_response = begin
|
|
client.get("") do |req|
|
|
req.headers.update(session_headers)
|
|
req.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
req.options.read_timeout = read_timeout
|
|
req.options.on_data = stream.on_data
|
|
end
|
|
rescue StreamAbort
|
|
# The awaited response arrived on the reconnected stream.
|
|
nil
|
|
end
|
|
|
|
# Same fallback as `resolve_response_body` for adapters that do not stream via `on_data`.
|
|
if reconnect_response && stream.response.nil?
|
|
stream.ingest_pending!(reconnect_response.body)
|
|
end
|
|
|
|
return stream.response if stream.response
|
|
end
|
|
|
|
reason = if gave_up_waiting
|
|
"the reconnection delay it asked for would exceed the #{@max_reconnection_wait} second reconnection budget"
|
|
else
|
|
"#{MAX_RECONNECTION_ATTEMPTS} reconnection attempts"
|
|
end
|
|
|
|
raise RequestHandlerError.new(
|
|
"Server closed the SSE stream without a response for #{method} after #{reason}",
|
|
{ method: method, params: params },
|
|
error_type: :internal_error,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|