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
@@ -11,6 +11,14 @@ module MCPClient
11
11
  include OriginPolicy
12
12
  include JsonRpcCommon
13
13
 
14
+ # Returned by #check_for_result when nothing has arrived for the
15
+ # request yet. The stored result is whatever `result` member the
16
+ # response carried, so `null` and `false` are answers the client must
17
+ # deliver (and validate) rather than truthiness the waiter can read as
18
+ # "still outstanding" — doing that waited out the whole read timeout
19
+ # and cancelled a request the server had already answered.
20
+ NO_RESULT = Object.new.freeze
21
+
14
22
  # Generic JSON-RPC request: send method with params and return result
15
23
  # @param method [String] JSON-RPC method name
16
24
  # @param params [Hash] parameters for the request
@@ -22,20 +30,31 @@ module MCPClient
22
30
  def rpc_request(method, params = {}, timeout: nil)
23
31
  ensure_initialized
24
32
 
25
- with_retry(method) do
26
- request_id = @mutex.synchronize { @request_id += 1 }
27
- request = build_jsonrpc_request(method, params, request_id)
28
- begin
29
- send_jsonrpc_request(request, timeout: timeout)
30
- rescue MCPClient::Errors::RequestTimeoutError
31
- # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
32
- # notification for the abandoned request and stop waiting.
33
- send_cancellation_notification(request_id) if cancellable_request?(method, params)
34
- raise
35
- end
33
+ # The multi round-trip resolver sits outside the per-attempt retry, so
34
+ # a retry carrying inputResponses/requestState keeps them through
35
+ # transport retries — the same shape as the other transports, so an
36
+ # input_required answer is validated and fulfilled here as well.
37
+ resolve_input_round_trips(method, params, timeout) do |attempt_params|
38
+ with_retry(method) { send_one_request(method, attempt_params, timeout) }
36
39
  end
37
40
  end
38
41
 
42
+ # One request on the wire: build it, send it and wait for its answer.
43
+ # @param method [String] JSON-RPC method
44
+ # @param params [Hash] the params of this attempt
45
+ # @param timeout [Numeric, nil] per-request timeout
46
+ # @return [Object] the result
47
+ def send_one_request(method, params, timeout)
48
+ request_id = @mutex.synchronize { @request_id += 1 }
49
+ request = build_jsonrpc_request(method, params, request_id)
50
+ send_jsonrpc_request(request, timeout: timeout)
51
+ rescue MCPClient::Errors::RequestTimeoutError
52
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
53
+ # notification for the abandoned request and stop waiting.
54
+ send_cancellation_notification(request_id) if request_id && cancellable_request?(method, params)
55
+ raise
56
+ end
57
+
39
58
  # Best-effort notifications/cancelled for a request the client stopped
40
59
  # waiting on. Failures are swallowed.
41
60
  # @param request_id [Integer] id of the abandoned request
@@ -89,7 +108,15 @@ module MCPClient
89
108
  request_id = @mutex.synchronize { @request_id += 1 }
90
109
  json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
91
110
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
92
- result = send_jsonrpc_request(json_rpc_request)
111
+ begin
112
+ result = send_jsonrpc_request(json_rpc_request)
113
+ rescue MCPClient::Errors::UnsupportedProtocolVersionError => e
114
+ # As on the HTTP transports: the versions a modern-only server
115
+ # names in `data` are the diagnostic a legacy configuration needs,
116
+ # so they are spelled out rather than dropped by connect's wrap.
117
+ raise MCPClient::Errors::ConnectionError,
118
+ "Initialize failed: #{e.message} (server supports: #{e.supported.join(', ')})"
119
+ end
93
120
  unless result.is_a?(Hash)
94
121
  # A non-object initialize result means the handshake did not succeed.
95
122
  # Continuing would enter the Operation phase without ever sending the
@@ -122,6 +149,10 @@ module MCPClient
122
149
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
123
150
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
124
151
  def send_jsonrpc_request(request, timeout: nil)
152
+ # As late as a request pinned to a session can be held back: every
153
+ # reconnect on the way here (ensure_initialized, a retry after the
154
+ # stream dropped) has happened by now.
155
+ check_session_pin!
125
156
  @logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
126
157
  record_activity
127
158
  # Register the id BEFORE posting: the SSE stream may deliver the
@@ -130,14 +161,23 @@ module MCPClient
130
161
  register_pending_request(request['id'])
131
162
 
132
163
  begin
164
+ clear_response_received_at if respond_to?(:clear_response_received_at, true)
133
165
  response = post_json_rpc_request(request)
134
166
 
135
167
  if @use_sse
168
+ # Dated by check_for_result from the stream's arrival time.
136
169
  wait_for_sse_result(request, timeout: timeout)
137
170
  else
171
+ # Dated from receipt, before the body is decoded.
172
+ note_response_received_at if respond_to?(:note_response_received_at, true)
138
173
  parse_direct_response(response)
139
174
  end
140
- rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
175
+ # A pre-write refusal keeps its type: the late pin check inside the
176
+ # POST turns a request down (or the caller's own guard does, see
177
+ # {MCPClient::SessionPin#guarded_writes}) and nothing was written,
178
+ # which is not an error of executing the request.
179
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError,
180
+ MCPClient::Errors::ServerError, MCPClient::Errors::TaskReplacedError
141
181
  raise
142
182
  rescue JSON::ParserError => e
143
183
  raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
@@ -167,6 +207,7 @@ module MCPClient
167
207
  @mutex.synchronize do
168
208
  @pending_request_ids.delete(request_id)
169
209
  @sse_results.delete(request_id)
210
+ @sse_result_arrivals&.delete(request_id)
170
211
  end
171
212
  end
172
213
 
@@ -177,7 +218,15 @@ module MCPClient
177
218
  def post_json_rpc_request(request)
178
219
  uri = URI.parse(@base_url)
179
220
  base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
180
- rpc_ep = @mutex.synchronize { @rpc_endpoint }
221
+ # The endpoint this request goes to, and the pin checked in the same
222
+ # critical section: a reconnect completing between the check in
223
+ # #send_jsonrpc_request and this capture would otherwise post a
224
+ # request of the ended session to the session that replaced it
225
+ # (cleanup bumps the epoch before it takes this monitor).
226
+ rpc_ep = @mutex.synchronize do
227
+ check_session_pin!
228
+ @rpc_endpoint
229
+ end
181
230
 
182
231
  @rpc_conn ||= create_json_rpc_connection(base)
183
232
 
@@ -187,9 +236,13 @@ module MCPClient
187
236
 
188
237
  unless response.success?
189
238
  # 5xx failures are plausibly transient (retryable); 4xx and other
190
- # statuses are deterministic and raise a plain (non-retryable) error.
191
- error_class = (500..599).cover?(response.status) ? MCPClient::Errors::TransientServerError : MCPClient::Errors::ServerError
192
- raise error_class, "Server returned error: #{response.status} #{response.reason_phrase}"
239
+ # statuses are deterministic and raise a plain (non-retryable)
240
+ # error — typed when the body carries a JSON-RPC error (MCP
241
+ # 2026-07-28 protocol errors ride in 400/404 bodies).
242
+ message = "Server returned error: #{response.status} #{response.reason_phrase}"
243
+ raise MCPClient::Errors::TransientServerError, message if (500..599).cover?(response.status)
244
+
245
+ raise jsonrpc_error_from_http_response(response, message)
193
246
  end
194
247
 
195
248
  response
@@ -231,8 +284,14 @@ module MCPClient
231
284
  h.delete('Accept')
232
285
  h.delete('Cache-Control')
233
286
  end).each { |k, v| req.headers[k] = v }
287
+ # MCP 2026-07-28 caching: the result is bound to the credentials
288
+ # this very request carries.
289
+ if respond_to?(:note_request_authorization, true)
290
+ note_request_authorization(authorization_header_value(req.headers))
291
+ end
234
292
  req.body = request.to_json
235
293
  end
294
+ note_sent_authorization(response) if respond_to?(:note_sent_authorization, true)
236
295
 
237
296
  msg = "Received JSON-RPC response: #{response.status}"
238
297
  msg += " (#{describe_body_size(response.body)})" if response.respond_to?(:body)
@@ -276,7 +335,7 @@ module MCPClient
276
335
  def wait_for_result_with_timeout(request_id, start_time, timeout)
277
336
  loop do
278
337
  result = check_for_result(request_id)
279
- return result if result
338
+ return result unless result.equal?(NO_RESULT)
280
339
 
281
340
  unless connection_active?
282
341
  raise MCPClient::Errors::ConnectionError,
@@ -294,35 +353,64 @@ module MCPClient
294
353
 
295
354
  # Check if a result is available for the given request ID
296
355
  # @param request_id [Integer] the request ID to check
297
- # @return [Hash, nil] the result if available, nil otherwise
356
+ # @return [Object] the result if one has arrived, NO_RESULT otherwise
298
357
  # @raise [MCPClient::Errors::ServerError] if the stored result is a JSON-RPC error response
299
358
  def check_for_result(request_id)
359
+ arrived = false
300
360
  result = nil
361
+ arrival = nil
301
362
  @mutex.synchronize do
302
- result = @sse_results.delete(request_id) if @sse_results.key?(request_id)
363
+ if @sse_results.key?(request_id)
364
+ arrived = true
365
+ result = @sse_results.delete(request_id)
366
+ end
367
+ arrival = @sse_result_arrivals&.delete(request_id)
303
368
  end
369
+ return NO_RESULT unless arrived
304
370
 
305
- if result
371
+ if arrived
306
372
  record_activity
373
+ note_response_received_at(arrival || monotonic_now) if respond_to?(:note_response_received_at, true)
307
374
  # SseParser#process_response? stores JSON-RPC error responses under
308
375
  # the Symbol :error key; deliver them to the caller as ServerError
309
376
  # (MCP lifecycle "Error Handling") instead of timing out.
310
377
  raise_sse_error_response(result[:error]) if result.is_a?(Hash) && result.key?(:error)
378
+ # …and an envelope that carried neither member under :no_answer.
379
+ if result.is_a?(Hash) && result[:no_answer]
380
+ raise MCPClient::Errors::InvalidResultError,
381
+ "Invalid result: the response to request #{request_id} carried neither a result nor an error member"
382
+ end
383
+ # Same resultType invariant as process_jsonrpc_response on the
384
+ # other transports: an unrecognized value is an invalid response.
385
+ validate_result_type!(result)
311
386
  return result
312
387
  end
313
388
 
314
389
  nil
315
390
  end
316
391
 
392
+ # The legacy HTTP+SSE transport never negotiates a modern revision, so
393
+ # subscriptions/listen (which requires one) is refused by the shared
394
+ # implementation after this readiness check.
395
+ # @return [void]
396
+ def ensure_session_ready
397
+ ensure_connected if respond_to?(:ensure_connected, true)
398
+ end
399
+
400
+ # @param _subscription [MCPClient::Subscription]
401
+ # @return [void]
402
+ def cancel_subscription(_subscription)
403
+ nil
404
+ end
405
+
317
406
  # Raise a ServerError for a JSON-RPC error response received over SSE,
318
407
  # mirroring JsonRpcCommon#process_jsonrpc_response for the other transports.
319
408
  # @param error [Hash, nil] the JSON-RPC error object ('code', 'message', 'data')
320
409
  # @raise [MCPClient::Errors::ServerError] always
321
410
  def raise_sse_error_response(error)
322
- error ||= {}
323
- message = error['message'] || 'Unknown server error'
324
- message = "#{message} (code #{error['code']})" if error['code']
325
- raise MCPClient::Errors::ServerError, message
411
+ typed = MCPClient::Errors::ServerError.from_jsonrpc(error)
412
+ message = typed.code ? "#{typed.message} (code #{typed.code})" : typed.message
413
+ raise typed.class.new(message, code: typed.code, data: typed.data)
326
414
  end
327
415
 
328
416
  # Parse a direct (non-SSE) JSON-RPC response
@@ -12,7 +12,8 @@ module MCPClient
12
12
 
13
13
  # Parse and handle a raw SSE event payload.
14
14
  # @param event_data [String] the raw event chunk
15
- def parse_and_handle_sse_event(event_data)
15
+ # @param arrived [Float, nil] monotonic time the chunk carrying it arrived
16
+ def parse_and_handle_sse_event(event_data, arrived = nil)
16
17
  event = parse_sse_event(event_data)
17
18
  return if event.nil?
18
19
 
@@ -22,23 +23,27 @@ module MCPClient
22
23
  when 'ping'
23
24
  # no-op
24
25
  when 'message'
25
- handle_message_event(event)
26
+ handle_message_event(event, arrived)
26
27
  end
27
28
  end
28
29
 
29
30
  # Handle a "message" SSE event (payload is JSON-RPC over SSE)
30
31
  # @param event [Hash] the parsed SSE event (with :data, :id, etc)
31
- def handle_message_event(event)
32
+ # @param arrived [Float, nil] monotonic time the chunk carrying it arrived
33
+ def handle_message_event(event, arrived = nil)
32
34
  return if event[:data].empty?
33
35
 
34
36
  begin
37
+ # Dated from the arrival of the chunk it came in, before any event
38
+ # of that chunk was decoded or dispatched.
39
+ arrived ||= monotonic_now if respond_to?(:monotonic_now, true)
35
40
  data = JSON.parse(event[:data])
36
41
 
37
42
  return if process_error_in_message?(data)
38
43
  return if process_server_request?(data)
39
44
  return if process_notification?(data)
40
45
 
41
- process_response?(data)
46
+ process_response?(data, arrived)
42
47
  rescue MCPClient::Errors::ConnectionError
43
48
  raise
44
49
  rescue JSON::ParserError => e
@@ -85,14 +90,32 @@ module MCPClient
85
90
  def process_notification?(data)
86
91
  return false unless data['method'] && !data.key?('id')
87
92
 
93
+ # notifications/message is the Logging utility, Deprecated as a whole
94
+ # in 2026-07-28 (SEP-2577). The notice belongs to the transport, not
95
+ # to MCPClient::Client: a host that registered on_notification on a
96
+ # ServerSSE receives log messages without a Client ever existing. It
97
+ # is raised here rather than only in
98
+ # {MCPClient::SubscriptionSupport#route_notification} because this
99
+ # transport is the one that does not go through it — see below.
100
+ warn_logging_deprecated if data['method'] == 'notifications/message'
101
+ # The legacy SSE transport carries no subscriptions/listen stream, so
102
+ # there is no delivery to run ahead of — but the transport's own caches
103
+ # and a host that registered its invalidation on the dedicated hook
104
+ # must still be told, in the order routing uses them
105
+ # (see {MCPClient::ServerBase#on_cache_invalidation}).
106
+ invalidate_cache_for_notification(data['method'], data['params']) if respond_to?(
107
+ :invalidate_cache_for_notification, true
108
+ )
109
+ notify_cache_invalidation(data['method'], data['params'])
88
110
  @notification_callback&.call(data['method'], data['params'])
89
111
  true
90
112
  end
91
113
 
92
114
  # Process a JSON-RPC response (id => response)
93
115
  # @param data [Hash] the parsed JSON payload
116
+ # @param arrived [Float, nil] monotonic time the event arrived
94
117
  # @return [Boolean] true if we saw & handled a response
95
- def process_response?(data)
118
+ def process_response?(data, arrived = nil)
96
119
  return false unless data['id']
97
120
 
98
121
  # Deliver the response to the waiting caller via @sse_results only.
@@ -110,14 +133,24 @@ module MCPClient
110
133
  return true
111
134
  end
112
135
 
136
+ # Dated from arrival: the waiter polls and may wake much later.
137
+ (@sse_result_arrivals ||= {})[data['id']] = arrived || monotonic_now if respond_to?(:monotonic_now, true)
113
138
  @sse_results[data['id']] =
114
139
  if data['error']
115
140
  # JSON-RPC error response: store the error under a Symbol key
116
141
  # (JSON.parse only produces String keys, so this cannot collide
117
142
  # with a success result) for the waiter to raise ServerError.
118
143
  { error: data['error'] }
119
- else
144
+ elsif data.key?('result')
145
+ # An explicit null (or false) result is an answer; only the
146
+ # member's presence decides that.
120
147
  data['result']
148
+ else
149
+ # JSON-RPC 2.0 section 5: "Either the result member or error
150
+ # member MUST be included". An envelope carrying neither answers
151
+ # nothing, and reading its absent result as a delivered nil
152
+ # would turn a malformed response into a successful call.
153
+ { no_answer: true }
121
154
  end
122
155
  end
123
156