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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +914 -49
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1367 -311
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -36
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +851 -478
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +481 -6
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +669 -100
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +967 -11
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -2
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +4 -2
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +893 -175
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +12 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
- data/lib/mcp_client/server_sse.rb +310 -76
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
- data/lib/mcp_client/server_stdio.rb +778 -186
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
- data/lib/mcp_client/server_streamable_http.rb +547 -157
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -5
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +115 -19
- 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
295
|
-
message =
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|