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.
@@ -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.to_json}")
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.message}"
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 send_http_request(request) if @session_id != expired_session_id
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
- send_http_request(request)
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
@@ -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 { |item, idx| errors.concat(validate(item, items, path: "#{path}/#{idx}")) }
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
- return [] if data.match?(Regexp.new(pattern))
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.message}"
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.to_json}")
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.message}"
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.message}"
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
- f.response :follow_redirects, limit: 3
209
+ # Same origin pinning as the RPC connection: the SSE stream carries
210
+ # the configured credential headers too.
211
+ f.response :follow_redirects, limit: 3, callback: method(:reject_cross_origin_redirect!)
206
212
  f.adapter Faraday.default_adapter
207
213
  end
208
214
 
@@ -2,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.message}")
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).to_s
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
- message = "Invalid endpoint URI in SSE endpoint event: #{data.inspect} (#{e.message})"
170
- # The SSE worker thread swallows this exception with a generic rescue,
171
- # so also record the failure cause (mirroring @auth_error) for the
172
- # connect caller blocked in wait_for_connection to surface promptly.
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