ruby-mcp-client 2.0.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 +95 -7
- data/lib/mcp_client/auth/oauth_provider.rb +91 -3
- data/lib/mcp_client/auth.rb +0 -4
- data/lib/mcp_client/client.rb +109 -14
- data/lib/mcp_client/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +8 -0
- data/lib/mcp_client/http_transport_base.rb +29 -6
- data/lib/mcp_client/json_rpc_common.rb +70 -1
- data/lib/mcp_client/resource.rb +0 -2
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +0 -2
- data/lib/mcp_client/schema_validator.rb +56 -12
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +34 -6
- 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 +38 -7
- data/lib/mcp_client/server_sse.rb +83 -14
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +2 -2
- data/lib/mcp_client/server_stdio.rb +6 -3
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +77 -7
- data/lib/mcp_client/server_streamable_http.rb +246 -43
- data/lib/mcp_client/tool.rb +0 -2
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +8 -1
- metadata +4 -6
|
@@ -33,7 +33,7 @@ module MCPClient
|
|
|
33
33
|
def rpc_request(method, params = {}, timeout: nil)
|
|
34
34
|
ensure_connected
|
|
35
35
|
|
|
36
|
-
with_retry do
|
|
36
|
+
with_retry(method) do
|
|
37
37
|
request_id = @mutex.synchronize { @request_id += 1 }
|
|
38
38
|
request = build_jsonrpc_request(method, params, request_id)
|
|
39
39
|
begin
|
|
@@ -112,6 +112,29 @@ module MCPClient
|
|
|
112
112
|
end
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# Resend a request against the freshly restarted session — unless doing so
|
|
116
|
+
# could execute a side effect twice.
|
|
117
|
+
#
|
|
118
|
+
# A 404 usually means the server rejected the request outright, but it does
|
|
119
|
+
# not prove that: a session can expire after the tool ran. Automatic
|
|
120
|
+
# session recovery is worth having for idempotent methods, and would
|
|
121
|
+
# otherwise be a hole straight through the no-replay guarantee that
|
|
122
|
+
# with_retry enforces for NON_IDEMPOTENT_METHODS.
|
|
123
|
+
#
|
|
124
|
+
# Raises ConnectionError (which with_retry never retries) so no other path
|
|
125
|
+
# can turn this into a second attempt.
|
|
126
|
+
# @param request [Hash] the JSON-RPC request that hit the expired session
|
|
127
|
+
# @return [Faraday::Response] the response to the resent request
|
|
128
|
+
# @raise [MCPClient::Errors::ConnectionError] for a non-idempotent method
|
|
129
|
+
def resend_after_session_restart(request)
|
|
130
|
+
method = request['method']
|
|
131
|
+
return send_http_request(request) unless NON_IDEMPOTENT_METHODS.include?(method)
|
|
132
|
+
|
|
133
|
+
raise MCPClient::Errors::ConnectionError,
|
|
134
|
+
"Session expired during #{method}; a new session was started but the request was NOT resent " \
|
|
135
|
+
'because it may already have executed. Retry it explicitly if that is safe.'
|
|
136
|
+
end
|
|
137
|
+
|
|
115
138
|
# Validate session ID format
|
|
116
139
|
# Per MCP 2025-11-25, the server-assigned session ID "MUST only contain
|
|
117
140
|
# visible ASCII characters (ranging from 0x21 to 0x7E)" — e.g. a UUID, a
|
|
@@ -181,7 +204,7 @@ module MCPClient
|
|
|
181
204
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
182
205
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
183
206
|
def send_jsonrpc_request(request, timeout: nil)
|
|
184
|
-
@logger.debug("Sending JSON-RPC request: #{request
|
|
207
|
+
@logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
|
|
185
208
|
|
|
186
209
|
begin
|
|
187
210
|
response = send_http_request(request, timeout: timeout)
|
|
@@ -189,7 +212,7 @@ module MCPClient
|
|
|
189
212
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
190
213
|
raise
|
|
191
214
|
rescue JSON::ParserError => e
|
|
192
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
215
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
193
216
|
rescue Errno::ECONNREFUSED => e
|
|
194
217
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
|
195
218
|
rescue StandardError => e
|
|
@@ -274,14 +297,14 @@ module MCPClient
|
|
|
274
297
|
# Recheck now that the monitor is held: another caller may already
|
|
275
298
|
# have restarted the session while this one waited. If so, skip the
|
|
276
299
|
# extra initialize and just resend against the fresh session.
|
|
277
|
-
return
|
|
300
|
+
return resend_after_session_restart(request) if @session_id != expired_session_id
|
|
278
301
|
|
|
279
302
|
@logger.warn("Session #{@session_id} no longer valid (HTTP 404); starting a new session")
|
|
280
303
|
@restarting_session = true
|
|
281
304
|
@session_id = nil
|
|
282
305
|
@last_event_id = nil if instance_variable_defined?(:@last_event_id)
|
|
283
306
|
perform_initialize
|
|
284
|
-
|
|
307
|
+
resend_after_session_restart(request)
|
|
285
308
|
ensure
|
|
286
309
|
@restarting_session = false
|
|
287
310
|
end
|
|
@@ -471,7 +494,7 @@ module MCPClient
|
|
|
471
494
|
# Log HTTP response (to be overridden by specific transports)
|
|
472
495
|
# @param response [Faraday::Response] the HTTP response
|
|
473
496
|
def log_response(response)
|
|
474
|
-
@logger.debug("Received HTTP response: #{response.status} #{response.body}")
|
|
497
|
+
@logger.debug("Received HTTP response: #{response.status} (#{describe_body_size(response.body)})")
|
|
475
498
|
end
|
|
476
499
|
|
|
477
500
|
# Parse HTTP response (to be implemented by specific transports)
|
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
module MCPClient
|
|
4
4
|
# Shared retry/backoff logic for JSON-RPC transports
|
|
5
5
|
module JsonRpcCommon
|
|
6
|
+
# JSON-RPC methods with arbitrary side effects that MUST NOT be re-sent
|
|
7
|
+
# automatically. Even a "transient" failure (5xx, dropped connection,
|
|
8
|
+
# malformed response) can arrive AFTER the server received the request,
|
|
9
|
+
# so a retry could execute the operation twice — and JSON-RPC has no
|
|
10
|
+
# idempotency key to make the duplicate safe. Callers who want to retry
|
|
11
|
+
# such an operation must decide that explicitly.
|
|
12
|
+
NON_IDEMPOTENT_METHODS = %w[tools/call].freeze
|
|
13
|
+
|
|
6
14
|
# Execute the block with retry/backoff for transient errors only.
|
|
7
15
|
#
|
|
8
16
|
# Retries genuinely transient failures where the request most likely did not
|
|
@@ -14,10 +22,15 @@ module MCPClient
|
|
|
14
22
|
# server received and processed (or deterministically rejected) the request.
|
|
15
23
|
# Re-sending those would silently re-execute a non-idempotent operation
|
|
16
24
|
# (e.g. a tools/call), which JSON-RPC provides no way to make safe.
|
|
25
|
+
#
|
|
26
|
+
# It also never retries a NON_IDEMPOTENT_METHODS request (pass the
|
|
27
|
+
# JSON-RPC method being sent): an ambiguous failure may follow server-side
|
|
28
|
+
# receipt, so those fail fast instead of risking a duplicate execution.
|
|
29
|
+
# @param method [String, nil] the JSON-RPC method the block sends
|
|
17
30
|
# @yield block to execute
|
|
18
31
|
# @return [Object] result of block
|
|
19
32
|
# @raise original exception if max retries exceeded or the error is not retryable
|
|
20
|
-
def with_retry
|
|
33
|
+
def with_retry(method = nil)
|
|
21
34
|
attempts = 0
|
|
22
35
|
begin
|
|
23
36
|
yield
|
|
@@ -25,7 +38,16 @@ module MCPClient
|
|
|
25
38
|
Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE => e
|
|
26
39
|
# A timed-out request may still be executing server-side; re-sending
|
|
27
40
|
# it could run a non-idempotent operation twice. Never retry those.
|
|
41
|
+
# An oversized response is the same story from the other direction:
|
|
42
|
+
# the server already ran the request, so a re-send risks a duplicate
|
|
43
|
+
# side effect (and re-does the oversized decode).
|
|
28
44
|
raise if e.is_a?(MCPClient::Errors::RequestTimeoutError)
|
|
45
|
+
raise if e.is_a?(MCPClient::Errors::ResponseTooLargeError)
|
|
46
|
+
|
|
47
|
+
if NON_IDEMPOTENT_METHODS.include?(method)
|
|
48
|
+
@logger.debug("Not retrying non-idempotent #{method} after error: #{e.message}")
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
29
51
|
|
|
30
52
|
attempts += 1
|
|
31
53
|
if attempts <= @max_retries
|
|
@@ -38,6 +60,53 @@ module MCPClient
|
|
|
38
60
|
end
|
|
39
61
|
end
|
|
40
62
|
|
|
63
|
+
# A log-safe description of a JSON-RPC message: its method and id only.
|
|
64
|
+
#
|
|
65
|
+
# Params and results are deliberately omitted. tools/call arguments and
|
|
66
|
+
# tool results routinely carry credentials, personal data or customer
|
|
67
|
+
# content, and logs are frequently shipped to lower-trust destinations
|
|
68
|
+
# (aggregators, CI artifacts, support bundles) — so enabling DEBUG must
|
|
69
|
+
# not silently start recording payloads.
|
|
70
|
+
# @param message [Hash] a JSON-RPC request, notification or response
|
|
71
|
+
# @return [String] method/id summary, never payload content
|
|
72
|
+
def describe_jsonrpc_message(message)
|
|
73
|
+
return '(non-object message)' unless message.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
parts = []
|
|
76
|
+
parts << (message['method'] || message[:method] || '(response)').to_s
|
|
77
|
+
id = message['id'] || message[:id]
|
|
78
|
+
parts << "id=#{id}" if id
|
|
79
|
+
parts << 'error' if message['error'] || message[:error]
|
|
80
|
+
parts.join(' ')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A log-safe description of a JSON parse failure.
|
|
84
|
+
#
|
|
85
|
+
# JSON::ParserError#message quotes the offending token — e.g.
|
|
86
|
+
# "expected object key, got 'SECRET-123' at line 1 column 2" — so
|
|
87
|
+
# interpolating it puts peer-controlled bytes straight into logs and
|
|
88
|
+
# exception messages. Keep the position, which is what actually helps
|
|
89
|
+
# diagnose a broken server, and drop the quoted content.
|
|
90
|
+
# @param error [JSON::ParserError] the parse failure
|
|
91
|
+
# @param payload [String, nil] the payload that failed to parse
|
|
92
|
+
# @return [String] position and size, never payload content
|
|
93
|
+
def describe_parse_error(error, payload = nil)
|
|
94
|
+
location = error.message[/at line \d+ column \d+/]
|
|
95
|
+
parts = ['malformed JSON']
|
|
96
|
+
parts << location if location
|
|
97
|
+
parts << describe_body_size(payload) if payload
|
|
98
|
+
parts.join(', ')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A log-safe description of a payload body: its size, never its content.
|
|
102
|
+
# @param body [String, nil] the response/request body
|
|
103
|
+
# @return [String]
|
|
104
|
+
def describe_body_size(body)
|
|
105
|
+
return 'empty body' if body.nil? || body.empty?
|
|
106
|
+
|
|
107
|
+
"#{body.bytesize} bytes"
|
|
108
|
+
end
|
|
109
|
+
|
|
41
110
|
# Ping the server to keep the connection alive
|
|
42
111
|
# @return [Hash] the result of the ping request
|
|
43
112
|
# @raise [MCPClient::Errors::ToolCallError] if ping times out or fails
|
data/lib/mcp_client/resource.rb
CHANGED
|
@@ -36,7 +36,6 @@ module MCPClient
|
|
|
36
36
|
# @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
|
|
37
37
|
# @param meta [Hash, nil] optional `_meta` metadata attached to the resource (MCP 2025-11-25)
|
|
38
38
|
# @param server [MCPClient::ServerBase, nil] the server this resource belongs to
|
|
39
|
-
# rubocop:disable Metrics/ParameterLists
|
|
40
39
|
def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil,
|
|
41
40
|
icons: nil, meta: nil, server: nil)
|
|
42
41
|
@uri = uri
|
|
@@ -50,7 +49,6 @@ module MCPClient
|
|
|
50
49
|
@meta = meta
|
|
51
50
|
@server = server
|
|
52
51
|
end
|
|
53
|
-
# rubocop:enable Metrics/ParameterLists
|
|
54
52
|
|
|
55
53
|
# Return the lastModified annotation value (ISO 8601 timestamp string)
|
|
56
54
|
# @return [String, nil] the lastModified timestamp, or nil if not set
|
|
@@ -35,7 +35,6 @@ module MCPClient
|
|
|
35
35
|
# @param size [Integer, nil] optional size of the resource in bytes
|
|
36
36
|
# @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
|
|
37
37
|
# @param meta [Hash, nil] optional `_meta` metadata attached to the resource link (MCP 2025-11-25)
|
|
38
|
-
# rubocop:disable Metrics/ParameterLists
|
|
39
38
|
def initialize(uri:, name:, description: nil, mime_type: nil, annotations: nil, title: nil, size: nil,
|
|
40
39
|
icons: nil, meta: nil)
|
|
41
40
|
@uri = uri
|
|
@@ -48,7 +47,6 @@ module MCPClient
|
|
|
48
47
|
@icons = icons
|
|
49
48
|
@meta = meta
|
|
50
49
|
end
|
|
51
|
-
# rubocop:enable Metrics/ParameterLists
|
|
52
50
|
|
|
53
51
|
# Create a ResourceLink instance from JSON data
|
|
54
52
|
# @param data [Hash] JSON data from MCP server (content item with type 'resource_link')
|
|
@@ -34,7 +34,6 @@ module MCPClient
|
|
|
34
34
|
# @param icons [Array<Hash>, nil] optional icons for display in user interfaces (MCP 2025-11-25)
|
|
35
35
|
# @param meta [Hash, nil] optional `_meta` metadata attached to the resource template (MCP 2025-11-25)
|
|
36
36
|
# @param server [MCPClient::ServerBase, nil] the server this resource template belongs to
|
|
37
|
-
# rubocop:disable Metrics/ParameterLists
|
|
38
37
|
def initialize(uri_template:, name:, title: nil, description: nil, mime_type: nil, annotations: nil,
|
|
39
38
|
icons: nil, meta: nil, server: nil)
|
|
40
39
|
@uri_template = uri_template
|
|
@@ -47,7 +46,6 @@ module MCPClient
|
|
|
47
46
|
@meta = meta
|
|
48
47
|
@server = server
|
|
49
48
|
end
|
|
50
|
-
# rubocop:enable Metrics/ParameterLists
|
|
51
49
|
|
|
52
50
|
# Create a ResourceTemplate instance from JSON data
|
|
53
51
|
# @param data [Hash] JSON data from MCP server
|
|
@@ -35,6 +35,19 @@ module MCPClient
|
|
|
35
35
|
unevaluatedProperties unevaluatedItems
|
|
36
36
|
].freeze
|
|
37
37
|
|
|
38
|
+
# Wall-clock budget for ALL pattern matching in a single validate call.
|
|
39
|
+
# Schemas come from the remote server, so an expensive expression must not
|
|
40
|
+
# be able to monopolize the calling thread.
|
|
41
|
+
#
|
|
42
|
+
# The budget is for the whole operation, not per match: a per-match limit
|
|
43
|
+
# multiplies, since the server also controls how many strings it sends
|
|
44
|
+
# (N array items under one pathological items.pattern costs N x limit).
|
|
45
|
+
PATTERN_MATCH_TIMEOUT = 1.0
|
|
46
|
+
|
|
47
|
+
# Floor for an individual match's timeout, so a nearly-exhausted budget
|
|
48
|
+
# still makes progress rather than failing every remaining pattern.
|
|
49
|
+
MIN_PATTERN_MATCH_TIMEOUT = 0.01
|
|
50
|
+
|
|
38
51
|
# Keywords whose value is a single subschema to walk.
|
|
39
52
|
SUBSCHEMA_KEYWORDS = %w[
|
|
40
53
|
items contains additionalProperties propertyNames not if then else
|
|
@@ -85,17 +98,20 @@ module MCPClient
|
|
|
85
98
|
# @param schema [Hash] the JSON schema
|
|
86
99
|
# @param path [String] JSON-pointer-style location used in error messages
|
|
87
100
|
# @return [Array<String>] human-readable validation errors (empty if valid)
|
|
88
|
-
def self.validate(data, schema, path: '#')
|
|
101
|
+
def self.validate(data, schema, path: '#', deadline: nil)
|
|
89
102
|
return [] unless schema.is_a?(Hash)
|
|
90
103
|
|
|
104
|
+
# One deadline covers the entire (recursive) validation.
|
|
105
|
+
deadline ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT
|
|
106
|
+
|
|
91
107
|
schema = schema.transform_keys(&:to_s)
|
|
92
108
|
errors = []
|
|
93
109
|
errors.concat(validate_type(data, schema['type'], path)) if schema.key?('type')
|
|
94
110
|
errors.concat(validate_enum(data, schema, path))
|
|
95
111
|
case data
|
|
96
|
-
when Hash then errors.concat(validate_object(data, schema, path))
|
|
97
|
-
when Array then errors.concat(validate_array(data, schema, path))
|
|
98
|
-
when String then errors.concat(validate_string(data, schema, path))
|
|
112
|
+
when Hash then errors.concat(validate_object(data, schema, path, deadline))
|
|
113
|
+
when Array then errors.concat(validate_array(data, schema, path, deadline))
|
|
114
|
+
when String then errors.concat(validate_string(data, schema, path, deadline))
|
|
99
115
|
when Numeric then errors.concat(validate_number(data, schema, path))
|
|
100
116
|
end
|
|
101
117
|
errors
|
|
@@ -179,7 +195,7 @@ module MCPClient
|
|
|
179
195
|
# @param schema [Hash] string-keyed schema
|
|
180
196
|
# @param path [String] location for error messages
|
|
181
197
|
# @return [Array<String>] validation errors
|
|
182
|
-
def self.validate_object(data, schema, path)
|
|
198
|
+
def self.validate_object(data, schema, path, deadline = nil)
|
|
183
199
|
errors = []
|
|
184
200
|
Array(schema['required']).each do |raw_name|
|
|
185
201
|
name = raw_name.to_s
|
|
@@ -199,7 +215,7 @@ module MCPClient
|
|
|
199
215
|
end
|
|
200
216
|
next if key.nil?
|
|
201
217
|
|
|
202
|
-
errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}"))
|
|
218
|
+
errors.concat(validate(data[key], prop_schema, path: "#{path}/#{name}", deadline: deadline))
|
|
203
219
|
end
|
|
204
220
|
errors
|
|
205
221
|
end
|
|
@@ -209,7 +225,7 @@ module MCPClient
|
|
|
209
225
|
# @param schema [Hash] string-keyed schema
|
|
210
226
|
# @param path [String] location for error messages
|
|
211
227
|
# @return [Array<String>] validation errors
|
|
212
|
-
def self.validate_array(data, schema, path)
|
|
228
|
+
def self.validate_array(data, schema, path, deadline = nil)
|
|
213
229
|
errors = []
|
|
214
230
|
min_items = schema['minItems']
|
|
215
231
|
max_items = schema['maxItems']
|
|
@@ -221,7 +237,9 @@ module MCPClient
|
|
|
221
237
|
end
|
|
222
238
|
items = schema['items']
|
|
223
239
|
if items.is_a?(Hash)
|
|
224
|
-
data.each_with_index
|
|
240
|
+
data.each_with_index do |item, idx|
|
|
241
|
+
errors.concat(validate(item, items, path: "#{path}/#{idx}", deadline: deadline))
|
|
242
|
+
end
|
|
225
243
|
end
|
|
226
244
|
errors
|
|
227
245
|
end
|
|
@@ -231,7 +249,7 @@ module MCPClient
|
|
|
231
249
|
# @param schema [Hash] string-keyed schema
|
|
232
250
|
# @param path [String] location for error messages
|
|
233
251
|
# @return [Array<String>] validation errors
|
|
234
|
-
def self.validate_string(data, schema, path)
|
|
252
|
+
def self.validate_string(data, schema, path, deadline = nil)
|
|
235
253
|
errors = []
|
|
236
254
|
min_length = schema['minLength']
|
|
237
255
|
max_length = schema['maxLength']
|
|
@@ -241,25 +259,51 @@ module MCPClient
|
|
|
241
259
|
if max_length.is_a?(Numeric) && data.length > max_length
|
|
242
260
|
errors << "#{path}: string is longer than maxLength #{max_length}"
|
|
243
261
|
end
|
|
244
|
-
errors.concat(validate_pattern(data, schema['pattern'], path))
|
|
262
|
+
errors.concat(validate_pattern(data, schema['pattern'], path, deadline))
|
|
245
263
|
errors
|
|
246
264
|
end
|
|
247
265
|
|
|
248
266
|
# Validate a string against a regular-expression pattern.
|
|
249
267
|
# Invalid patterns are not enforced.
|
|
268
|
+
#
|
|
269
|
+
# The pattern comes from the tool's outputSchema, i.e. from the remote
|
|
270
|
+
# server, so matching runs against the validation-wide deadline: neither a
|
|
271
|
+
# single expensive expression nor many cheap-looking ones can pin the
|
|
272
|
+
# calling thread. A match that exceeds the budget is reported as a
|
|
273
|
+
# validation error rather than silently accepted — the value was never
|
|
274
|
+
# shown to satisfy the schema.
|
|
250
275
|
# @param data [String] the string
|
|
251
276
|
# @param pattern [Object] the pattern keyword value
|
|
252
277
|
# @param path [String] location for error messages
|
|
278
|
+
# @param deadline [Float, nil] monotonic deadline for the whole validation
|
|
253
279
|
# @return [Array<String>] validation errors
|
|
254
|
-
def self.validate_pattern(data, pattern, path)
|
|
280
|
+
def self.validate_pattern(data, pattern, path, deadline = nil)
|
|
255
281
|
return [] unless pattern.is_a?(String)
|
|
256
|
-
|
|
282
|
+
|
|
283
|
+
remaining = pattern_budget_remaining(deadline)
|
|
284
|
+
return ["#{path}: pattern matching budget exhausted before #{pattern.inspect}"] if remaining.zero?
|
|
285
|
+
|
|
286
|
+
return [] if data.match?(Regexp.new(pattern, timeout: remaining))
|
|
257
287
|
|
|
258
288
|
["#{path}: string does not match pattern #{pattern.inspect}"]
|
|
289
|
+
rescue Regexp::TimeoutError
|
|
290
|
+
["#{path}: pattern #{pattern.inspect} exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"]
|
|
259
291
|
rescue RegexpError
|
|
260
292
|
[]
|
|
261
293
|
end
|
|
262
294
|
|
|
295
|
+
# Time left in the validation-wide pattern budget.
|
|
296
|
+
# @param deadline [Float, nil] monotonic deadline, or nil for a lone match
|
|
297
|
+
# @return [Float] seconds available for the next match; 0.0 when exhausted
|
|
298
|
+
def self.pattern_budget_remaining(deadline)
|
|
299
|
+
return PATTERN_MATCH_TIMEOUT unless deadline
|
|
300
|
+
|
|
301
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
302
|
+
return 0.0 if remaining <= 0
|
|
303
|
+
|
|
304
|
+
[remaining, MIN_PATTERN_MATCH_TIMEOUT].max
|
|
305
|
+
end
|
|
306
|
+
|
|
263
307
|
# Validate a number against inclusive/exclusive bounds.
|
|
264
308
|
# @param data [Numeric] the number
|
|
265
309
|
# @param schema [Hash] string-keyed schema
|
|
@@ -99,7 +99,10 @@ module MCPClient
|
|
|
99
99
|
name: config[:name],
|
|
100
100
|
logger: logger,
|
|
101
101
|
oauth_provider: config[:oauth_provider],
|
|
102
|
-
faraday_config: config[:faraday_config]
|
|
102
|
+
faraday_config: config[:faraday_config],
|
|
103
|
+
max_decompressed_body_bytes:
|
|
104
|
+
config[:max_decompressed_body_bytes] ||
|
|
105
|
+
MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
|
|
103
106
|
)
|
|
104
107
|
end
|
|
105
108
|
|
|
@@ -21,7 +21,7 @@ module MCPClient
|
|
|
21
21
|
data = JSON.parse(body)
|
|
22
22
|
process_jsonrpc_response(data)
|
|
23
23
|
rescue JSON::ParserError => e
|
|
24
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
24
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -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
|
|
@@ -19,7 +22,7 @@ module MCPClient
|
|
|
19
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
|
|
@@ -119,8 +122,12 @@ module MCPClient
|
|
|
119
122
|
# @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
|
|
120
123
|
# @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
|
|
121
124
|
def send_jsonrpc_request(request, timeout: nil)
|
|
122
|
-
@logger.debug("Sending JSON-RPC request: #{request
|
|
125
|
+
@logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
|
|
123
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'])
|
|
124
131
|
|
|
125
132
|
begin
|
|
126
133
|
response = post_json_rpc_request(request)
|
|
@@ -133,12 +140,33 @@ module MCPClient
|
|
|
133
140
|
rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
|
|
134
141
|
raise
|
|
135
142
|
rescue JSON::ParserError => e
|
|
136
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
143
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
137
144
|
rescue Errno::ECONNREFUSED => e
|
|
138
145
|
raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
|
|
139
146
|
rescue StandardError => e
|
|
140
147
|
method_name = request['method']
|
|
141
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)
|
|
142
170
|
end
|
|
143
171
|
end
|
|
144
172
|
|
|
@@ -178,7 +206,7 @@ module MCPClient
|
|
|
178
206
|
def create_json_rpc_connection(base_url)
|
|
179
207
|
Faraday.new(url: base_url) do |f|
|
|
180
208
|
f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
|
|
181
|
-
f.response :follow_redirects, limit: 3
|
|
209
|
+
f.response :follow_redirects, limit: 3, callback: method(:reject_cross_origin_redirect!)
|
|
182
210
|
f.options.open_timeout = @read_timeout
|
|
183
211
|
f.options.timeout = @read_timeout
|
|
184
212
|
f.adapter Faraday.default_adapter
|
|
@@ -207,7 +235,7 @@ module MCPClient
|
|
|
207
235
|
end
|
|
208
236
|
|
|
209
237
|
msg = "Received JSON-RPC response: #{response.status}"
|
|
210
|
-
msg += " #{response.body}" if response.respond_to?(:body)
|
|
238
|
+
msg += " (#{describe_body_size(response.body)})" if response.respond_to?(:body)
|
|
211
239
|
@logger.debug(msg)
|
|
212
240
|
response
|
|
213
241
|
end
|
|
@@ -306,7 +334,7 @@ module MCPClient
|
|
|
306
334
|
data = JSON.parse(response.body)
|
|
307
335
|
process_jsonrpc_response(data)
|
|
308
336
|
rescue JSON::ParserError => e
|
|
309
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
337
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
310
338
|
end
|
|
311
339
|
end
|
|
312
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
|
|
@@ -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,11 +2,14 @@
|
|
|
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
|
def parse_and_handle_sse_event(event_data)
|
|
@@ -39,7 +42,7 @@ module MCPClient
|
|
|
39
42
|
rescue MCPClient::Errors::ConnectionError
|
|
40
43
|
raise
|
|
41
44
|
rescue JSON::ParserError => e
|
|
42
|
-
@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])}")
|
|
43
46
|
rescue StandardError => e
|
|
44
47
|
@logger.error("Error processing SSE event: #{e.message}")
|
|
45
48
|
end
|
|
@@ -98,6 +101,15 @@ module MCPClient
|
|
|
98
101
|
# paginated list. Writing each page as it arrives would let a concurrent
|
|
99
102
|
# list_tools observe a partial (page-1-only) cache mid-pagination.
|
|
100
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
|
+
|
|
101
113
|
@sse_results[data['id']] =
|
|
102
114
|
if data['error']
|
|
103
115
|
# JSON-RPC error response: store the error under a Symbol key
|
|
@@ -156,20 +168,39 @@ module MCPClient
|
|
|
156
168
|
end
|
|
157
169
|
end
|
|
158
170
|
|
|
159
|
-
# Resolve an endpoint URI reference against the SSE connection URL
|
|
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.
|
|
160
177
|
# @param data [String] the endpoint event payload (absolute or relative URI)
|
|
161
178
|
# @return [String] the absolute endpoint URL
|
|
162
179
|
def resolve_endpoint_uri(data)
|
|
163
|
-
URI.join(@base_url, 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
|
|
164
189
|
rescue URI::Error => e
|
|
165
190
|
# The endpoint event is the handshake's core payload; an unresolvable
|
|
166
191
|
# URI must fail the handshake rather than deferring a broken POST
|
|
167
192
|
# target to the first request.
|
|
168
193
|
@logger.error("Failed to resolve endpoint URI #{data.inspect} against #{@base_url}: #{e.message}")
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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)
|
|
173
204
|
@mutex.synchronize do
|
|
174
205
|
@connection_error = message
|
|
175
206
|
@connection_established = false
|