ruby-mcp-client 2.0.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +914 -49
  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 +1367 -311
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -36
  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 +851 -478
  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/elicitation_validator.rb +56 -13
  35. data/lib/mcp_client/errors.rb +481 -6
  36. data/lib/mcp_client/header_params.rb +320 -0
  37. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  38. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  39. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  40. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  41. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  42. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  43. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  44. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  45. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  46. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  47. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  48. data/lib/mcp_client/http_transport_base.rb +669 -100
  49. data/lib/mcp_client/input_round_trips.rb +128 -0
  50. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  51. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  52. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  53. data/lib/mcp_client/json_rpc_common.rb +967 -11
  54. data/lib/mcp_client/oauth_client.rb +14 -5
  55. data/lib/mcp_client/prompt.rb +4 -0
  56. data/lib/mcp_client/request_authorization.rb +128 -0
  57. data/lib/mcp_client/request_meta_scope.rb +77 -0
  58. data/lib/mcp_client/request_metadata.rb +287 -0
  59. data/lib/mcp_client/resource.rb +4 -2
  60. data/lib/mcp_client/resource_content.rb +20 -0
  61. data/lib/mcp_client/resource_link.rb +0 -2
  62. data/lib/mcp_client/resource_template.rb +4 -2
  63. data/lib/mcp_client/result_caching.rb +999 -0
  64. data/lib/mcp_client/result_completeness.rb +34 -0
  65. data/lib/mcp_client/root.rb +6 -0
  66. data/lib/mcp_client/round_trip_marker.rb +28 -0
  67. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  68. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  69. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  70. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  71. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  72. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  73. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  74. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  75. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  76. data/lib/mcp_client/schema_validator/references.rb +610 -0
  77. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  78. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  79. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  80. data/lib/mcp_client/schema_validator.rb +893 -175
  81. data/lib/mcp_client/server_base.rb +233 -5
  82. data/lib/mcp_client/server_factory.rb +12 -3
  83. data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
  84. data/lib/mcp_client/server_http.rb +307 -90
  85. data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
  86. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  87. data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
  88. data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
  89. data/lib/mcp_client/server_sse.rb +310 -76
  90. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  91. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
  92. data/lib/mcp_client/server_stdio.rb +778 -186
  93. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
  94. data/lib/mcp_client/server_streamable_http.rb +547 -157
  95. data/lib/mcp_client/session_pin.rb +119 -0
  96. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  97. data/lib/mcp_client/subscription.rb +852 -0
  98. data/lib/mcp_client/subscription_support.rb +715 -0
  99. data/lib/mcp_client/task.rb +286 -14
  100. data/lib/mcp_client/tool.rb +31 -5
  101. data/lib/mcp_client/version.rb +21 -6
  102. data/lib/mcp_client.rb +115 -19
  103. metadata +71 -7
@@ -2,12 +2,23 @@
2
2
 
3
3
  require_relative '../json_rpc_common'
4
4
 
5
+ require_relative 'origin_policy'
6
+
5
7
  module MCPClient
6
8
  class ServerSSE
7
9
  # JSON-RPC request/notification plumbing for SSE transport
8
10
  module JsonRpcTransport
11
+ include OriginPolicy
9
12
  include JsonRpcCommon
10
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
+
11
22
  # Generic JSON-RPC request: send method with params and return result
12
23
  # @param method [String] JSON-RPC method name
13
24
  # @param params [Hash] parameters for the request
@@ -19,20 +30,31 @@ module MCPClient
19
30
  def rpc_request(method, params = {}, timeout: nil)
20
31
  ensure_initialized
21
32
 
22
- with_retry do
23
- request_id = @mutex.synchronize { @request_id += 1 }
24
- request = build_jsonrpc_request(method, params, request_id)
25
- begin
26
- send_jsonrpc_request(request, timeout: timeout)
27
- rescue MCPClient::Errors::RequestTimeoutError
28
- # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
29
- # notification for the abandoned request and stop waiting.
30
- send_cancellation_notification(request_id) if cancellable_request?(method, params)
31
- raise
32
- 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) }
33
39
  end
34
40
  end
35
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
+
36
58
  # Best-effort notifications/cancelled for a request the client stopped
37
59
  # waiting on. Failures are swallowed.
38
60
  # @param request_id [Integer] id of the abandoned request
@@ -86,7 +108,15 @@ module MCPClient
86
108
  request_id = @mutex.synchronize { @request_id += 1 }
87
109
  json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
88
110
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
89
- 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
90
120
  unless result.is_a?(Hash)
91
121
  # A non-object initialize result means the handshake did not succeed.
92
122
  # Continuing would enter the Operation phase without ever sending the
@@ -119,26 +149,65 @@ module MCPClient
119
149
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
120
150
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
121
151
  def send_jsonrpc_request(request, timeout: nil)
122
- @logger.debug("Sending JSON-RPC request: #{request.to_json}")
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!
156
+ @logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
123
157
  record_activity
158
+ # Register the id BEFORE posting: the SSE stream may deliver the
159
+ # response before the POST returns, and only responses to registered
160
+ # (outstanding) requests are accepted into @sse_results.
161
+ register_pending_request(request['id'])
124
162
 
125
163
  begin
164
+ clear_response_received_at if respond_to?(:clear_response_received_at, true)
126
165
  response = post_json_rpc_request(request)
127
166
 
128
167
  if @use_sse
168
+ # Dated by check_for_result from the stream's arrival time.
129
169
  wait_for_sse_result(request, timeout: timeout)
130
170
  else
171
+ # Dated from receipt, before the body is decoded.
172
+ note_response_received_at if respond_to?(:note_response_received_at, true)
131
173
  parse_direct_response(response)
132
174
  end
133
- 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
134
181
  raise
135
182
  rescue JSON::ParserError => e
136
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
183
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
137
184
  rescue Errno::ECONNREFUSED => e
138
185
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
139
186
  rescue StandardError => e
140
187
  method_name = request['method']
141
188
  raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
189
+ ensure
190
+ unregister_pending_request(request['id'])
191
+ end
192
+ end
193
+
194
+ # Mark a request id as awaiting its response.
195
+ # @param request_id [Integer, String] id of the outgoing request
196
+ # @return [void]
197
+ def register_pending_request(request_id)
198
+ @mutex.synchronize { @pending_request_ids.add(request_id) }
199
+ end
200
+
201
+ # Stop accepting responses for a request id (completed, failed or timed
202
+ # out) and drop any result that was never consumed — a late or duplicate
203
+ # response must not accumulate in @sse_results.
204
+ # @param request_id [Integer, String] id of the finished request
205
+ # @return [void]
206
+ def unregister_pending_request(request_id)
207
+ @mutex.synchronize do
208
+ @pending_request_ids.delete(request_id)
209
+ @sse_results.delete(request_id)
210
+ @sse_result_arrivals&.delete(request_id)
142
211
  end
143
212
  end
144
213
 
@@ -149,7 +218,15 @@ module MCPClient
149
218
  def post_json_rpc_request(request)
150
219
  uri = URI.parse(@base_url)
151
220
  base = "#{uri.scheme}://#{uri.host}:#{uri.port}"
152
- 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
153
230
 
154
231
  @rpc_conn ||= create_json_rpc_connection(base)
155
232
 
@@ -159,9 +236,13 @@ module MCPClient
159
236
 
160
237
  unless response.success?
161
238
  # 5xx failures are plausibly transient (retryable); 4xx and other
162
- # statuses are deterministic and raise a plain (non-retryable) error.
163
- error_class = (500..599).cover?(response.status) ? MCPClient::Errors::TransientServerError : MCPClient::Errors::ServerError
164
- 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)
165
246
  end
166
247
 
167
248
  response
@@ -178,7 +259,7 @@ module MCPClient
178
259
  def create_json_rpc_connection(base_url)
179
260
  Faraday.new(url: base_url) do |f|
180
261
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
181
- f.response :follow_redirects, limit: 3
262
+ f.response :follow_redirects, limit: 3, callback: method(:reject_cross_origin_redirect!)
182
263
  f.options.open_timeout = @read_timeout
183
264
  f.options.timeout = @read_timeout
184
265
  f.adapter Faraday.default_adapter
@@ -203,11 +284,17 @@ module MCPClient
203
284
  h.delete('Accept')
204
285
  h.delete('Cache-Control')
205
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
206
292
  req.body = request.to_json
207
293
  end
294
+ note_sent_authorization(response) if respond_to?(:note_sent_authorization, true)
208
295
 
209
296
  msg = "Received JSON-RPC response: #{response.status}"
210
- msg += " #{response.body}" if response.respond_to?(:body)
297
+ msg += " (#{describe_body_size(response.body)})" if response.respond_to?(:body)
211
298
  @logger.debug(msg)
212
299
  response
213
300
  end
@@ -248,7 +335,7 @@ module MCPClient
248
335
  def wait_for_result_with_timeout(request_id, start_time, timeout)
249
336
  loop do
250
337
  result = check_for_result(request_id)
251
- return result if result
338
+ return result unless result.equal?(NO_RESULT)
252
339
 
253
340
  unless connection_active?
254
341
  raise MCPClient::Errors::ConnectionError,
@@ -266,35 +353,64 @@ module MCPClient
266
353
 
267
354
  # Check if a result is available for the given request ID
268
355
  # @param request_id [Integer] the request ID to check
269
- # @return [Hash, nil] the result if available, nil otherwise
356
+ # @return [Object] the result if one has arrived, NO_RESULT otherwise
270
357
  # @raise [MCPClient::Errors::ServerError] if the stored result is a JSON-RPC error response
271
358
  def check_for_result(request_id)
359
+ arrived = false
272
360
  result = nil
361
+ arrival = nil
273
362
  @mutex.synchronize do
274
- 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)
275
368
  end
369
+ return NO_RESULT unless arrived
276
370
 
277
- if result
371
+ if arrived
278
372
  record_activity
373
+ note_response_received_at(arrival || monotonic_now) if respond_to?(:note_response_received_at, true)
279
374
  # SseParser#process_response? stores JSON-RPC error responses under
280
375
  # the Symbol :error key; deliver them to the caller as ServerError
281
376
  # (MCP lifecycle "Error Handling") instead of timing out.
282
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)
283
386
  return result
284
387
  end
285
388
 
286
389
  nil
287
390
  end
288
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
+
289
406
  # Raise a ServerError for a JSON-RPC error response received over SSE,
290
407
  # mirroring JsonRpcCommon#process_jsonrpc_response for the other transports.
291
408
  # @param error [Hash, nil] the JSON-RPC error object ('code', 'message', 'data')
292
409
  # @raise [MCPClient::Errors::ServerError] always
293
410
  def raise_sse_error_response(error)
294
- error ||= {}
295
- message = error['message'] || 'Unknown server error'
296
- message = "#{message} (code #{error['code']})" if error['code']
297
- 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)
298
414
  end
299
415
 
300
416
  # Parse a direct (non-SSE) JSON-RPC response
@@ -306,7 +422,7 @@ module MCPClient
306
422
  data = JSON.parse(response.body)
307
423
  process_jsonrpc_response(data)
308
424
  rescue JSON::ParserError => e
309
- raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
425
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
310
426
  end
311
427
  end
312
428
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module MCPClient
6
+ class ServerSSE
7
+ # Origin pinning for the legacy HTTP+SSE transport.
8
+ #
9
+ # Everything this transport sends carries the caller's configured headers
10
+ # (Authorization, API keys, cookies), and callback responses carry
11
+ # roots/sampling/elicitation data, so no request may leave the origin the
12
+ # caller connected to — neither by a server-chosen endpoint URI nor by a
13
+ # redirect.
14
+ module OriginPolicy
15
+ # @param base [URI::Generic] the SSE connection URL
16
+ # @param other [URI::Generic] the URL to compare
17
+ # @return [Boolean] whether both share scheme, host and port
18
+ def same_origin?(base, other)
19
+ base.scheme == other.scheme &&
20
+ base.host&.downcase == other.host&.downcase &&
21
+ base.port == other.port
22
+ end
23
+
24
+ # @param uri [URI::Generic]
25
+ # @return [String] scheme://host:port of the URI
26
+ def origin_of(uri)
27
+ "#{uri.scheme}://#{uri.host}:#{uri.port}"
28
+ end
29
+
30
+ # Refuse to follow a redirect that leaves the SSE connection's origin.
31
+ #
32
+ # Pinning the endpoint event's origin is not sufficient on its own: a
33
+ # same-origin endpoint can answer a POST with a 307/308 to another
34
+ # origin, and faraday-follow_redirects replays the request there. It
35
+ # strips only the literal Authorization header, so configured API-key
36
+ # and other custom headers — plus the JSON-RPC body — would still reach
37
+ # the foreign origin.
38
+ #
39
+ # Raises ConnectionError rather than TransportError because the server
40
+ # has already received the original request; with_retry must not re-send
41
+ # it.
42
+ # @param _old_env [Faraday::Env] the redirecting response environment
43
+ # @param new_env [Faraday::Env] environment of the request about to be replayed
44
+ # @return [void]
45
+ # @raise [MCPClient::Errors::ConnectionError] if the redirect changes origin
46
+ def reject_cross_origin_redirect!(_old_env, new_env)
47
+ base = URI.parse(@base_url)
48
+ target = new_env.url
49
+ return if same_origin?(base, target)
50
+
51
+ message = "Refusing cross-origin redirect from #{origin_of(base)} to #{origin_of(target)}"
52
+ @logger.error(message)
53
+ raise MCPClient::Errors::ConnectionError, message
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'origin_policy'
4
+
3
5
  module MCPClient
4
6
  class ServerSSE
5
7
  # Extracted module for back-off, ping, and reconnection logic
6
8
  module ReconnectMonitor
9
+ include OriginPolicy
10
+
7
11
  # Start an activity monitor thread to maintain the connection
8
12
  # @return [void]
9
13
  def start_activity_monitor
@@ -202,7 +206,9 @@ module MCPClient
202
206
  f.options.open_timeout = 10
203
207
  f.options.timeout = nil
204
208
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
205
- f.response :follow_redirects, limit: 3
209
+ # Same origin pinning as the RPC connection: the SSE stream carries
210
+ # the configured credential headers too.
211
+ f.response :follow_redirects, limit: 3, callback: method(:reject_cross_origin_redirect!)
206
212
  f.adapter Faraday.default_adapter
207
213
  end
208
214
 
@@ -2,14 +2,18 @@
2
2
 
3
3
  require 'json'
4
4
  require 'uri'
5
+ require_relative 'origin_policy'
5
6
 
6
7
  module MCPClient
7
8
  class ServerSSE
8
9
  # === Wire-level SSE parsing & dispatch ===
9
10
  module SseParser
11
+ include OriginPolicy
12
+
10
13
  # Parse and handle a raw SSE event payload.
11
14
  # @param event_data [String] the raw event chunk
12
- 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)
13
17
  event = parse_sse_event(event_data)
14
18
  return if event.nil?
15
19
 
@@ -19,27 +23,31 @@ module MCPClient
19
23
  when 'ping'
20
24
  # no-op
21
25
  when 'message'
22
- handle_message_event(event)
26
+ handle_message_event(event, arrived)
23
27
  end
24
28
  end
25
29
 
26
30
  # Handle a "message" SSE event (payload is JSON-RPC over SSE)
27
31
  # @param event [Hash] the parsed SSE event (with :data, :id, etc)
28
- 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)
29
34
  return if event[:data].empty?
30
35
 
31
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)
32
40
  data = JSON.parse(event[:data])
33
41
 
34
42
  return if process_error_in_message?(data)
35
43
  return if process_server_request?(data)
36
44
  return if process_notification?(data)
37
45
 
38
- process_response?(data)
46
+ process_response?(data, arrived)
39
47
  rescue MCPClient::Errors::ConnectionError
40
48
  raise
41
49
  rescue JSON::ParserError => e
42
- @logger.warn("Failed to parse JSON from event data: #{e.message}")
50
+ @logger.warn("Failed to parse JSON from event data: #{describe_parse_error(e, event[:data])}")
43
51
  rescue StandardError => e
44
52
  @logger.error("Error processing SSE event: #{e.message}")
45
53
  end
@@ -82,14 +90,32 @@ module MCPClient
82
90
  def process_notification?(data)
83
91
  return false unless data['method'] && !data.key?('id')
84
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'])
85
110
  @notification_callback&.call(data['method'], data['params'])
86
111
  true
87
112
  end
88
113
 
89
114
  # Process a JSON-RPC response (id => response)
90
115
  # @param data [Hash] the parsed JSON payload
116
+ # @param arrived [Float, nil] monotonic time the event arrived
91
117
  # @return [Boolean] true if we saw & handled a response
92
- def process_response?(data)
118
+ def process_response?(data, arrived = nil)
93
119
  return false unless data['id']
94
120
 
95
121
  # Deliver the response to the waiting caller via @sse_results only.
@@ -98,14 +124,33 @@ module MCPClient
98
124
  # paginated list. Writing each page as it arrives would let a concurrent
99
125
  # list_tools observe a partial (page-1-only) cache mid-pagination.
100
126
  @mutex.synchronize do
127
+ # The stream is peer-controlled: only ids some caller is actually
128
+ # waiting on are stored. Without this check a server could stream
129
+ # unsolicited responses with fresh ids and grow @sse_results without
130
+ # bound for the lifetime of the client.
131
+ unless @pending_request_ids.include?(data['id'])
132
+ @logger.debug("Discarding unsolicited response id #{data['id'].inspect}")
133
+ return true
134
+ end
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)
101
138
  @sse_results[data['id']] =
102
139
  if data['error']
103
140
  # JSON-RPC error response: store the error under a Symbol key
104
141
  # (JSON.parse only produces String keys, so this cannot collide
105
142
  # with a success result) for the waiter to raise ServerError.
106
143
  { error: data['error'] }
107
- else
144
+ elsif data.key?('result')
145
+ # An explicit null (or false) result is an answer; only the
146
+ # member's presence decides that.
108
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 }
109
154
  end
110
155
  end
111
156
 
@@ -156,20 +201,39 @@ module MCPClient
156
201
  end
157
202
  end
158
203
 
159
- # Resolve an endpoint URI reference against the SSE connection URL
204
+ # Resolve an endpoint URI reference against the SSE connection URL.
205
+ # The resolved endpoint MUST stay on the SSE connection's origin: the
206
+ # event payload is server-controlled input, and honoring a cross-origin
207
+ # target would redirect every JSON-RPC POST — including the configured
208
+ # Authorization/API-key headers and callback response bodies — to a
209
+ # server the caller never chose.
160
210
  # @param data [String] the endpoint event payload (absolute or relative URI)
161
211
  # @return [String] the absolute endpoint URL
162
212
  def resolve_endpoint_uri(data)
163
- URI.join(@base_url, data).to_s
213
+ endpoint = URI.join(@base_url, data)
214
+ base = URI.parse(@base_url)
215
+ unless same_origin?(base, endpoint)
216
+ fail_endpoint_handshake!(
217
+ "Cross-origin endpoint in SSE endpoint event: #{data.inspect} " \
218
+ "does not match the connection origin #{origin_of(base)}"
219
+ )
220
+ end
221
+ endpoint.to_s
164
222
  rescue URI::Error => e
165
223
  # The endpoint event is the handshake's core payload; an unresolvable
166
224
  # URI must fail the handshake rather than deferring a broken POST
167
225
  # target to the first request.
168
226
  @logger.error("Failed to resolve endpoint URI #{data.inspect} against #{@base_url}: #{e.message}")
169
- message = "Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})"
170
- # The SSE worker thread swallows this exception with a generic rescue,
171
- # so also record the failure cause (mirroring @auth_error) for the
172
- # connect caller blocked in wait_for_connection to surface promptly.
227
+ fail_endpoint_handshake!("Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})")
228
+ end
229
+
230
+ # Record the handshake failure cause and raise. The SSE worker thread
231
+ # swallows this exception with a generic rescue, so also record the
232
+ # failure (mirroring @auth_error) for the connect caller blocked in
233
+ # wait_for_connection to surface promptly.
234
+ # @param message [String] the failure description
235
+ # @raise [MCPClient::Errors::TransportError] always
236
+ def fail_endpoint_handshake!(message)
173
237
  @mutex.synchronize do
174
238
  @connection_error = message
175
239
  @connection_established = false