ruby-mcp-client 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -1,17 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'zlib'
|
|
3
6
|
require_relative 'json_rpc_common'
|
|
7
|
+
require_relative 'called_tool_definition'
|
|
4
8
|
require_relative 'auth/oauth_provider'
|
|
9
|
+
require_relative 'http_transport_base/sse_event_scanner'
|
|
10
|
+
require_relative 'http_transport_base/stream_capture'
|
|
11
|
+
require_relative 'http_transport_base/era_detection'
|
|
12
|
+
require_relative 'http_transport_base/listen_stream'
|
|
13
|
+
require_relative 'http_transport_base/cache_support'
|
|
14
|
+
require_relative 'http_transport_base/tool_listing'
|
|
15
|
+
require_relative 'http_transport_base/session_recovery'
|
|
16
|
+
|
|
17
|
+
require_relative 'http_transport_base/param_headers'
|
|
18
|
+
require_relative 'http_transport_base/stream_recovery'
|
|
19
|
+
require_relative 'http_transport_base/request_recovery'
|
|
5
20
|
|
|
6
21
|
module MCPClient
|
|
7
22
|
# Base module for HTTP-based JSON-RPC transports
|
|
8
23
|
# Contains common functionality shared between HTTP and Streamable HTTP transports
|
|
9
24
|
module HttpTransportBase
|
|
10
25
|
include JsonRpcCommon
|
|
26
|
+
include StreamCapture
|
|
27
|
+
include EraDetection
|
|
28
|
+
include CalledToolDefinition
|
|
29
|
+
include ParamHeaders
|
|
30
|
+
include StreamRecovery
|
|
31
|
+
include RequestRecovery
|
|
32
|
+
include ListenStream
|
|
33
|
+
include CacheSupport
|
|
34
|
+
include ToolListing
|
|
35
|
+
include SessionRecovery
|
|
11
36
|
|
|
12
37
|
# Lightweight response wrapper for Faraday exception payloads (Hashes),
|
|
13
38
|
# so the exception path and the default path share one challenge pipeline.
|
|
14
|
-
|
|
39
|
+
# `context` carries the per-request capture state (see ResponseBodyCapture)
|
|
40
|
+
# for a response assembled from a captured body, so the parser can tell
|
|
41
|
+
# which events were already dispatched while the body was arriving.
|
|
42
|
+
NormalizedResponse = Struct.new(:status, :headers, :body, :context)
|
|
15
43
|
|
|
16
44
|
# One auth-param (name = token / quoted-string) as it appears in a
|
|
17
45
|
# WWW-Authenticate challenge (RFC 7235 §2.1, optional whitespace around '=').
|
|
@@ -22,6 +50,29 @@ module MCPClient
|
|
|
22
50
|
# values are consumed by the quoted-string branch, not treated as boundaries.
|
|
23
51
|
AUTH_PARAMS_RUN = /\A(?:[\s,]*#{AUTH_PARAM})*/
|
|
24
52
|
|
|
53
|
+
# Socket-level failures that can only occur once the exchange was under
|
|
54
|
+
# way: the peer reset or closed the connection, the response head was
|
|
55
|
+
# truncated, or the encoded body stopped short. Failures proving the
|
|
56
|
+
# request never reached the server (connection refused, DNS failure,
|
|
57
|
+
# unreachable network) are deliberately absent — there is nothing in
|
|
58
|
+
# flight to replace.
|
|
59
|
+
#
|
|
60
|
+
# IOError covers EOFError and Net::HTTP's own "closed stream"; Zlib::Error
|
|
61
|
+
# covers a gzip body (Streamable HTTP always offers gzip) that stops
|
|
62
|
+
# before its footer; OpenSSL::SSL::SSLError covers an HTTPS body whose TLS
|
|
63
|
+
# session dies mid-read, which is what production Streamable HTTP actually
|
|
64
|
+
# raises — see tls_handshake_failure? for the one OpenSSL case that means
|
|
65
|
+
# the exchange never started.
|
|
66
|
+
INTERRUPTED_EXCHANGE_ERRORS = [
|
|
67
|
+
IOError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
|
|
68
|
+
Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error, OpenSSL::SSL::SSLError
|
|
69
|
+
].freeze
|
|
70
|
+
|
|
71
|
+
# Faraday exception classes that can carry a broken response stream. TLS
|
|
72
|
+
# failures are a sibling of ConnectionFailed, not a subclass, so both must
|
|
73
|
+
# be named for an HTTPS stream to reach the re-issue path at all.
|
|
74
|
+
INTERRUPTED_EXCHANGE_FARADAY_ERRORS = [Faraday::ConnectionFailed, Faraday::SSLError].freeze
|
|
75
|
+
|
|
25
76
|
# Generic JSON-RPC request: send method with params and return result
|
|
26
77
|
# @param method [String] JSON-RPC method name
|
|
27
78
|
# @param params [Hash] parameters for the request
|
|
@@ -31,30 +82,76 @@ module MCPClient
|
|
|
31
82
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
32
83
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
33
84
|
def rpc_request(method, params = {}, timeout: nil)
|
|
85
|
+
freshly_probed = !@mutex.synchronize { @connection_established }
|
|
34
86
|
ensure_connected
|
|
87
|
+
if method == 'ping' && modern?
|
|
88
|
+
# `ping` was removed in MCP 2026-07-28; the mandatory server/discover
|
|
89
|
+
# request is the modern heartbeat, and the probe that just established
|
|
90
|
+
# the connection already was one.
|
|
91
|
+
return @last_discover_result if freshly_probed && @last_discover_result
|
|
35
92
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
method = 'server/discover'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
header_refresh_done = false
|
|
97
|
+
# The multi round-trip resolver sits outside the per-attempt recovery,
|
|
98
|
+
# so a retry carrying inputResponses/requestState keeps them through
|
|
99
|
+
# version renegotiation, the HeaderMismatch refresh and a re-issued
|
|
100
|
+
# stream. Each attempt is a request of its own, with its own id and its
|
|
101
|
+
# own budget; the deadline lives in attempt_request.
|
|
102
|
+
result = resolve_input_round_trips(method, params, timeout) do |attempt_params|
|
|
103
|
+
attempt_request(method, attempt_params, timeout, header_refresh_done) { header_refresh_done = true }
|
|
47
104
|
end
|
|
105
|
+
# Every server/discover answer is validated and applied: a later
|
|
106
|
+
# heartbeat may advertise new versions or capabilities.
|
|
107
|
+
result = apply_discover_result(result) if method == 'server/discover'
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# One request/response exchange with its own JSON-RPC id.
|
|
112
|
+
# @param method [String] JSON-RPC method name
|
|
113
|
+
# @param params [Hash] parameters for the request
|
|
114
|
+
# @param timeout [Numeric, nil] per-request timeout override
|
|
115
|
+
# @param deadline [Float, nil] monotonic instant this exchange and the one
|
|
116
|
+
# replacement the re-issue rule allows must finish by
|
|
117
|
+
# @return [Object] result from the JSON-RPC response
|
|
118
|
+
def send_request_and_parse(method, params, timeout, deadline = nil)
|
|
119
|
+
request_id = @mutex.synchronize { @request_id += 1 }
|
|
120
|
+
request = build_jsonrpc_request(method, params, request_id)
|
|
121
|
+
# Computed before sending so a value that cannot be mirrored fails the
|
|
122
|
+
# call locally (ValidationError) rather than mid-request.
|
|
123
|
+
param_headers = modern? ? mcp_param_headers(request) : {}
|
|
124
|
+
send_jsonrpc_request(request, timeout: timeout, deadline: deadline, extra_headers: param_headers)
|
|
125
|
+
rescue MCPClient::Errors::RequestTimeoutError
|
|
126
|
+
# MCP lifecycle: on timeout the sender SHOULD cancel the abandoned
|
|
127
|
+
# request. On modern Streamable HTTP closing the response stream IS the
|
|
128
|
+
# cancellation signal and no notifications/cancelled is expected; legacy
|
|
129
|
+
# servers still get the notification.
|
|
130
|
+
send_cancellation_notification(request_id) if !modern? && cancellable_request?(method, params)
|
|
131
|
+
raise
|
|
48
132
|
end
|
|
49
133
|
|
|
50
134
|
# Best-effort notifications/cancelled for a request the client stopped
|
|
51
135
|
# waiting on. Failures are swallowed.
|
|
136
|
+
#
|
|
137
|
+
# It is sent for the abandoned request, on that request's own thread and
|
|
138
|
+
# after it, and it brings nothing back to cache: the credentials it
|
|
139
|
+
# carries are whatever the host holds by now -- a rotation, a refresh --
|
|
140
|
+
# and they must not stand in for the ones the abandoned request went out
|
|
141
|
+
# with, which are what its failure is judged by (MCP 2026-07-28 caching,
|
|
142
|
+
# cacheScope "private": a stale copy may be served only to the context
|
|
143
|
+
# the failed request itself carried).
|
|
52
144
|
# @param request_id [Integer] id of the abandoned request
|
|
53
145
|
# @return [void]
|
|
54
146
|
def send_cancellation_notification(request_id)
|
|
55
147
|
notif = build_jsonrpc_notification('notifications/cancelled',
|
|
56
148
|
{ 'requestId' => request_id, 'reason' => 'Request timed out' })
|
|
57
|
-
|
|
149
|
+
abandoned = recorded_request_authorization
|
|
150
|
+
begin
|
|
151
|
+
send_http_request(notif)
|
|
152
|
+
ensure
|
|
153
|
+
restore_request_authorization(abandoned)
|
|
154
|
+
end
|
|
58
155
|
rescue StandardError => e
|
|
59
156
|
@logger.debug("Failed to send cancellation notification: #{e.message}")
|
|
60
157
|
end
|
|
@@ -65,6 +162,10 @@ module MCPClient
|
|
|
65
162
|
# @return [void]
|
|
66
163
|
def rpc_notify(method, params = {})
|
|
67
164
|
ensure_connected
|
|
165
|
+
if suppressed_modern_notification?(method)
|
|
166
|
+
@logger.debug("Not sending #{method}: removed in MCP #{protocol_version}")
|
|
167
|
+
return
|
|
168
|
+
end
|
|
68
169
|
|
|
69
170
|
notif = build_jsonrpc_notification(method, params)
|
|
70
171
|
|
|
@@ -80,8 +181,22 @@ module MCPClient
|
|
|
80
181
|
# @return [Boolean] true if termination was successful
|
|
81
182
|
# @raise [MCPClient::Errors::ConnectionError] if termination fails
|
|
82
183
|
def terminate_session
|
|
184
|
+
# MCP 2026-07-28 removed the session layer: a modern connection has no
|
|
185
|
+
# session to terminate and MUST NOT send the DELETE, whatever a
|
|
186
|
+
# non-conforming server (or a caller) put in @session_id.
|
|
187
|
+
if modern?
|
|
188
|
+
@session_id = nil
|
|
189
|
+
return true
|
|
190
|
+
end
|
|
191
|
+
|
|
83
192
|
return true unless @session_id
|
|
84
193
|
|
|
194
|
+
# The session is over from here whatever the DELETE answers (every
|
|
195
|
+
# outcome below clears the id), and it ends without a #cleanup: the
|
|
196
|
+
# epoch moves so nothing keyed by it — the tasks extension's task ids,
|
|
197
|
+
# answered and pending input keys — outlives it into the session the
|
|
198
|
+
# next request establishes, which may reuse those very ids.
|
|
199
|
+
bump_session_epoch
|
|
85
200
|
conn = http_connection
|
|
86
201
|
|
|
87
202
|
begin
|
|
@@ -93,6 +208,7 @@ module MCPClient
|
|
|
93
208
|
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
94
209
|
# MCP: authorization MUST be included in every HTTP request
|
|
95
210
|
@oauth_provider&.apply_authorization(req)
|
|
211
|
+
note_request_authorization(authorization_header_value(req.headers))
|
|
96
212
|
end
|
|
97
213
|
|
|
98
214
|
if response.success?
|
|
@@ -112,29 +228,6 @@ module MCPClient
|
|
|
112
228
|
end
|
|
113
229
|
end
|
|
114
230
|
|
|
115
|
-
# Resend a request against the freshly restarted session — unless doing so
|
|
116
|
-
# could execute a side effect twice.
|
|
117
|
-
#
|
|
118
|
-
# A 404 usually means the server rejected the request outright, but it does
|
|
119
|
-
# not prove that: a session can expire after the tool ran. Automatic
|
|
120
|
-
# session recovery is worth having for idempotent methods, and would
|
|
121
|
-
# otherwise be a hole straight through the no-replay guarantee that
|
|
122
|
-
# with_retry enforces for NON_IDEMPOTENT_METHODS.
|
|
123
|
-
#
|
|
124
|
-
# Raises ConnectionError (which with_retry never retries) so no other path
|
|
125
|
-
# can turn this into a second attempt.
|
|
126
|
-
# @param request [Hash] the JSON-RPC request that hit the expired session
|
|
127
|
-
# @return [Faraday::Response] the response to the resent request
|
|
128
|
-
# @raise [MCPClient::Errors::ConnectionError] for a non-idempotent method
|
|
129
|
-
def resend_after_session_restart(request)
|
|
130
|
-
method = request['method']
|
|
131
|
-
return send_http_request(request) unless NON_IDEMPOTENT_METHODS.include?(method)
|
|
132
|
-
|
|
133
|
-
raise MCPClient::Errors::ConnectionError,
|
|
134
|
-
"Session expired during #{method}; a new session was started but the request was NOT resent " \
|
|
135
|
-
'because it may already have executed. Retry it explicitly if that is safe.'
|
|
136
|
-
end
|
|
137
|
-
|
|
138
231
|
# Validate session ID format
|
|
139
232
|
# Per MCP 2025-11-25, the server-assigned session ID "MUST only contain
|
|
140
233
|
# visible ASCII characters (ranging from 0x21 to 0x7E)" — e.g. a UUID, a
|
|
@@ -174,8 +267,238 @@ module MCPClient
|
|
|
174
267
|
false
|
|
175
268
|
end
|
|
176
269
|
|
|
270
|
+
# How the server's protocol era is established (MCP 2026-07-28 Streamable
|
|
271
|
+
# HTTP "Backward Compatibility"): :auto attempts a modern request first
|
|
272
|
+
# and falls back to the initialize handshake on a legacy rejection,
|
|
273
|
+
# :modern never falls back, :legacy never probes.
|
|
274
|
+
PROTOCOL_MODES = %i[auto modern legacy].freeze
|
|
275
|
+
|
|
276
|
+
# @return [Symbol] the configured protocol mode (:auto, :modern or :legacy)
|
|
277
|
+
attr_reader :protocol_mode
|
|
278
|
+
|
|
279
|
+
# @return [Numeric] seconds allowed for the server/discover probe
|
|
280
|
+
attr_reader :discover_timeout
|
|
281
|
+
|
|
177
282
|
private
|
|
178
283
|
|
|
284
|
+
# Whether tearing this connection down ends an MCP session — and with it
|
|
285
|
+
# the namespace a task id and an input request key live in.
|
|
286
|
+
#
|
|
287
|
+
# A legacy transport's session is the one `initialize` opened (named by
|
|
288
|
+
# Mcp-Session-Id when the server assigned one, unnamed otherwise): closing
|
|
289
|
+
# the connection ends it, the next request opens another with a fresh
|
|
290
|
+
# handshake, and the server may hand the ids of the old one out again — so
|
|
291
|
+
# the epoch must move. MCP 2026-07-28 removed the handshake and the
|
|
292
|
+
# session with it: a modern transport is sessionless (it never sends an
|
|
293
|
+
# Mcp-Session-Id — this client only ever captures one from an initialize
|
|
294
|
+
# response, which a modern server does not send), a task lives for its own
|
|
295
|
+
# ttlMs in the server's own id namespace, and a reconnect resumes exactly
|
|
296
|
+
# what was there before. Ending the connection there is not a task
|
|
297
|
+
# namespace reset: throwing away the answered keys and the undelivered
|
|
298
|
+
# tasks/update of a task that is still alive would ask the host to answer
|
|
299
|
+
# an input request twice and drop an answer the server never confirmed,
|
|
300
|
+
# and it would make the task's own handles refuse tasks/get, tasks/update
|
|
301
|
+
# and tasks/cancel for a session that never existed. A modern server that
|
|
302
|
+
# does hand a task id out again is handled where it happens, by the task
|
|
303
|
+
# registry's per-creation lifetime.
|
|
304
|
+
# A 2025-11-25 session is the one the server assigned with an
|
|
305
|
+
# Mcp-Session-Id, and assigning one is optional ("Session Management"): a
|
|
306
|
+
# legacy server that never sent the header kept no session state for this
|
|
307
|
+
# client, so there is nothing for a cleanup to end there either, and its
|
|
308
|
+
# durable tasks — and the handles naming them — outlive the connection
|
|
309
|
+
# exactly as a modern server's do. What decides is therefore the session
|
|
310
|
+
# id itself, not the era; the era only decides while it is still unknown,
|
|
311
|
+
# when a session may yet be assigned and the connection counts as
|
|
312
|
+
# session-bearing until the probe settles.
|
|
313
|
+
# @return [Boolean]
|
|
314
|
+
def session_bearing_connection?
|
|
315
|
+
!@session_id.nil? || protocol_era.nil?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Whether #cleanup ends a session. A transport nothing was ever sent
|
|
319
|
+
# through has none to end: a first connect failing on its way, or a
|
|
320
|
+
# transport a host restored a task handle into before anything was sent
|
|
321
|
+
# — and until the era is known the connection counts as session-bearing,
|
|
322
|
+
# so without this the epoch would move on that first connect and the
|
|
323
|
+
# restored handle be refused for a session that never existed (and, on a
|
|
324
|
+
# sessionless 2026-07-28 server, never will).
|
|
325
|
+
# @return [Boolean]
|
|
326
|
+
def ending_session?
|
|
327
|
+
session_bearing_connection? && (@connection_established || @initialized)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Store the session id a handshake established. A handshake that lands a
|
|
331
|
+
# different id on a live session replaced it — the 404 recovery is only
|
|
332
|
+
# one way there, and none of them goes through #cleanup — so the epoch
|
|
333
|
+
# moves with it: task ids and input keys are per session and reusable,
|
|
334
|
+
# and nothing the previous one recorded may colour the next.
|
|
335
|
+
# @param session_id [String] the validated id the server assigned
|
|
336
|
+
# @return [void]
|
|
337
|
+
def capture_session_id(session_id)
|
|
338
|
+
bump_session_epoch if @session_id && @session_id != session_id
|
|
339
|
+
@session_id = session_id
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Validate and store the protocol-mode options shared by the HTTP transports.
|
|
343
|
+
# @param protocol [Symbol] :auto, :modern or :legacy
|
|
344
|
+
# @param discover_timeout [Numeric, nil] probe timeout (default: read_timeout)
|
|
345
|
+
# @return [void]
|
|
346
|
+
# @raise [ArgumentError] on an unknown mode
|
|
347
|
+
def configure_protocol_mode(protocol, discover_timeout)
|
|
348
|
+
unless PROTOCOL_MODES.include?(protocol)
|
|
349
|
+
raise ArgumentError, "protocol must be one of #{PROTOCOL_MODES.inspect}, got #{protocol.inspect}"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
@protocol_mode = protocol
|
|
353
|
+
@discover_timeout = discover_timeout || @read_timeout
|
|
354
|
+
@confirmed_era = nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Establish the server's protocol era: probe with a modern request unless
|
|
358
|
+
# configured legacy-only (or the server was already found to be legacy),
|
|
359
|
+
# and fall back to the initialize handshake when the probe shows a legacy
|
|
360
|
+
# server. The era is cached for the life of this transport.
|
|
361
|
+
# @return [void]
|
|
362
|
+
# @raise [MCPClient::Errors::ConnectionError] if no era can be established
|
|
363
|
+
def negotiate_protocol
|
|
364
|
+
return perform_initialize if @protocol_mode == :legacy || @confirmed_era == :legacy
|
|
365
|
+
return if probe_modern_server
|
|
366
|
+
|
|
367
|
+
perform_initialize
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Send the modern server/discover probe. Outcomes (MCP 2026-07-28
|
|
371
|
+
# Streamable HTTP "Backward Compatibility"): a DiscoverResult is modern;
|
|
372
|
+
# a recognized modern JSON-RPC error in a 400 body is modern too
|
|
373
|
+
# (UnsupportedProtocolVersion is retried with an advertised version,
|
|
374
|
+
# HeaderMismatch / MissingRequiredClientCapability are surfaced); a 404
|
|
375
|
+
# carrying -32601 is a modern server that violates the "MUST implement
|
|
376
|
+
# server/discover" rule, tolerated with unknown capabilities; any other
|
|
377
|
+
# 4xx, or a 2xx carrying a JSON-RPC error (reserved code or not), is a
|
|
378
|
+
# legacy server.
|
|
379
|
+
# Only a genuine rejection settles the era: authorization failures, 5xx,
|
|
380
|
+
# timeouts and a broken response stream propagate untouched, because an
|
|
381
|
+
# exchange that never completed says nothing about the era. Both verdicts
|
|
382
|
+
# are cached, so a confirmed modern server never gets initialize later.
|
|
383
|
+
# @return [Boolean] true when the server is modern and a version was selected
|
|
384
|
+
# @raise [MCPClient::Errors::ConnectionError] if the server is modern but the
|
|
385
|
+
# probe failed, or legacy while protocol: :modern is configured
|
|
386
|
+
def probe_modern_server
|
|
387
|
+
@protocol_version = MCPClient::LATEST_PROTOCOL_VERSION
|
|
388
|
+
# The version is a proposal until the server answers: a 2025-11-25
|
|
389
|
+
# server may send a request on the probe's own response stream and wait
|
|
390
|
+
# for the answer, and it gets one while the era is unknown (a modern
|
|
391
|
+
# server never sends one, so answering costs nothing).
|
|
392
|
+
begin_era_probe
|
|
393
|
+
# A server already found to be modern never gets the initialize
|
|
394
|
+
# fallback again, however a later probe fails — the mirror image of the
|
|
395
|
+
# cached legacy verdict.
|
|
396
|
+
modern_confirmed = @confirmed_era == :modern
|
|
397
|
+
begin
|
|
398
|
+
perform_discover
|
|
399
|
+
rescue MCPClient::Errors::UnsupportedProtocolVersionError => e
|
|
400
|
+
# Only a well-formed rejection (data.supported present) in a 400
|
|
401
|
+
# body is a recognized modern error; a bare -32022, or the same body
|
|
402
|
+
# under any other status, is a legacy answer.
|
|
403
|
+
raise unless modern_probe_rejection?(e)
|
|
404
|
+
|
|
405
|
+
# A well-formed rejection settles the era: whatever the retried probe
|
|
406
|
+
# does next, this server is modern and never gets initialize.
|
|
407
|
+
modern_confirmed = true
|
|
408
|
+
@confirmed_era = :modern
|
|
409
|
+
retry_discover_with_advertised_version(e)
|
|
410
|
+
end
|
|
411
|
+
@confirmed_era = :modern
|
|
412
|
+
true
|
|
413
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
414
|
+
# A DiscoverResult (or advertised list) with no mutual version, or an
|
|
415
|
+
# authorization failure: nothing was negotiated. The first of those
|
|
416
|
+
# still settles the era — the server answered server/discover as a
|
|
417
|
+
# modern server — so cache it, exactly as a probe failure that reaches
|
|
418
|
+
# modern_probe_failure does. An authorization failure settles nothing.
|
|
419
|
+
@protocol_version = nil
|
|
420
|
+
@confirmed_era = :modern if e.is_a?(MCPClient::Errors::ModernServerError)
|
|
421
|
+
raise
|
|
422
|
+
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError => e
|
|
423
|
+
modern_despite_probe_failure?(e, modern_confirmed)
|
|
424
|
+
ensure
|
|
425
|
+
settle_era_probe
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Send server/discover and apply the DiscoverResult.
|
|
429
|
+
# @return [Hash] the DiscoverResult
|
|
430
|
+
def perform_discover
|
|
431
|
+
# MCP 2026-07-28 cancellation/timeouts: implementations SHOULD enforce a
|
|
432
|
+
# maximum timeout regardless of progress. Faraday's socket timeout only
|
|
433
|
+
# bounds the gap between bytes, so a probe answered with an endless
|
|
434
|
+
# trickle of SSE keep-alives would never time out and every caller
|
|
435
|
+
# waiting on the connection monitor would block with it. One deadline
|
|
436
|
+
# covers the probe and its one re-issue.
|
|
437
|
+
deadline = @discover_timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @discover_timeout)
|
|
438
|
+
result = begin
|
|
439
|
+
send_discover_request(deadline)
|
|
440
|
+
rescue MCPClient::Errors::ResponseStreamClosedError => e
|
|
441
|
+
# The probe goes through the same recovery as every other modern
|
|
442
|
+
# request: a broken response stream loses it and it MUST be re-issued
|
|
443
|
+
# with a new request id. Without this a probe whose stream dies would
|
|
444
|
+
# surface as a plain transport failure and be mistaken for a legacy
|
|
445
|
+
# rejection, permanently misclassifying a modern server.
|
|
446
|
+
@logger.warn("#{e.message}; re-issuing server/discover as a new request")
|
|
447
|
+
send_discover_request(deadline)
|
|
448
|
+
end
|
|
449
|
+
# The input_required rejection comes first: an InputRequiredResult need
|
|
450
|
+
# only carry `requestState`, so an unfinished discover answer does not
|
|
451
|
+
# have to look like a DiscoverResult at all, and testing the shape first
|
|
452
|
+
# would classify it as a permissive legacy endpoint. Any other 2xx that
|
|
453
|
+
# is not a DiscoverResult is a legacy answer the probe may fall back on
|
|
454
|
+
# — unless it carries a resultType, which only a modern server writes
|
|
455
|
+
# (see #invalid_discover_answer).
|
|
456
|
+
reject_input_required_discover!(result)
|
|
457
|
+
reject_task_result_discover!(result)
|
|
458
|
+
raise invalid_discover_answer(result, 'answered without a DiscoverResult') unless discover_result?(result)
|
|
459
|
+
|
|
460
|
+
apply_discover_result(result)
|
|
461
|
+
rescue MCPClient::Errors::InvalidResultError => e
|
|
462
|
+
# The error carries the result it refused. One that named a resultType
|
|
463
|
+
# this client does not recognize could only have been written by a
|
|
464
|
+
# modern server; one that is not an object at all (a permissive legacy
|
|
465
|
+
# endpoint answering any method) says nothing modern.
|
|
466
|
+
raise invalid_discover_answer(e.data, "answered without a DiscoverResult (#{e.message})")
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# What an unusable probe answer says about the server's era.
|
|
470
|
+
#
|
|
471
|
+
# `resultType` was introduced in MCP 2026-07-28, so a result carrying one
|
|
472
|
+
# could only have been written by a modern server, however little else of
|
|
473
|
+
# it this client can use: falling back would open the handshake that
|
|
474
|
+
# revision removed, on a server that has already answered as modern. The
|
|
475
|
+
# ModernServerError settles the era for good (see #probe_modern_server);
|
|
476
|
+
# anything else stays a plain ServerError the probe may read as legacy.
|
|
477
|
+
# @param result [Object] the probe's result
|
|
478
|
+
# @param message [String] what was wrong with it
|
|
479
|
+
# @return [MCPClient::Errors::MCPError] the failure to raise
|
|
480
|
+
def invalid_discover_answer(result, message)
|
|
481
|
+
modern = result.is_a?(Hash) && (result.key?('resultType') || result.key?(:resultType))
|
|
482
|
+
return MCPClient::Errors::ServerError.new("server/discover was #{message}") unless modern
|
|
483
|
+
|
|
484
|
+
MCPClient::Errors::ModernServerError.new("Server is modern but incompatible: server/discover was #{message}")
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# One server/discover exchange with its own JSON-RPC id.
|
|
488
|
+
# @param deadline [Float, nil] monotonic instant the whole probe must finish by
|
|
489
|
+
# @return [Object] the JSON-RPC result
|
|
490
|
+
def send_discover_request(deadline = nil)
|
|
491
|
+
request_id = @mutex.synchronize { @request_id += 1 }
|
|
492
|
+
request = build_jsonrpc_request('server/discover', {}, request_id)
|
|
493
|
+
send_jsonrpc_request(request, timeout: @discover_timeout, deadline: deadline)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# @param result [Object] a JSON-RPC result
|
|
497
|
+
# @return [Boolean] whether it has the DiscoverResult shape
|
|
498
|
+
def discover_result?(result)
|
|
499
|
+
result.is_a?(Hash) && result['supportedVersions'].is_a?(Array)
|
|
500
|
+
end
|
|
501
|
+
|
|
179
502
|
# Perform JSON-RPC initialize handshake with the MCP server
|
|
180
503
|
# @return [void]
|
|
181
504
|
# @raise [MCPClient::Errors::ConnectionError] if initialization fails
|
|
@@ -184,7 +507,17 @@ module MCPClient
|
|
|
184
507
|
json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
|
|
185
508
|
@logger.debug("Performing initialize RPC: #{json_rpc_request}")
|
|
186
509
|
|
|
187
|
-
|
|
510
|
+
begin
|
|
511
|
+
result = send_jsonrpc_request(json_rpc_request)
|
|
512
|
+
rescue MCPClient::Errors::UnsupportedProtocolVersionError => e
|
|
513
|
+
# A modern-only server SHOULD name the versions it supports when
|
|
514
|
+
# rejecting initialize (basic/versioning), and this message may be
|
|
515
|
+
# the only diagnostic a legacy configuration can surface. The list
|
|
516
|
+
# travels in `data`, not in the peer's prose, so spell it out here
|
|
517
|
+
# (as stdio does) rather than letting connect's generic wrap drop it.
|
|
518
|
+
raise MCPClient::Errors::ConnectionError,
|
|
519
|
+
"Initialize failed: #{e.message} (server supports: #{e.supported.join(', ')})"
|
|
520
|
+
end
|
|
188
521
|
unless result.is_a?(Hash)
|
|
189
522
|
raise MCPClient::Errors::ConnectionError,
|
|
190
523
|
"Server returned invalid initialize result: #{result.inspect}"
|
|
@@ -203,13 +536,21 @@ module MCPClient
|
|
|
203
536
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
204
537
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
205
538
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
206
|
-
def send_jsonrpc_request(request, timeout: nil)
|
|
539
|
+
def send_jsonrpc_request(request, timeout: nil, deadline: nil, extra_headers: {})
|
|
540
|
+
# As late as a request pinned to a session can be held back: every
|
|
541
|
+
# reconnect on the way here (ensure_connected, a retry after the
|
|
542
|
+
# connection dropped) has happened by now.
|
|
543
|
+
check_session_pin!
|
|
207
544
|
@logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
|
|
208
545
|
|
|
209
546
|
begin
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
547
|
+
exchange_jsonrpc(request, timeout: timeout, deadline: deadline, extra_headers: extra_headers)
|
|
548
|
+
# A pre-write refusal keeps its type: the late pin check inside
|
|
549
|
+
# #send_http_request turns a request down (or the caller's own guard
|
|
550
|
+
# does, see {MCPClient::SessionPin#guarded_writes}) and nothing was
|
|
551
|
+
# written, which is not an error of executing the request.
|
|
552
|
+
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
|
|
553
|
+
MCPClient::Errors::ServerError, MCPClient::Errors::TaskReplacedError
|
|
213
554
|
raise
|
|
214
555
|
rescue JSON::ParserError => e
|
|
215
556
|
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
@@ -221,116 +562,292 @@ module MCPClient
|
|
|
221
562
|
end
|
|
222
563
|
end
|
|
223
564
|
|
|
565
|
+
# What an answered POST means: the session it was sent under may have
|
|
566
|
+
# expired, its body may have been cut short, it may carry an error, or it
|
|
567
|
+
# settles the request.
|
|
568
|
+
# @param response [Faraday::Response] the answer as it arrived
|
|
569
|
+
# @param request [Hash] the JSON-RPC message that was sent
|
|
570
|
+
# @param sent_session_id [String, nil] the session id the request carried
|
|
571
|
+
# @param capture [Hash] the capture state of this exchange
|
|
572
|
+
# @return [Faraday::Response] the response the caller settles on
|
|
573
|
+
def settle_http_response(response, request, sent_session_id, capture)
|
|
574
|
+
# MCP 2026-07-28 caching: the result is bound to the Authorization
|
|
575
|
+
# the request went out with, middleware included.
|
|
576
|
+
note_sent_authorization(response)
|
|
577
|
+
|
|
578
|
+
return restart_session_and_resend(request, sent_session_id) if expired_session?(response, sent_session_id)
|
|
579
|
+
# A body that stopped short of its Content-Length was cut on the way,
|
|
580
|
+
# exactly like a socket that died mid-body — it just did not raise.
|
|
581
|
+
return truncated_body_outcome(request, capture) if capture[:mcp_short_body]
|
|
582
|
+
|
|
583
|
+
handle_http_error_response(response) unless response.success?
|
|
584
|
+
handle_successful_response(response, request)
|
|
585
|
+
|
|
586
|
+
log_response(response)
|
|
587
|
+
response
|
|
588
|
+
end
|
|
589
|
+
|
|
224
590
|
# Send an HTTP request to the server
|
|
225
591
|
# @param request [Hash] the JSON-RPC request
|
|
592
|
+
# @param timeout [Numeric, nil] per-request timeout override
|
|
593
|
+
# @param deadline [Float, nil] monotonic instant the exchange must finish by
|
|
594
|
+
# @param extra_headers [Hash] headers for this request only
|
|
226
595
|
# @return [Faraday::Response] the HTTP response
|
|
227
596
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
228
|
-
def send_http_request(request, timeout: nil)
|
|
597
|
+
def send_http_request(request, timeout: nil, deadline: nil, extra_headers: {})
|
|
229
598
|
conn = http_connection
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
|
|
599
|
+
# The session id this request goes out with: a later 404 is attributed
|
|
600
|
+
# to it, not to a fresh session another caller established meanwhile.
|
|
601
|
+
# The pin is re-checked in the same critical section: a cleanup or a
|
|
602
|
+
# reconnect completing between the check in #send_jsonrpc_request and
|
|
603
|
+
# this capture would otherwise select the session that replaced the
|
|
604
|
+
# one the request belongs to (the epoch is bumped before the session
|
|
605
|
+
# is torn down and re-established under this monitor).
|
|
606
|
+
sent_session_id = @mutex.synchronize do
|
|
607
|
+
check_session_pin!
|
|
608
|
+
@session_id
|
|
609
|
+
end
|
|
610
|
+
timeout, deadline = request_bounds(timeout, deadline)
|
|
611
|
+
# ResponseBodyCapture fills this in as the body arrives, so the bytes
|
|
612
|
+
# that made it are still here when Faraday raises instead of returning.
|
|
613
|
+
capture = { mcp_body_buffer: +'', mcp_deadline: deadline,
|
|
614
|
+
mcp_stream_listener: response_stream_listener(request), mcp_inflate_limit: inflate_limit,
|
|
615
|
+
mcp_response_id: (request['id'] if request.is_a?(Hash)) }
|
|
236
616
|
|
|
237
617
|
begin
|
|
238
|
-
response =
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
# configurable on a per-request basis)
|
|
242
|
-
req.options.timeout = timeout if timeout
|
|
243
|
-
# The wire header must match the captured id exactly: a restart
|
|
244
|
-
# completing between capture and header attachment would otherwise
|
|
245
|
-
# attach a different (or fresh) session than the one attributed to
|
|
246
|
-
# this request at 404-handling time.
|
|
247
|
-
if req.headers.key?('Mcp-Session-Id')
|
|
248
|
-
if sent_session_id
|
|
249
|
-
req.headers['Mcp-Session-Id'] = sent_session_id
|
|
250
|
-
else
|
|
251
|
-
req.headers.delete('Mcp-Session-Id')
|
|
252
|
-
end
|
|
618
|
+
response = with_request_watchdog(deadline) do
|
|
619
|
+
post_json_rpc(conn) do |req|
|
|
620
|
+
prepare_http_request(req, request, sent_session_id, timeout, capture, extra_headers)
|
|
253
621
|
end
|
|
254
|
-
req.body = request.to_json
|
|
255
622
|
end
|
|
256
|
-
|
|
257
|
-
# MCP 2025-11-25 session management: HTTP 404 for a request carrying
|
|
258
|
-
# Mcp-Session-Id means the session expired — the client MUST start a
|
|
259
|
-
# new session with a fresh InitializeRequest (without a session ID).
|
|
260
|
-
if response.status == 404 && session_restart_applicable?(sent_session_id)
|
|
261
|
-
return restart_session_and_resend(request, sent_session_id)
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
handle_http_error_response(response) unless response.success?
|
|
265
|
-
handle_successful_response(response, request)
|
|
266
|
-
|
|
267
|
-
log_response(response)
|
|
268
|
-
response
|
|
623
|
+
settle_http_response(response, request, sent_session_id, capture)
|
|
269
624
|
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
|
270
625
|
handle_auth_error(e)
|
|
271
626
|
rescue Faraday::ResourceNotFound => e
|
|
272
627
|
# User-configured raise_error middleware surfaces 404 as an exception;
|
|
273
628
|
# apply the same session-expiry recovery as the response path.
|
|
274
|
-
|
|
629
|
+
if expired_session?(normalize_error_response(e.response) || NormalizedResponse.new(404, {}, nil),
|
|
630
|
+
sent_session_id)
|
|
631
|
+
return restart_session_and_resend(request, sent_session_id)
|
|
632
|
+
end
|
|
275
633
|
|
|
276
|
-
raise
|
|
277
|
-
rescue Faraday::
|
|
278
|
-
|
|
634
|
+
raise client_error_from_exception(e, 404)
|
|
635
|
+
rescue Faraday::ClientError => e
|
|
636
|
+
# Other 4xx raised by raise_error middleware: same body inspection as
|
|
637
|
+
# the response path, so a 400 carrying a modern JSON-RPC error still
|
|
638
|
+
# becomes the typed error (never a retryable TransportError).
|
|
639
|
+
status = e.response.is_a?(Hash) ? (e.response[:status] || e.response['status']) : nil
|
|
640
|
+
raise client_error_from_exception(e, status || 400)
|
|
641
|
+
rescue *INTERRUPTED_EXCHANGE_FARADAY_ERRORS => e
|
|
642
|
+
# The body may have been fully delivered before the socket died; if it
|
|
643
|
+
# was, that response settles the request and must not be replaced.
|
|
644
|
+
salvaged = salvaged_response(capture[:mcp_body_buffer], request, e, capture)
|
|
645
|
+
return settled_salvage(salvaged) if salvaged
|
|
646
|
+
|
|
647
|
+
raise connection_failure_error(e, request)
|
|
279
648
|
rescue Faraday::TimeoutError => e
|
|
649
|
+
# A stream that stalled after delivering the whole final event has
|
|
650
|
+
# answered the request; the timeout only tears the idle socket down.
|
|
651
|
+
salvaged = salvaged_response(capture[:mcp_body_buffer], request, e, capture)
|
|
652
|
+
return settled_salvage(salvaged) if salvaged
|
|
653
|
+
|
|
280
654
|
raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
|
|
655
|
+
rescue Faraday::ServerError => e
|
|
656
|
+
# 5xx raised by user-configured raise_error middleware. It must reach
|
|
657
|
+
# callers as the same retryable error the default response path
|
|
658
|
+
# raises, or a 5xx would look like a generic transport failure — and
|
|
659
|
+
# a server/discover probe would read it as a legacy rejection.
|
|
660
|
+
# Ordered after Faraday::TimeoutError, which subclasses ServerError.
|
|
661
|
+
status = e.response.is_a?(Hash) ? (e.response[:status] || e.response['status']) : nil
|
|
662
|
+
raise MCPClient::Errors::TransientServerError, "Server error: HTTP #{status || '5xx'} #{e.message}".strip
|
|
281
663
|
rescue Faraday::Error => e
|
|
282
664
|
raise MCPClient::Errors::TransportError, "HTTP request failed: #{e.message}"
|
|
283
665
|
end
|
|
284
666
|
end
|
|
285
667
|
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
# @param
|
|
290
|
-
# @param
|
|
291
|
-
# @
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
668
|
+
# Fill in one outgoing Faraday POST: headers, capture state, timeout and body.
|
|
669
|
+
# @param req [Faraday::Request] the request being built
|
|
670
|
+
# @param request [Hash] the JSON-RPC message to send
|
|
671
|
+
# @param sent_session_id [String, nil] the session id captured for this request
|
|
672
|
+
# @param timeout [Numeric, nil] per-request timeout override
|
|
673
|
+
# @param capture [Hash] ResponseBodyCapture state for this request
|
|
674
|
+
# @return [void]
|
|
675
|
+
def prepare_http_request(req, request, sent_session_id, timeout, capture, extra_headers = {})
|
|
676
|
+
apply_request_headers(req, request)
|
|
677
|
+
apply_param_headers(req, extra_headers)
|
|
678
|
+
extra_headers.each { |name, value| req.headers[name] = value }
|
|
679
|
+
# The capture hash itself is the request context, not a merged copy:
|
|
680
|
+
# what the capture middleware records as the body arrives (the events
|
|
681
|
+
# already handed to the stream listener) must be on the hash a salvaged
|
|
682
|
+
# response carries, or those events would be delivered a second time.
|
|
683
|
+
req.options.context = capture.replace((req.options.context || {}).merge(capture))
|
|
684
|
+
# Per-request timeout override (MCP lifecycle: timeouts SHOULD be
|
|
685
|
+
# configurable on a per-request basis)
|
|
686
|
+
# The same bound covers connection setup: a server that accepts the
|
|
687
|
+
# socket and stalls the TLS handshake never delivers a byte for the
|
|
688
|
+
# deadline check to see.
|
|
689
|
+
req.options.timeout = req.options.open_timeout = timeout if timeout
|
|
690
|
+
apply_captured_session_id(req, request, sent_session_id)
|
|
691
|
+
req.body = request.to_json
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# @param body [String] a response body
|
|
695
|
+
# @return [Boolean] whether the body is SSE-framed rather than plain JSON
|
|
696
|
+
def sse_framed_body?(body)
|
|
697
|
+
normalize_sse_newlines(body).each_line.any? { |line| line.match?(/\A(?::|(?:data|event|id|retry):)/) }
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# @param body [String] an SSE body that may end mid-event
|
|
701
|
+
# @return [String] the prefix up to and including the last event terminator
|
|
702
|
+
def complete_sse_events(body)
|
|
703
|
+
normalized = normalize_sse_newlines(body)
|
|
704
|
+
index = normalized.rindex("\n\n")
|
|
705
|
+
index ? normalized[0, index + 2] : +''
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Side-effect-free check for this request's answer, so the real parser
|
|
709
|
+
# (which dispatches notifications and tracks event ids) still runs exactly
|
|
710
|
+
# once, on the salvaged response.
|
|
711
|
+
# @param body [String] the complete portion of the body
|
|
712
|
+
# @param sse [Boolean] whether the body is SSE-framed
|
|
713
|
+
# @param request_id [Integer, String] id of the originating request
|
|
714
|
+
# @return [Boolean] whether the body carries a response to this request
|
|
715
|
+
def body_carries_response?(body, sse, request_id)
|
|
716
|
+
payloads = sse ? sse_data_payloads(body) : [body]
|
|
717
|
+
payloads.any? do |payload|
|
|
718
|
+
message = begin
|
|
719
|
+
JSON.parse(payload)
|
|
720
|
+
rescue JSON::ParserError
|
|
721
|
+
nil
|
|
722
|
+
end
|
|
723
|
+
message.is_a?(Hash) && !message.key?('method') &&
|
|
724
|
+
(message['id'] == request_id || message['id'].to_s == request_id.to_s)
|
|
310
725
|
end
|
|
311
726
|
end
|
|
312
727
|
|
|
313
|
-
# Whether a 404
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
|
|
319
|
-
|
|
728
|
+
# Whether a 404 means the session this request went out under has expired.
|
|
729
|
+
#
|
|
730
|
+
# MCP 2025-11-25 session management: "When receiving HTTP 404 in response
|
|
731
|
+
# to a request containing an Mcp-Session-Id, the client MUST start a new
|
|
732
|
+
# session by sending a new InitializeRequest without a session ID." The
|
|
733
|
+
# rule names the status and the session id and takes no exception for what
|
|
734
|
+
# the body carries, so on a session negotiated under that revision the 404
|
|
735
|
+
# is read as the expiry it is — a server on the era this session speaks
|
|
736
|
+
# answers the session, not the request.
|
|
737
|
+
#
|
|
738
|
+
# Off such a session — an era never established, or a modern one whose
|
|
739
|
+
# server assigned a session id 2026-07-28 gives it no reason to assign —
|
|
740
|
+
# a well-formed -32601 IS the answer to this very request (unknown
|
|
741
|
+
# method), and replaying it after a fresh initialize would only ask the
|
|
742
|
+
# unknown method a second time.
|
|
743
|
+
# @param response [#status, #body, nil] the normalized 404 response
|
|
744
|
+
# @param sent_session_id [String, nil] the session id the request carried
|
|
745
|
+
# @return [Boolean]
|
|
746
|
+
def expired_session?(response, sent_session_id)
|
|
747
|
+
return false unless response && response.status == 404
|
|
748
|
+
return false unless session_restart_applicable?(sent_session_id)
|
|
749
|
+
|
|
750
|
+
legacy_session? || !method_not_found_answer?(response)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Whether this transport negotiated a handshake-era revision, which is
|
|
754
|
+
# what makes Mcp-Session-Id — and the session-expiry rule that goes with
|
|
755
|
+
# it — part of the protocol in force.
|
|
756
|
+
# @return [Boolean]
|
|
757
|
+
def legacy_session?
|
|
758
|
+
MCPClient::LEGACY_PROTOCOL_VERSIONS.include?(@protocol_version)
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# Whether a 404 body is a well-formed JSON-RPC -32601 — MCP 2026-07-28's
|
|
762
|
+
# "unknown method" answer to the request itself — rather than a
|
|
763
|
+
# 2025-11-25 session expiry, which answers nothing.
|
|
764
|
+
#
|
|
765
|
+
# Read the way every other HTTP error body is (jsonrpc_error_in_body): a
|
|
766
|
+
# JSON-RPC 2.0 envelope, size-bounded, gunzipped when the response says
|
|
767
|
+
# so. Anything else — an "error" member outside an envelope, an oversized
|
|
768
|
+
# or undecodable body — is not an answer to this request and leaves the
|
|
769
|
+
# 404 meaning what 2025-11-25 says it means.
|
|
770
|
+
# @param response [#body, nil] the 404 response, if its body is readable
|
|
771
|
+
# @return [Boolean]
|
|
772
|
+
def method_not_found_answer?(response)
|
|
773
|
+
return false unless response
|
|
774
|
+
|
|
775
|
+
error = jsonrpc_error_in_body(response)
|
|
776
|
+
return false unless error.is_a?(Hash)
|
|
777
|
+
|
|
778
|
+
(error['code'] || error[:code]) == MCPClient::Errors::Codes::METHOD_NOT_FOUND &&
|
|
779
|
+
(error['message'] || error[:message]).is_a?(String)
|
|
780
|
+
end
|
|
320
781
|
|
|
321
|
-
|
|
782
|
+
# Put the captured session id on the wire, whatever @session_id says by
|
|
783
|
+
# now: the header must match the id this request was cleared for and is
|
|
784
|
+
# attributed to at 404-handling time. It is set (or removed)
|
|
785
|
+
# unconditionally — #apply_request_headers reads @session_id outside the
|
|
786
|
+
# monitor, so a concurrent recovery that nils it (a 404 restart running
|
|
787
|
+
# its replacement handshake) would otherwise send this pinned request
|
|
788
|
+
# with no session header at all, where the server may run it in another
|
|
789
|
+
# session. The handshake that establishes a session carries none.
|
|
790
|
+
# @param req [Faraday::Request] the request being built
|
|
791
|
+
# @param request [Hash] the JSON-RPC request
|
|
792
|
+
# @param sent_session_id [String, nil] the session id captured under the monitor
|
|
793
|
+
# @return [void]
|
|
794
|
+
def apply_captured_session_id(req, request, sent_session_id)
|
|
795
|
+
return if request['method'] == 'initialize'
|
|
796
|
+
|
|
797
|
+
# A modern session has none at all -- the client MUST NOT send
|
|
798
|
+
# Mcp-Session-Id -- so what such a request was cleared for is "no
|
|
799
|
+
# session", whatever a non-conforming server got itself recorded.
|
|
800
|
+
if sent_session_id && !modern?
|
|
801
|
+
req.headers['Mcp-Session-Id'] = sent_session_id
|
|
802
|
+
else
|
|
803
|
+
req.headers.delete('Mcp-Session-Id')
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# Build the ServerError for a 4xx surfaced as a Faraday::ClientError by
|
|
808
|
+
# user-configured raise_error middleware, inspecting the body like the
|
|
809
|
+
# response path does.
|
|
810
|
+
# @param error [Faraday::ClientError] the middleware exception
|
|
811
|
+
# @param status [Integer] the HTTP status
|
|
812
|
+
# @return [MCPClient::Errors::ServerError]
|
|
813
|
+
def client_error_from_exception(error, status)
|
|
814
|
+
response = normalize_error_response(error.response) || NormalizedResponse.new(status, {}, nil)
|
|
815
|
+
response.status ||= status
|
|
816
|
+
jsonrpc_error_from_http_response(response, "Client error: HTTP #{status} #{error.message}".strip)
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
# POST a JSON-RPC request; a failure before any response records the
|
|
820
|
+
# Authorization the request went out with when Faraday kept it.
|
|
821
|
+
# @param conn [Faraday::Connection]
|
|
822
|
+
# @yield [Faraday::Request]
|
|
823
|
+
# @return [Faraday::Response]
|
|
824
|
+
def post_json_rpc(conn, &)
|
|
825
|
+
conn.post(@endpoint, &)
|
|
826
|
+
rescue Faraday::Error => e
|
|
827
|
+
note_failed_request_authorization(e)
|
|
828
|
+
raise
|
|
322
829
|
end
|
|
323
830
|
|
|
324
831
|
# Apply headers to the HTTP request (can be overridden by subclasses)
|
|
325
832
|
# @param req [Faraday::Request] HTTP request
|
|
326
|
-
# @param
|
|
327
|
-
def apply_request_headers(req,
|
|
833
|
+
# @param request [Hash] JSON-RPC request
|
|
834
|
+
def apply_request_headers(req, request)
|
|
835
|
+
# The freshness probe models its request on the last method sent.
|
|
836
|
+
@probe_method = request['method'] if request.is_a?(Hash) && request['method'].is_a?(String)
|
|
328
837
|
# Apply all headers including custom ones
|
|
329
838
|
@headers.each { |k, v| req.headers[k] = v }
|
|
330
839
|
|
|
331
840
|
# Apply OAuth authorization if available
|
|
332
841
|
@logger.debug("OAuth provider present: #{@oauth_provider ? 'yes' : 'no'}")
|
|
333
842
|
@oauth_provider&.apply_authorization(req)
|
|
843
|
+
note_request_authorization(authorization_header_value(req.headers))
|
|
844
|
+
# Middleware installed through faraday_config may still change the
|
|
845
|
+
# header: the context of this attempt is known once it was sent.
|
|
846
|
+
note_request_authorization_pending if @faraday_config
|
|
847
|
+
|
|
848
|
+
# MCP 2026-07-28: every POST carries MCP-Protocol-Version (matching the
|
|
849
|
+
# body's _meta), Mcp-Method and, for named requests, Mcp-Name.
|
|
850
|
+
modern_request_headers(request).each { |k, v| req.headers[k] = v } if modern?
|
|
334
851
|
end
|
|
335
852
|
|
|
336
853
|
# Handle successful HTTP response (can be overridden by subclasses)
|
|
@@ -363,7 +880,8 @@ module MCPClient
|
|
|
363
880
|
|
|
364
881
|
status = raw[:status] || raw['status']
|
|
365
882
|
headers = raw[:headers] || raw['headers'] || {}
|
|
366
|
-
|
|
883
|
+
body = raw[:body] || raw['body']
|
|
884
|
+
NormalizedResponse.new(status, headers, body)
|
|
367
885
|
end
|
|
368
886
|
|
|
369
887
|
# Handle HTTP error responses
|
|
@@ -385,7 +903,11 @@ module MCPClient
|
|
|
385
903
|
when 400..499
|
|
386
904
|
# Deterministic client errors: the request was processed/rejected and
|
|
387
905
|
# will not succeed on retry, so raise a plain (non-retryable) ServerError.
|
|
388
|
-
|
|
906
|
+
# MCP 2026-07-28 carries its protocol errors in the body of a 400
|
|
907
|
+
# (HeaderMismatch, UnsupportedProtocolVersion,
|
|
908
|
+
# MissingRequiredClientCapability) and an unknown method as a 404
|
|
909
|
+
# with -32601, so a JSON-RPC error body becomes the typed error.
|
|
910
|
+
raise jsonrpc_error_from_http_response(response, "Client error: HTTP #{response.status}#{reason_text}")
|
|
389
911
|
when 500..599
|
|
390
912
|
# Server-side failures are plausibly transient: raise the retryable
|
|
391
913
|
# subclass so with_retry can re-attempt them.
|
|
@@ -409,20 +931,26 @@ module MCPClient
|
|
|
409
931
|
@logger.debug("OAuth challenge processing failed: #{e.message}")
|
|
410
932
|
end
|
|
411
933
|
|
|
412
|
-
# Raise the appropriate error for a 401/403: an insufficient_scope
|
|
934
|
+
# Raise the appropriate error for a 401/403: an insufficient_scope
|
|
413
935
|
# challenge (SEP-835) raises InsufficientScopeError exposing the required
|
|
414
936
|
# scopes so hosts can run a step-up authorization flow.
|
|
937
|
+
#
|
|
938
|
+
# The status the challenge arrives with does not change what it is. RFC
|
|
939
|
+
# 6750 Section 3.1 pairs insufficient_scope with 403, and authorization
|
|
940
|
+
# servers and resource servers do send it on 401 as well; a host that
|
|
941
|
+
# rescues the typed error to run the step-up flow would otherwise miss
|
|
942
|
+
# exactly those.
|
|
415
943
|
# @param response [Faraday::Response] the 401/403 response
|
|
416
944
|
# @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
|
|
417
945
|
def raise_authorization_error(response)
|
|
418
946
|
challenge = bearer_challenge_segment(www_authenticate_header(response))
|
|
419
947
|
|
|
420
|
-
if
|
|
948
|
+
if insufficient_scope_challenge?(challenge)
|
|
421
949
|
scope = challenge[/(?:^|[\s,])scope\s*=\s*"([^"]*)"/i, 1] ||
|
|
422
950
|
challenge[/(?:^|[\s,])scope\s*=\s*([^,\s"]+)/i, 1]
|
|
423
951
|
description = challenge[/(?:^|[\s,])error_description\s*=\s*"([^"]*)"/i, 1]
|
|
424
952
|
raise MCPClient::Errors::InsufficientScopeError.new(
|
|
425
|
-
"Authorization failed: HTTP
|
|
953
|
+
"Authorization failed: HTTP #{response.status} insufficient_scope#{" (required scopes: #{scope})" if scope}",
|
|
426
954
|
scope: scope, error_description: description
|
|
427
955
|
)
|
|
428
956
|
end
|
|
@@ -438,6 +966,11 @@ module MCPClient
|
|
|
438
966
|
# @return [String, nil] the Bearer challenge's parameters (possibly empty),
|
|
439
967
|
# or nil when the header has no Bearer challenge
|
|
440
968
|
def bearer_challenge_segment(header)
|
|
969
|
+
# Peer bytes, made decodable before any pattern is run over them:
|
|
970
|
+
# `gsub` and `match` raise `ArgumentError` on invalid UTF-8, and a
|
|
971
|
+
# challenge that crashed the code reading it would surface as the
|
|
972
|
+
# client's own ArgumentError instead of an authorization error.
|
|
973
|
+
header = MCPClient::Auth::PeerText.decodable(header) if header
|
|
441
974
|
return nil unless header
|
|
442
975
|
|
|
443
976
|
# Locate the Bearer scheme token only OUTSIDE quoted strings: a quoted
|
|
@@ -488,6 +1021,19 @@ module MCPClient
|
|
|
488
1021
|
# Apply user's Faraday customizations after defaults
|
|
489
1022
|
@faraday_config&.call(conn)
|
|
490
1023
|
|
|
1024
|
+
# Appended below any user middleware: the capture's on_complete puts the
|
|
1025
|
+
# streamed body back before raise_error and friends inspect it, and the
|
|
1026
|
+
# retry middleware above re-enters it on every attempt.
|
|
1027
|
+
begin
|
|
1028
|
+
conn.builder.use(ResponseBodyCapture)
|
|
1029
|
+
rescue StandardError => e
|
|
1030
|
+
@logger.debug("Could not install the response capture middleware: #{e.class}")
|
|
1031
|
+
end
|
|
1032
|
+
# Innermost of all, so its on_request sees the Authorization a request
|
|
1033
|
+
# finally carries -- after the host's middleware has run (MCP 2026-07-28
|
|
1034
|
+
# caching binds an entry to the credentials it was fetched with).
|
|
1035
|
+
record_sent_authorization(conn)
|
|
1036
|
+
|
|
491
1037
|
conn
|
|
492
1038
|
end
|
|
493
1039
|
|