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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. 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
- NormalizedResponse = Struct.new(:status, :headers)
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
- with_retry(method) do
37
- request_id = @mutex.synchronize { @request_id += 1 }
38
- request = build_jsonrpc_request(method, params, request_id)
39
- begin
40
- send_jsonrpc_request(request, timeout: timeout)
41
- rescue MCPClient::Errors::RequestTimeoutError
42
- # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
43
- # notification for the abandoned request and stop waiting.
44
- send_cancellation_notification(request_id) if cancellable_request?(method, params)
45
- raise
46
- end
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
- send_http_request(notif)
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
- result = send_jsonrpc_request(json_rpc_request)
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
- response = send_http_request(request, timeout: timeout)
211
- parse_response(response, request)
212
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
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
- # Capture the session id this request goes out with — the value
231
- # apply_request_headers attaches — so a later 404 is attributed to the
232
- # id that actually accompanied the request, not to whatever @session_id
233
- # holds by 404-handling time (another caller may have completed a
234
- # restart in between, and its fresh session must not be re-initialized).
235
- sent_session_id = @mutex.synchronize { @session_id }
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 = conn.post(@endpoint) do |req|
239
- apply_request_headers(req, request)
240
- # Per-request timeout override (MCP lifecycle: timeouts SHOULD be
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
- return restart_session_and_resend(request, sent_session_id) if session_restart_applicable?(sent_session_id)
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 MCPClient::Errors::ServerError, "Client error: HTTP 404 #{e.message}"
277
- rescue Faraday::ConnectionFailed => e
278
- raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
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
- # Start a new session after the server invalidated the current one, then
287
- # resend the original request once. The @restarting_session flag prevents
288
- # a second restart if the fresh session also answers 404.
289
- # @param request [Hash] the JSON-RPC request that hit the expired session
290
- # @param expired_session_id [String] the session id the 404'd request was sent with
291
- # @return [Faraday::Response] the response to the resent request
292
- def restart_session_and_resend(request, expired_session_id)
293
- # Serialized on the transport monitor so concurrent 404s trigger a
294
- # single restart; the monitor is reentrant, so the nested
295
- # perform_initialize/id generation inside is safe.
296
- @mutex.synchronize do
297
- # Recheck now that the monitor is held: another caller may already
298
- # have restarted the session while this one waited. If so, skip the
299
- # extra initialize and just resend against the fresh session.
300
- return resend_after_session_restart(request) if @session_id != expired_session_id
301
-
302
- @logger.warn("Session #{@session_id} no longer valid (HTTP 404); starting a new session")
303
- @restarting_session = true
304
- @session_id = nil
305
- @last_event_id = nil if instance_variable_defined?(:@last_event_id)
306
- perform_initialize
307
- resend_after_session_restart(request)
308
- ensure
309
- @restarting_session = false
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 should trigger a session restart: only when the 404'd
314
- # request was actually sent with a session id and no restart is already
315
- # in flight (a restart's own resend answering 404 must not loop).
316
- # @param sent_session_id [String, nil] session id captured when the request was sent
317
- # @return [Boolean] true if session restart recovery applies
318
- def session_restart_applicable?(sent_session_id)
319
- return false if sent_session_id.nil?
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
- @mutex.synchronize { !@restarting_session }
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 _request [Hash] JSON-RPC request
327
- def apply_request_headers(req, _request)
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
- NormalizedResponse.new(status, headers)
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
- raise MCPClient::Errors::ServerError, "Client error: HTTP #{response.status}#{reason_text}"
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 403
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 response.status == 403 && insufficient_scope_challenge?(challenge)
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 403 insufficient_scope#{" (required scopes: #{scope})" if scope}",
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