ruby-mcp-client 1.1.0 → 2.1.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/README.md +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- metadata +5 -6
|
@@ -2,10 +2,13 @@
|
|
|
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
|
|
|
11
14
|
# Generic JSON-RPC request: send method with params and return result
|
|
@@ -16,16 +19,35 @@ module MCPClient
|
|
|
16
19
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
17
20
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
18
21
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
19
|
-
def rpc_request(method, params = {})
|
|
22
|
+
def rpc_request(method, params = {}, timeout: nil)
|
|
20
23
|
ensure_initialized
|
|
21
24
|
|
|
22
|
-
with_retry do
|
|
25
|
+
with_retry(method) do
|
|
23
26
|
request_id = @mutex.synchronize { @request_id += 1 }
|
|
24
27
|
request = build_jsonrpc_request(method, params, request_id)
|
|
25
|
-
|
|
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
|
|
26
36
|
end
|
|
27
37
|
end
|
|
28
38
|
|
|
39
|
+
# Best-effort notifications/cancelled for a request the client stopped
|
|
40
|
+
# waiting on. Failures are swallowed.
|
|
41
|
+
# @param request_id [Integer] id of the abandoned request
|
|
42
|
+
# @return [void]
|
|
43
|
+
def send_cancellation_notification(request_id)
|
|
44
|
+
notif = build_jsonrpc_notification('notifications/cancelled',
|
|
45
|
+
{ 'requestId' => request_id, 'reason' => 'Request timed out' })
|
|
46
|
+
post_json_rpc_request(notif)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
@logger.debug("Failed to send cancellation notification: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
|
|
29
51
|
# Send a JSON-RPC notification (no response expected)
|
|
30
52
|
# @param method [String] JSON-RPC method name
|
|
31
53
|
# @param params [Hash] parameters for the notification
|
|
@@ -62,15 +84,28 @@ module MCPClient
|
|
|
62
84
|
|
|
63
85
|
# Perform JSON-RPC initialize handshake with the MCP server
|
|
64
86
|
# @return [void]
|
|
87
|
+
# @raise [MCPClient::Errors::ConnectionError] if the initialize result is malformed
|
|
65
88
|
def perform_initialize
|
|
66
89
|
request_id = @mutex.synchronize { @request_id += 1 }
|
|
67
90
|
json_rpc_request = build_jsonrpc_request('initialize', initialization_params, request_id)
|
|
68
91
|
@logger.debug("Performing initialize RPC: #{json_rpc_request}")
|
|
69
92
|
result = send_jsonrpc_request(json_rpc_request)
|
|
70
|
-
|
|
93
|
+
unless result.is_a?(Hash)
|
|
94
|
+
# A non-object initialize result means the handshake did not succeed.
|
|
95
|
+
# Continuing would enter the Operation phase without ever sending the
|
|
96
|
+
# mandatory notifications/initialized (MCP lifecycle "Initialization":
|
|
97
|
+
# "After successful initialization, the client MUST send an
|
|
98
|
+
# `initialized` notification"), so fail the connection instead.
|
|
99
|
+
cleanup
|
|
100
|
+
raise MCPClient::Errors::ConnectionError,
|
|
101
|
+
"Invalid initialize response from server: expected an object, got #{result.inspect}"
|
|
102
|
+
end
|
|
71
103
|
|
|
104
|
+
# Disconnects if the server negotiated a version we cannot speak.
|
|
105
|
+
@protocol_version = validate_protocol_version!(result)
|
|
72
106
|
@server_info = result['serverInfo']
|
|
73
107
|
@capabilities = result['capabilities']
|
|
108
|
+
@instructions = result['instructions']
|
|
74
109
|
|
|
75
110
|
# Send initialized notification to acknowledge completion of initialization
|
|
76
111
|
initialized_notification = build_jsonrpc_notification('notifications/initialized', {})
|
|
@@ -86,27 +121,52 @@ module MCPClient
|
|
|
86
121
|
# @raise [MCPClient::Errors::ConnectionError] if connection fails
|
|
87
122
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
88
123
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
89
|
-
def send_jsonrpc_request(request)
|
|
90
|
-
@logger.debug("Sending JSON-RPC request: #{request
|
|
124
|
+
def send_jsonrpc_request(request, timeout: nil)
|
|
125
|
+
@logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
|
|
91
126
|
record_activity
|
|
127
|
+
# Register the id BEFORE posting: the SSE stream may deliver the
|
|
128
|
+
# response before the POST returns, and only responses to registered
|
|
129
|
+
# (outstanding) requests are accepted into @sse_results.
|
|
130
|
+
register_pending_request(request['id'])
|
|
92
131
|
|
|
93
132
|
begin
|
|
94
133
|
response = post_json_rpc_request(request)
|
|
95
134
|
|
|
96
135
|
if @use_sse
|
|
97
|
-
wait_for_sse_result(request)
|
|
136
|
+
wait_for_sse_result(request, timeout: timeout)
|
|
98
137
|
else
|
|
99
138
|
parse_direct_response(response)
|
|
100
139
|
end
|
|
101
140
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
102
141
|
raise
|
|
103
142
|
rescue JSON::ParserError => e
|
|
104
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
143
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
105
144
|
rescue Errno::ECONNREFUSED => e
|
|
106
145
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
|
107
146
|
rescue StandardError => e
|
|
108
147
|
method_name = request['method']
|
|
109
148
|
raise MCPClient::Errors::ToolCallError, "Error executing request '#{method_name}': #{e.message}"
|
|
149
|
+
ensure
|
|
150
|
+
unregister_pending_request(request['id'])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Mark a request id as awaiting its response.
|
|
155
|
+
# @param request_id [Integer, String] id of the outgoing request
|
|
156
|
+
# @return [void]
|
|
157
|
+
def register_pending_request(request_id)
|
|
158
|
+
@mutex.synchronize { @pending_request_ids.add(request_id) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Stop accepting responses for a request id (completed, failed or timed
|
|
162
|
+
# out) and drop any result that was never consumed — a late or duplicate
|
|
163
|
+
# response must not accumulate in @sse_results.
|
|
164
|
+
# @param request_id [Integer, String] id of the finished request
|
|
165
|
+
# @return [void]
|
|
166
|
+
def unregister_pending_request(request_id)
|
|
167
|
+
@mutex.synchronize do
|
|
168
|
+
@pending_request_ids.delete(request_id)
|
|
169
|
+
@sse_results.delete(request_id)
|
|
110
170
|
end
|
|
111
171
|
end
|
|
112
172
|
|
|
@@ -133,6 +193,8 @@ module MCPClient
|
|
|
133
193
|
end
|
|
134
194
|
|
|
135
195
|
response
|
|
196
|
+
rescue Faraday::TimeoutError => e
|
|
197
|
+
raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
|
|
136
198
|
rescue Faraday::ConnectionFailed => e
|
|
137
199
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
|
138
200
|
end
|
|
@@ -144,7 +206,7 @@ module MCPClient
|
|
|
144
206
|
def create_json_rpc_connection(base_url)
|
|
145
207
|
Faraday.new(url: base_url) do |f|
|
|
146
208
|
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
|
147
|
-
f.response :follow_redirects, limit: 3
|
|
209
|
+
f.response :follow_redirects, limit: 3, callback: method(:reject_cross_origin_redirect!)
|
|
148
210
|
f.options.open_timeout = @read_timeout
|
|
149
211
|
f.options.timeout = @read_timeout
|
|
150
212
|
f.adapter Faraday.default_adapter
|
|
@@ -160,6 +222,11 @@ module MCPClient
|
|
|
160
222
|
response = conn.post(endpoint) do |req|
|
|
161
223
|
req.headers['Content-Type'] = 'application/json'
|
|
162
224
|
req.headers['Accept'] = 'application/json'
|
|
225
|
+
# MCP lifecycle "Version Negotiation": the client MUST include the
|
|
226
|
+
# MCP-Protocol-Version header on all requests after the initialize
|
|
227
|
+
# handshake. The guard naturally skips the initialize POST itself,
|
|
228
|
+
# since the negotiated version is only known from its result.
|
|
229
|
+
req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
|
|
163
230
|
(@headers.dup.tap do |h|
|
|
164
231
|
h.delete('Accept')
|
|
165
232
|
h.delete('Cache-Control')
|
|
@@ -168,7 +235,7 @@ module MCPClient
|
|
|
168
235
|
end
|
|
169
236
|
|
|
170
237
|
msg = "Received JSON-RPC response: #{response.status}"
|
|
171
|
-
msg += " #{response.body}" if response.respond_to?(:body)
|
|
238
|
+
msg += " (#{describe_body_size(response.body)})" if response.respond_to?(:body)
|
|
172
239
|
@logger.debug(msg)
|
|
173
240
|
response
|
|
174
241
|
end
|
|
@@ -177,10 +244,10 @@ module MCPClient
|
|
|
177
244
|
# @param request [Hash] the original JSON-RPC request
|
|
178
245
|
# @return [Hash] the result data
|
|
179
246
|
# @raise [MCPClient::Errors::ConnectionError, MCPClient::Errors::ToolCallError] on errors
|
|
180
|
-
def wait_for_sse_result(request)
|
|
247
|
+
def wait_for_sse_result(request, timeout: nil)
|
|
181
248
|
request_id = request['id']
|
|
182
249
|
start_time = Time.now
|
|
183
|
-
timeout
|
|
250
|
+
timeout ||= @read_timeout || 10
|
|
184
251
|
|
|
185
252
|
ensure_sse_connection_active
|
|
186
253
|
|
|
@@ -222,12 +289,13 @@ module MCPClient
|
|
|
222
289
|
sleep 0.1
|
|
223
290
|
end
|
|
224
291
|
|
|
225
|
-
raise MCPClient::Errors::
|
|
292
|
+
raise MCPClient::Errors::RequestTimeoutError, "Timeout waiting for SSE result for request #{request_id}"
|
|
226
293
|
end
|
|
227
294
|
|
|
228
295
|
# Check if a result is available for the given request ID
|
|
229
296
|
# @param request_id [Integer] the request ID to check
|
|
230
297
|
# @return [Hash, nil] the result if available, nil otherwise
|
|
298
|
+
# @raise [MCPClient::Errors::ServerError] if the stored result is a JSON-RPC error response
|
|
231
299
|
def check_for_result(request_id)
|
|
232
300
|
result = nil
|
|
233
301
|
@mutex.synchronize do
|
|
@@ -236,12 +304,27 @@ module MCPClient
|
|
|
236
304
|
|
|
237
305
|
if result
|
|
238
306
|
record_activity
|
|
307
|
+
# SseParser#process_response? stores JSON-RPC error responses under
|
|
308
|
+
# the Symbol :error key; deliver them to the caller as ServerError
|
|
309
|
+
# (MCP lifecycle "Error Handling") instead of timing out.
|
|
310
|
+
raise_sse_error_response(result[:error]) if result.is_a?(Hash) && result.key?(:error)
|
|
239
311
|
return result
|
|
240
312
|
end
|
|
241
313
|
|
|
242
314
|
nil
|
|
243
315
|
end
|
|
244
316
|
|
|
317
|
+
# Raise a ServerError for a JSON-RPC error response received over SSE,
|
|
318
|
+
# mirroring JsonRpcCommon#process_jsonrpc_response for the other transports.
|
|
319
|
+
# @param error [Hash, nil] the JSON-RPC error object ('code', 'message', 'data')
|
|
320
|
+
# @raise [MCPClient::Errors::ServerError] always
|
|
321
|
+
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
|
|
326
|
+
end
|
|
327
|
+
|
|
245
328
|
# Parse a direct (non-SSE) JSON-RPC response
|
|
246
329
|
# @param response [Faraday::Response] the HTTP response
|
|
247
330
|
# @return [Hash] the parsed result
|
|
@@ -251,7 +334,7 @@ module MCPClient
|
|
|
251
334
|
data = JSON.parse(response.body)
|
|
252
335
|
process_jsonrpc_response(data)
|
|
253
336
|
rescue JSON::ParserError => e
|
|
254
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
337
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
255
338
|
end
|
|
256
339
|
end
|
|
257
340
|
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
|
|
@@ -88,9 +92,13 @@ module MCPClient
|
|
|
88
92
|
|
|
89
93
|
cleanup
|
|
90
94
|
|
|
91
|
-
# Clear any stale auth error from the previous connection
|
|
92
|
-
# not spuriously abort this reconnect via
|
|
93
|
-
|
|
95
|
+
# Clear any stale auth/connection error from the previous connection
|
|
96
|
+
# so it does not spuriously abort this reconnect via
|
|
97
|
+
# wait_for_connection.
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@auth_error = nil
|
|
100
|
+
@connection_error = nil
|
|
101
|
+
end
|
|
94
102
|
|
|
95
103
|
connect
|
|
96
104
|
@logger.info('Successfully reconnected after ping failures')
|
|
@@ -169,11 +177,14 @@ module MCPClient
|
|
|
169
177
|
deadline = Time.now + timeout
|
|
170
178
|
|
|
171
179
|
until @connection_established
|
|
180
|
+
break if @connection_error
|
|
181
|
+
|
|
172
182
|
remaining = [1, deadline - Time.now].min
|
|
173
183
|
break if remaining <= 0 || @connection_cv.wait(remaining) { @connection_established }
|
|
174
184
|
end
|
|
175
185
|
|
|
176
186
|
raise MCPClient::Errors::ConnectionError, @auth_error if @auth_error
|
|
187
|
+
raise MCPClient::Errors::ConnectionError, @connection_error if @connection_error
|
|
177
188
|
|
|
178
189
|
unless @connection_established
|
|
179
190
|
cleanup
|
|
@@ -195,7 +206,9 @@ module MCPClient
|
|
|
195
206
|
f.options.open_timeout = 10
|
|
196
207
|
f.options.timeout = nil
|
|
197
208
|
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
|
198
|
-
|
|
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!)
|
|
199
212
|
f.adapter Faraday.default_adapter
|
|
200
213
|
end
|
|
201
214
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require_relative 'origin_policy'
|
|
4
6
|
|
|
5
7
|
module MCPClient
|
|
6
8
|
class ServerSSE
|
|
7
9
|
# === Wire-level SSE parsing & dispatch ===
|
|
8
10
|
module SseParser
|
|
11
|
+
include OriginPolicy
|
|
12
|
+
|
|
9
13
|
# Parse and handle a raw SSE event payload.
|
|
10
14
|
# @param event_data [String] the raw event chunk
|
|
11
15
|
def parse_and_handle_sse_event(event_data)
|
|
@@ -30,7 +34,7 @@ module MCPClient
|
|
|
30
34
|
begin
|
|
31
35
|
data = JSON.parse(event[:data])
|
|
32
36
|
|
|
33
|
-
return if process_error_in_message(data)
|
|
37
|
+
return if process_error_in_message?(data)
|
|
34
38
|
return if process_server_request?(data)
|
|
35
39
|
return if process_notification?(data)
|
|
36
40
|
|
|
@@ -38,17 +42,23 @@ module MCPClient
|
|
|
38
42
|
rescue MCPClient::Errors::ConnectionError
|
|
39
43
|
raise
|
|
40
44
|
rescue JSON::ParserError => e
|
|
41
|
-
@logger.warn("Failed to parse JSON from event data: #{e
|
|
45
|
+
@logger.warn("Failed to parse JSON from event data: #{describe_parse_error(e, event[:data])}")
|
|
42
46
|
rescue StandardError => e
|
|
43
47
|
@logger.error("Error processing SSE event: #{e.message}")
|
|
44
48
|
end
|
|
45
49
|
end
|
|
46
50
|
|
|
47
|
-
# Process a JSON-RPC error
|
|
51
|
+
# Process a connection-level JSON-RPC error payload in the SSE stream.
|
|
52
|
+
# Error RESPONSES (id-bearing) belong to a pending request and are
|
|
53
|
+
# delivered to the waiting caller via process_response? instead, per the
|
|
54
|
+
# MCP lifecycle "Error Handling" section (implementations SHOULD handle
|
|
55
|
+
# error cases such as protocol version mismatch), so they must not be
|
|
56
|
+
# swallowed here.
|
|
48
57
|
# @param data [Hash] the parsed JSON payload
|
|
49
|
-
# @return [Boolean] true if we saw & handled an error
|
|
50
|
-
def process_error_in_message(data)
|
|
51
|
-
return unless data['error']
|
|
58
|
+
# @return [Boolean] true if we saw & handled an id-less error
|
|
59
|
+
def process_error_in_message?(data)
|
|
60
|
+
return false unless data['error']
|
|
61
|
+
return false if data['id']
|
|
52
62
|
|
|
53
63
|
error_message = data['error']['message'] || 'Unknown server error'
|
|
54
64
|
error_code = data['error']['code']
|
|
@@ -91,10 +101,21 @@ module MCPClient
|
|
|
91
101
|
# paginated list. Writing each page as it arrives would let a concurrent
|
|
92
102
|
# list_tools observe a partial (page-1-only) cache mid-pagination.
|
|
93
103
|
@mutex.synchronize do
|
|
104
|
+
# The stream is peer-controlled: only ids some caller is actually
|
|
105
|
+
# waiting on are stored. Without this check a server could stream
|
|
106
|
+
# unsolicited responses with fresh ids and grow @sse_results without
|
|
107
|
+
# bound for the lifetime of the client.
|
|
108
|
+
unless @pending_request_ids.include?(data['id'])
|
|
109
|
+
@logger.debug("Discarding unsolicited response id #{data['id'].inspect}")
|
|
110
|
+
return true
|
|
111
|
+
end
|
|
112
|
+
|
|
94
113
|
@sse_results[data['id']] =
|
|
95
114
|
if data['error']
|
|
96
|
-
|
|
97
|
-
|
|
115
|
+
# JSON-RPC error response: store the error under a Symbol key
|
|
116
|
+
# (JSON.parse only produces String keys, so this cannot collide
|
|
117
|
+
# with a success result) for the waiter to raise ServerError.
|
|
118
|
+
{ error: data['error'] }
|
|
98
119
|
else
|
|
99
120
|
data['result']
|
|
100
121
|
end
|
|
@@ -130,16 +151,63 @@ module MCPClient
|
|
|
130
151
|
has_content ? event : nil
|
|
131
152
|
end
|
|
132
153
|
|
|
133
|
-
# Handle the special "endpoint" control frame (for SSE handshake)
|
|
154
|
+
# Handle the special "endpoint" control frame (for SSE handshake).
|
|
155
|
+
# The event data is a URI reference (MCP 2024-11-05 HTTP with SSE: the
|
|
156
|
+
# server sends "an `endpoint` event containing a URI for the client to
|
|
157
|
+
# use for sending messages") which must be resolved against the SSE
|
|
158
|
+
# connection URL per RFC 3986 section 5.1.3, so relative endpoint URIs
|
|
159
|
+
# POST to the URL the server actually designated.
|
|
134
160
|
# @param data [String] the raw endpoint payload
|
|
135
161
|
def handle_endpoint_event(data)
|
|
162
|
+
endpoint = resolve_endpoint_uri(data)
|
|
136
163
|
@mutex.synchronize do
|
|
137
|
-
@rpc_endpoint =
|
|
164
|
+
@rpc_endpoint = endpoint
|
|
138
165
|
@sse_connected = true
|
|
139
166
|
@connection_established = true
|
|
140
167
|
@connection_cv.broadcast
|
|
141
168
|
end
|
|
142
169
|
end
|
|
170
|
+
|
|
171
|
+
# Resolve an endpoint URI reference against the SSE connection URL.
|
|
172
|
+
# The resolved endpoint MUST stay on the SSE connection's origin: the
|
|
173
|
+
# event payload is server-controlled input, and honoring a cross-origin
|
|
174
|
+
# target would redirect every JSON-RPC POST — including the configured
|
|
175
|
+
# Authorization/API-key headers and callback response bodies — to a
|
|
176
|
+
# server the caller never chose.
|
|
177
|
+
# @param data [String] the endpoint event payload (absolute or relative URI)
|
|
178
|
+
# @return [String] the absolute endpoint URL
|
|
179
|
+
def resolve_endpoint_uri(data)
|
|
180
|
+
endpoint = URI.join(@base_url, data)
|
|
181
|
+
base = URI.parse(@base_url)
|
|
182
|
+
unless same_origin?(base, endpoint)
|
|
183
|
+
fail_endpoint_handshake!(
|
|
184
|
+
"Cross-origin endpoint in SSE endpoint event: #{data.inspect} " \
|
|
185
|
+
"does not match the connection origin #{origin_of(base)}"
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
endpoint.to_s
|
|
189
|
+
rescue URI::Error => e
|
|
190
|
+
# The endpoint event is the handshake's core payload; an unresolvable
|
|
191
|
+
# URI must fail the handshake rather than deferring a broken POST
|
|
192
|
+
# target to the first request.
|
|
193
|
+
@logger.error("Failed to resolve endpoint URI #{data.inspect} against #{@base_url}: #{e.message}")
|
|
194
|
+
fail_endpoint_handshake!("Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Record the handshake failure cause and raise. The SSE worker thread
|
|
198
|
+
# swallows this exception with a generic rescue, so also record the
|
|
199
|
+
# failure (mirroring @auth_error) for the connect caller blocked in
|
|
200
|
+
# wait_for_connection to surface promptly.
|
|
201
|
+
# @param message [String] the failure description
|
|
202
|
+
# @raise [MCPClient::Errors::TransportError] always
|
|
203
|
+
def fail_endpoint_handshake!(message)
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
@connection_error = message
|
|
206
|
+
@connection_established = false
|
|
207
|
+
@connection_cv.broadcast
|
|
208
|
+
end
|
|
209
|
+
raise MCPClient::Errors::TransportError, message
|
|
210
|
+
end
|
|
143
211
|
end
|
|
144
212
|
end
|
|
145
213
|
end
|