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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +216 -10
  3. data/lib/mcp_client/auth/oauth_provider.rb +325 -27
  4. data/lib/mcp_client/auth.rb +38 -11
  5. data/lib/mcp_client/client.rb +523 -150
  6. data/lib/mcp_client/elicitation_validator.rb +99 -13
  7. data/lib/mcp_client/errors.rb +43 -1
  8. data/lib/mcp_client/http_transport_base.rb +254 -41
  9. data/lib/mcp_client/json_rpc_common.rb +196 -14
  10. data/lib/mcp_client/oauth_client.rb +8 -3
  11. data/lib/mcp_client/prompt.rb +17 -2
  12. data/lib/mcp_client/resource.rb +13 -2
  13. data/lib/mcp_client/resource_content.rb +8 -3
  14. data/lib/mcp_client/resource_link.rb +14 -3
  15. data/lib/mcp_client/resource_template.rb +13 -2
  16. data/lib/mcp_client/root.rb +61 -7
  17. data/lib/mcp_client/schema_validator.rb +329 -0
  18. data/lib/mcp_client/server_base.rb +66 -0
  19. data/lib/mcp_client/server_factory.rb +4 -1
  20. data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
  21. data/lib/mcp_client/server_http.rb +18 -12
  22. data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
  23. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  24. data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
  25. data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
  26. data/lib/mcp_client/server_sse.rb +132 -35
  27. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
  28. data/lib/mcp_client/server_stdio.rb +98 -20
  29. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
  30. data/lib/mcp_client/server_streamable_http.rb +472 -108
  31. data/lib/mcp_client/tool.rb +16 -3
  32. data/lib/mcp_client/version.rb +6 -1
  33. data/lib/mcp_client.rb +9 -1
  34. metadata +5 -6
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+ require 'date'
5
+ require 'time'
6
+
3
7
  module MCPClient
4
8
  # Validates elicitation schemas and content per MCP 2025-11-25 spec.
5
9
  # Schemas are restricted to flat objects with primitive property types:
@@ -14,6 +18,18 @@ module MCPClient
14
18
  # Allowed string formats per MCP spec
15
19
  STRING_FORMATS = %w[email uri date date-time].freeze
16
20
 
21
+ # Wall-clock budget for ALL pattern matching in a single validate_content
22
+ # call. The requestedSchema comes from the remote server, so an expensive
23
+ # expression must not be able to monopolize the calling thread.
24
+ #
25
+ # The budget covers the whole operation, not each match: a per-match limit
26
+ # multiplies, since the server also controls how many fields it declares.
27
+ PATTERN_MATCH_TIMEOUT = 1.0
28
+
29
+ # Floor for an individual match, so a nearly-exhausted budget still makes
30
+ # progress rather than failing every remaining field.
31
+ MIN_PATTERN_MATCH_TIMEOUT = 0.01
32
+
17
33
  # Validate that a requestedSchema conforms to MCP elicitation constraints.
18
34
  # Returns an array of error messages (empty if valid).
19
35
  # @param schema [Hash] the requestedSchema
@@ -114,6 +130,9 @@ module MCPClient
114
130
  errors = []
115
131
  return errors unless content.is_a?(Hash) && schema.is_a?(Hash)
116
132
 
133
+ # One deadline covers every field's pattern in this call.
134
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT
135
+
117
136
  properties = schema['properties'] || {}
118
137
  required = Array(schema['required'])
119
138
 
@@ -128,7 +147,7 @@ module MCPClient
128
147
  prop = properties[field.to_s]
129
148
  next unless prop.is_a?(Hash)
130
149
 
131
- errors.concat(validate_value(field.to_s, value, prop))
150
+ errors.concat(validate_value(field.to_s, value, prop, deadline))
132
151
  end
133
152
 
134
153
  errors
@@ -139,13 +158,13 @@ module MCPClient
139
158
  # @param value [Object] the value to validate
140
159
  # @param prop [Hash] property schema
141
160
  # @return [Array<String>] validation errors
142
- def self.validate_value(field, value, prop)
161
+ def self.validate_value(field, value, prop, deadline = nil)
143
162
  errors = []
144
163
  type = prop['type']
145
164
 
146
165
  case type
147
166
  when 'string'
148
- errors.concat(validate_string_value(field, value, prop))
167
+ errors.concat(validate_string_value(field, value, prop, deadline))
149
168
  when 'number', 'integer'
150
169
  errors.concat(validate_number_value(field, value, prop))
151
170
  when 'boolean'
@@ -162,7 +181,7 @@ module MCPClient
162
181
  # @param value [Object] the value
163
182
  # @param prop [Hash] property schema
164
183
  # @return [Array<String>] validation errors
165
- def self.validate_string_value(field, value, prop)
184
+ def self.validate_string_value(field, value, prop, deadline = nil)
166
185
  errors = []
167
186
 
168
187
  unless value.is_a?(String)
@@ -179,15 +198,7 @@ module MCPClient
179
198
  errors << "Field '#{field}' must be one of: #{allowed.join(', ')}" unless allowed.include?(value)
180
199
  end
181
200
 
182
- if prop['pattern']
183
- begin
184
- unless value.match?(Regexp.new(prop['pattern']))
185
- errors << "Field '#{field}' must match pattern '#{prop['pattern']}'"
186
- end
187
- rescue RegexpError
188
- # Skip pattern validation if the pattern is invalid
189
- end
190
- end
201
+ errors.concat(validate_string_pattern(field, value, prop['pattern'], deadline)) if prop['pattern']
191
202
 
192
203
  if prop['minLength'] && value.length < prop['minLength']
193
204
  errors << "Field '#{field}' must be at least #{prop['minLength']} characters"
@@ -197,9 +208,84 @@ module MCPClient
197
208
  errors << "Field '#{field}' must be at most #{prop['maxLength']} characters"
198
209
  end
199
210
 
211
+ errors.concat(validate_string_format(field, value, prop['format']))
212
+
200
213
  errors
201
214
  end
202
215
 
216
+ # Validate a string value against the schema's regular-expression pattern.
217
+ # An invalid pattern is not enforced (unchanged behavior), but matching
218
+ # runs under PATTERN_MATCH_TIMEOUT because the pattern comes from the
219
+ # remote server. A match that exceeds the budget is reported as a
220
+ # validation error rather than silently accepted — the value was never
221
+ # shown to satisfy the constraint.
222
+ # @param field [String] field name
223
+ # @param value [String] the value
224
+ # @param pattern [String] the declared pattern
225
+ # @return [Array<String>] validation errors
226
+ def self.validate_string_pattern(field, value, pattern, deadline = nil)
227
+ remaining = pattern_budget_remaining(deadline)
228
+ return ["Field '#{field}' pattern matching budget exhausted"] if remaining.zero?
229
+
230
+ return [] if value.match?(Regexp.new(pattern, timeout: remaining))
231
+
232
+ ["Field '#{field}' must match pattern '#{pattern}'"]
233
+ rescue Regexp::TimeoutError
234
+ ["Field '#{field}' pattern '#{pattern}' exceeded the #{PATTERN_MATCH_TIMEOUT}s matching budget"]
235
+ rescue RegexpError
236
+ # Skip pattern validation if the pattern is invalid
237
+ []
238
+ end
239
+
240
+ # Time left in the validation-wide pattern budget.
241
+ # @param deadline [Float, nil] monotonic deadline, or nil for a lone match
242
+ # @return [Float] seconds available for the next match; 0.0 when exhausted
243
+ def self.pattern_budget_remaining(deadline)
244
+ return PATTERN_MATCH_TIMEOUT unless deadline
245
+
246
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
+ return 0.0 if remaining <= 0
248
+
249
+ [remaining, MIN_PATTERN_MATCH_TIMEOUT].max
250
+ end
251
+
252
+ # Validate a string value against the schema's format constraint.
253
+ # The MCP elicitation schema supports email, uri, date, and date-time.
254
+ # @param field [String] field name
255
+ # @param value [String] the value
256
+ # @param format [String, nil] declared format
257
+ # @return [Array<String>] validation errors
258
+ def self.validate_string_format(field, value, format)
259
+ valid = case format
260
+ when 'email' then value.match?(URI::MailTo::EMAIL_REGEXP)
261
+ when 'uri' then valid_uri?(value)
262
+ when 'date' then parseable?(Date, value)
263
+ when 'date-time' then parseable?(Time, value)
264
+ else true # No format declared, or an unknown format: not validated
265
+ end
266
+
267
+ valid ? [] : ["Field '#{field}' must be a valid #{format}"]
268
+ end
269
+
270
+ # @param value [String] candidate URI
271
+ # @return [Boolean] whether the value is an absolute URI
272
+ def self.valid_uri?(value)
273
+ uri = URI.parse(value)
274
+ !uri.scheme.nil?
275
+ rescue URI::InvalidURIError
276
+ false
277
+ end
278
+
279
+ # @param klass [Class] Date or Time
280
+ # @param value [String] candidate ISO 8601 value
281
+ # @return [Boolean] whether the value parses
282
+ def self.parseable?(klass, value)
283
+ klass.iso8601(value)
284
+ true
285
+ rescue ArgumentError
286
+ false
287
+ end
288
+
203
289
  # Validate a number value against its property schema.
204
290
  # @param field [String] field name
205
291
  # @param value [Object] the value
@@ -30,6 +30,30 @@ module MCPClient
30
30
  # Raised when there's a connection error with an MCP server
31
31
  class ConnectionError < MCPError; end
32
32
 
33
+ # Raised when a request requires a server capability that was not
34
+ # negotiated during initialization (MCP lifecycle: "Only use capabilities
35
+ # that were successfully negotiated")
36
+ class CapabilityError < MCPError; end
37
+
38
+ # Raised for an HTTP 403 with a WWW-Authenticate insufficient_scope
39
+ # challenge (MCP 2025-11-25 / SEP-835). Exposes the challenge parameters
40
+ # so hosts can run a step-up authorization flow with the required scopes.
41
+ class InsufficientScopeError < ConnectionError
42
+ # @return [String, nil] the scopes required by the server's challenge
43
+ attr_reader :scope
44
+ # @return [String, nil] the challenge's human-readable error description
45
+ attr_reader :error_description
46
+
47
+ # @param message [String] error message
48
+ # @param scope [String, nil] scopes from the challenge's scope parameter
49
+ # @param error_description [String, nil] challenge error_description
50
+ def initialize(message, scope: nil, error_description: nil)
51
+ super(message)
52
+ @scope = scope
53
+ @error_description = error_description
54
+ end
55
+ end
56
+
33
57
  # Raised when the MCP server returns an error response
34
58
  class ServerError < MCPError; end
35
59
 
@@ -45,7 +69,25 @@ module MCPClient
45
69
  # Raised when there's an error in the MCP server transport
46
70
  class TransportError < MCPError; end
47
71
 
48
- # Raised when tool parameters fail validation against JSON schema
72
+ # Raised when a request exceeded its timeout without receiving a
73
+ # response. A subclass of TransportError so existing rescues keep
74
+ # working, but deliberately excluded from automatic retries: the
75
+ # request may still be executing server-side, so a blind re-send could
76
+ # run a non-idempotent operation twice (MCP lifecycle: on timeout the
77
+ # sender SHOULD cancel and stop waiting, not re-send).
78
+ class RequestTimeoutError < TransportError; end
79
+
80
+ # Raised when a response body exceeded the configured size limit (e.g. a
81
+ # gzip payload that expands past the decompression ceiling). A subclass of
82
+ # TransportError so existing rescues keep working, but deliberately
83
+ # excluded from automatic retries: the server already received and
84
+ # processed the request, so re-sending it could run a non-idempotent
85
+ # operation again — and would decompress the oversized body each time.
86
+ class ResponseTooLargeError < TransportError; end
87
+
88
+ # Raised when tool parameters fail validation against the tool's input
89
+ # schema, or (in strict mode) when a tool result's structuredContent fails
90
+ # validation against the tool's output schema
49
91
  class ValidationError < MCPError; end
50
92
 
51
93
  # Raised when multiple tools with the same name exist across different servers
@@ -9,6 +9,19 @@ module MCPClient
9
9
  module HttpTransportBase
10
10
  include JsonRpcCommon
11
11
 
12
+ # Lightweight response wrapper for Faraday exception payloads (Hashes),
13
+ # so the exception path and the default path share one challenge pipeline.
14
+ NormalizedResponse = Struct.new(:status, :headers)
15
+
16
+ # One auth-param (name = token / quoted-string) as it appears in a
17
+ # WWW-Authenticate challenge (RFC 7235 §2.1, optional whitespace around '=').
18
+ AUTH_PARAM = /[A-Za-z0-9._~+-]+\s*=\s*(?:"(?:[^"\\]|\\.)*"|[^,\s]*)/
19
+ # A run of comma/space separated auth-params anchored at the start of a
20
+ # string. The run ends before a token that is NOT followed by '=' — the
21
+ # auth-scheme introducing the next challenge — while commas inside quoted
22
+ # values are consumed by the quoted-string branch, not treated as boundaries.
23
+ AUTH_PARAMS_RUN = /\A(?:[\s,]*#{AUTH_PARAM})*/
24
+
12
25
  # Generic JSON-RPC request: send method with params and return result
13
26
  # @param method [String] JSON-RPC method name
14
27
  # @param params [Hash] parameters for the request
@@ -17,16 +30,35 @@ module MCPClient
17
30
  # @raise [MCPClient::Errors::ServerError] if server returns an error
18
31
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
19
32
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
20
- def rpc_request(method, params = {})
33
+ def rpc_request(method, params = {}, timeout: nil)
21
34
  ensure_connected
22
35
 
23
- with_retry do
36
+ with_retry(method) do
24
37
  request_id = @mutex.synchronize { @request_id += 1 }
25
38
  request = build_jsonrpc_request(method, params, request_id)
26
- send_jsonrpc_request(request)
39
+ begin
40
+ send_jsonrpc_request(request, timeout: timeout)
41
+ rescue MCPClient::Errors::RequestTimeoutError
42
+ # MCP lifecycle: on timeout the sender SHOULD issue a cancellation
43
+ # notification for the abandoned request and stop waiting.
44
+ send_cancellation_notification(request_id) if cancellable_request?(method, params)
45
+ raise
46
+ end
27
47
  end
28
48
  end
29
49
 
50
+ # Best-effort notifications/cancelled for a request the client stopped
51
+ # waiting on. Failures are swallowed.
52
+ # @param request_id [Integer] id of the abandoned request
53
+ # @return [void]
54
+ def send_cancellation_notification(request_id)
55
+ notif = build_jsonrpc_notification('notifications/cancelled',
56
+ { 'requestId' => request_id, 'reason' => 'Request timed out' })
57
+ send_http_request(notif)
58
+ rescue StandardError => e
59
+ @logger.debug("Failed to send cancellation notification: #{e.message}")
60
+ end
61
+
30
62
  # Send a JSON-RPC notification (no response expected)
31
63
  # @param method [String] JSON-RPC method name
32
64
  # @param params [Hash] parameters for the notification
@@ -59,6 +91,8 @@ module MCPClient
59
91
  @headers.each { |k, v| req.headers[k] = v }
60
92
  req.headers['Mcp-Session-Id'] = @session_id
61
93
  req.headers['Mcp-Protocol-Version'] = @protocol_version if @protocol_version
94
+ # MCP: authorization MUST be included in every HTTP request
95
+ @oauth_provider&.apply_authorization(req)
62
96
  end
63
97
 
64
98
  if response.success?
@@ -78,16 +112,42 @@ module MCPClient
78
112
  end
79
113
  end
80
114
 
81
- # Validate session ID format for security
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
+
138
+ # Validate session ID format
139
+ # Per MCP 2025-11-25, the server-assigned session ID "MUST only contain
140
+ # visible ASCII characters (ranging from 0x21 to 0x7E)" — e.g. a UUID, a
141
+ # JWT, or a cryptographic hash — and the client MUST echo whatever the
142
+ # server assigned. A generous length cap guards against abuse.
82
143
  # @param session_id [String] the session ID to validate
83
144
  # @return [Boolean] true if session ID is valid
84
145
  def valid_session_id?(session_id)
85
146
  return false unless session_id.is_a?(String)
86
- return false if session_id.empty?
87
147
 
88
- # Session ID should be alphanumeric with optional hyphens and underscores
89
- # Length should be reasonable (8-128 characters)
90
- session_id.match?(/\A[a-zA-Z0-9\-_]{8,128}\z/)
148
+ # The 4096-char cap is header-size hygiene, not MCP grammar — the spec
149
+ # imposes no length limit on session IDs.
150
+ session_id.match?(/\A[\x21-\x7E]{1,4096}\z/)
91
151
  end
92
152
 
93
153
  # Validate the server's base URL for security
@@ -116,16 +176,6 @@ module MCPClient
116
176
 
117
177
  private
118
178
 
119
- # Generate initialization parameters for HTTP MCP protocol
120
- # @return [Hash] the initialization parameters
121
- def initialization_params
122
- {
123
- 'protocolVersion' => MCPClient::PROTOCOL_VERSION,
124
- 'capabilities' => {},
125
- 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
126
- }
127
- end
128
-
129
179
  # Perform JSON-RPC initialize handshake with the MCP server
130
180
  # @return [void]
131
181
  # @raise [MCPClient::Errors::ConnectionError] if initialization fails
@@ -135,11 +185,16 @@ module MCPClient
135
185
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
136
186
 
137
187
  result = send_jsonrpc_request(json_rpc_request)
138
- return unless result.is_a?(Hash)
188
+ unless result.is_a?(Hash)
189
+ raise MCPClient::Errors::ConnectionError,
190
+ "Server returned invalid initialize result: #{result.inspect}"
191
+ end
139
192
 
193
+ # Disconnects if the server negotiated a version we cannot speak.
194
+ @protocol_version = validate_protocol_version!(result)
140
195
  @server_info = result['serverInfo']
141
196
  @capabilities = result['capabilities']
142
- @protocol_version = result['protocolVersion']
197
+ @instructions = result['instructions']
143
198
  end
144
199
 
145
200
  # Send a JSON-RPC request to the server and wait for result
@@ -148,16 +203,16 @@ module MCPClient
148
203
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
149
204
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
150
205
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
151
- def send_jsonrpc_request(request)
152
- @logger.debug("Sending JSON-RPC request: #{request.to_json}")
206
+ def send_jsonrpc_request(request, timeout: nil)
207
+ @logger.debug("Sending JSON-RPC request: #{describe_jsonrpc_message(request)}")
153
208
 
154
209
  begin
155
- response = send_http_request(request)
156
- parse_response(response)
210
+ response = send_http_request(request, timeout: timeout)
211
+ parse_response(response, request)
157
212
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
158
213
  raise
159
214
  rescue JSON::ParserError => e
160
- 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)}"
161
216
  rescue Errno::ECONNREFUSED => e
162
217
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
163
218
  rescue StandardError => e
@@ -170,15 +225,42 @@ module MCPClient
170
225
  # @param request [Hash] the JSON-RPC request
171
226
  # @return [Faraday::Response] the HTTP response
172
227
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
173
- def send_http_request(request)
228
+ def send_http_request(request, timeout: nil)
174
229
  conn = http_connection
230
+ # Capture the session id this request goes out with — the value
231
+ # apply_request_headers attaches — so a later 404 is attributed to the
232
+ # id that actually accompanied the request, not to whatever @session_id
233
+ # holds by 404-handling time (another caller may have completed a
234
+ # restart in between, and its fresh session must not be re-initialized).
235
+ sent_session_id = @mutex.synchronize { @session_id }
175
236
 
176
237
  begin
177
238
  response = conn.post(@endpoint) do |req|
178
239
  apply_request_headers(req, request)
240
+ # Per-request timeout override (MCP lifecycle: timeouts SHOULD be
241
+ # configurable on a per-request basis)
242
+ req.options.timeout = timeout if timeout
243
+ # The wire header must match the captured id exactly: a restart
244
+ # completing between capture and header attachment would otherwise
245
+ # attach a different (or fresh) session than the one attributed to
246
+ # this request at 404-handling time.
247
+ if req.headers.key?('Mcp-Session-Id')
248
+ if sent_session_id
249
+ req.headers['Mcp-Session-Id'] = sent_session_id
250
+ else
251
+ req.headers.delete('Mcp-Session-Id')
252
+ end
253
+ end
179
254
  req.body = request.to_json
180
255
  end
181
256
 
257
+ # MCP 2025-11-25 session management: HTTP 404 for a request carrying
258
+ # Mcp-Session-Id means the session expired — the client MUST start a
259
+ # new session with a fresh InitializeRequest (without a session ID).
260
+ if response.status == 404 && session_restart_applicable?(sent_session_id)
261
+ return restart_session_and_resend(request, sent_session_id)
262
+ end
263
+
182
264
  handle_http_error_response(response) unless response.success?
183
265
  handle_successful_response(response, request)
184
266
 
@@ -186,13 +268,59 @@ module MCPClient
186
268
  response
187
269
  rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
188
270
  handle_auth_error(e)
271
+ rescue Faraday::ResourceNotFound => e
272
+ # User-configured raise_error middleware surfaces 404 as an exception;
273
+ # apply the same session-expiry recovery as the response path.
274
+ return restart_session_and_resend(request, sent_session_id) if session_restart_applicable?(sent_session_id)
275
+
276
+ raise MCPClient::Errors::ServerError, "Client error: HTTP 404 #{e.message}"
189
277
  rescue Faraday::ConnectionFailed => e
190
278
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
279
+ rescue Faraday::TimeoutError => e
280
+ raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
191
281
  rescue Faraday::Error => e
192
282
  raise MCPClient::Errors::TransportError, "HTTP request failed: #{e.message}"
193
283
  end
194
284
  end
195
285
 
286
+ # Start a new session after the server invalidated the current one, then
287
+ # resend the original request once. The @restarting_session flag prevents
288
+ # a second restart if the fresh session also answers 404.
289
+ # @param request [Hash] the JSON-RPC request that hit the expired session
290
+ # @param expired_session_id [String] the session id the 404'd request was sent with
291
+ # @return [Faraday::Response] the response to the resent request
292
+ def restart_session_and_resend(request, expired_session_id)
293
+ # Serialized on the transport monitor so concurrent 404s trigger a
294
+ # single restart; the monitor is reentrant, so the nested
295
+ # perform_initialize/id generation inside is safe.
296
+ @mutex.synchronize do
297
+ # Recheck now that the monitor is held: another caller may already
298
+ # have restarted the session while this one waited. If so, skip the
299
+ # extra initialize and just resend against the fresh session.
300
+ return resend_after_session_restart(request) if @session_id != expired_session_id
301
+
302
+ @logger.warn("Session #{@session_id} no longer valid (HTTP 404); starting a new session")
303
+ @restarting_session = true
304
+ @session_id = nil
305
+ @last_event_id = nil if instance_variable_defined?(:@last_event_id)
306
+ perform_initialize
307
+ resend_after_session_restart(request)
308
+ ensure
309
+ @restarting_session = false
310
+ end
311
+ end
312
+
313
+ # Whether a 404 should trigger a session restart: only when the 404'd
314
+ # request was actually sent with a session id and no restart is already
315
+ # in flight (a restart's own resend answering 404 must not loop).
316
+ # @param sent_session_id [String, nil] session id captured when the request was sent
317
+ # @return [Boolean] true if session restart recovery applies
318
+ def session_restart_applicable?(sent_session_id)
319
+ return false if sent_session_id.nil?
320
+
321
+ @mutex.synchronize { !@restarting_session }
322
+ end
323
+
196
324
  # Apply headers to the HTTP request (can be overridden by subclasses)
197
325
  # @param req [Faraday::Request] HTTP request
198
326
  # @param _request [Hash] JSON-RPC request
@@ -212,22 +340,30 @@ module MCPClient
212
340
  # Default: no additional handling
213
341
  end
214
342
 
215
- # Handle authentication errors
343
+ # Handle authentication errors raised by user-configured raise_error
344
+ # middleware; routes through the same challenge pipeline as the default
345
+ # response path.
216
346
  # @param error [Faraday::UnauthorizedError, Faraday::ForbiddenError] Auth error
217
- # @raise [MCPClient::Errors::ConnectionError] Connection error
347
+ # @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
218
348
  def handle_auth_error(error)
219
- # Handle OAuth authorization challenges
220
- if error.response && @oauth_provider
221
- resource_metadata = @oauth_provider.handle_unauthorized_response(error.response)
222
- if resource_metadata
223
- @logger.debug('Received OAuth challenge, discovered resource metadata')
224
- # Re-raise the error to trigger OAuth flow in calling code
225
- raise MCPClient::Errors::ConnectionError, "OAuth authorization required: HTTP #{error.response[:status]}"
226
- end
349
+ response = normalize_error_response(error.response)
350
+ if response
351
+ process_authorization_challenge(response)
352
+ raise_authorization_error(response)
227
353
  end
228
354
 
229
- error_status = error.response ? error.response[:status] : 'unknown'
230
- raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
355
+ raise MCPClient::Errors::ConnectionError, 'Authorization failed: HTTP unknown'
356
+ end
357
+
358
+ # @param raw [Faraday::Response, Hash, nil] an exception's response payload
359
+ # @return [#status, nil] a response-like object with #status and #headers
360
+ def normalize_error_response(raw)
361
+ return nil unless raw
362
+ return raw if raw.respond_to?(:status) && raw.respond_to?(:headers)
363
+
364
+ status = raw[:status] || raw['status']
365
+ headers = raw[:headers] || raw['headers'] || {}
366
+ NormalizedResponse.new(status, headers)
231
367
  end
232
368
 
233
369
  # Handle HTTP error responses
@@ -241,7 +377,11 @@ module MCPClient
241
377
 
242
378
  case response.status
243
379
  when 401, 403
244
- raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
380
+ # MCP 2025-11-25: clients MUST parse WWW-Authenticate headers on 401
381
+ # responses and use the advertised resource metadata; the challenge's
382
+ # scope parameter is authoritative for the next authorization.
383
+ process_authorization_challenge(response)
384
+ raise_authorization_error(response)
245
385
  when 400..499
246
386
  # Deterministic client errors: the request was processed/rejected and
247
387
  # will not succeed on retry, so raise a plain (non-retryable) ServerError.
@@ -255,6 +395,79 @@ module MCPClient
255
395
  end
256
396
  end
257
397
 
398
+ # Surface a 401/403 WWW-Authenticate challenge to the OAuth provider so
399
+ # the advertised resource metadata and challenge scope are captured before
400
+ # the error propagates. Discovery failures must not mask the original
401
+ # authorization error.
402
+ # @param response [Faraday::Response] the 401/403 response
403
+ # @return [void]
404
+ def process_authorization_challenge(response)
405
+ return unless @oauth_provider && response.respond_to?(:headers)
406
+
407
+ @oauth_provider.handle_unauthorized_response(response)
408
+ rescue StandardError => e
409
+ @logger.debug("OAuth challenge processing failed: #{e.message}")
410
+ end
411
+
412
+ # Raise the appropriate error for a 401/403: an insufficient_scope 403
413
+ # challenge (SEP-835) raises InsufficientScopeError exposing the required
414
+ # scopes so hosts can run a step-up authorization flow.
415
+ # @param response [Faraday::Response] the 401/403 response
416
+ # @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
417
+ def raise_authorization_error(response)
418
+ challenge = bearer_challenge_segment(www_authenticate_header(response))
419
+
420
+ if response.status == 403 && insufficient_scope_challenge?(challenge)
421
+ scope = challenge[/(?:^|[\s,])scope\s*=\s*"([^"]*)"/i, 1] ||
422
+ challenge[/(?:^|[\s,])scope\s*=\s*([^,\s"]+)/i, 1]
423
+ description = challenge[/(?:^|[\s,])error_description\s*=\s*"([^"]*)"/i, 1]
424
+ raise MCPClient::Errors::InsufficientScopeError.new(
425
+ "Authorization failed: HTTP 403 insufficient_scope#{" (required scopes: #{scope})" if scope}",
426
+ scope: scope, error_description: description
427
+ )
428
+ end
429
+
430
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
431
+ end
432
+
433
+ # Extract the Bearer challenge's own parameter segment from a (possibly
434
+ # multi-challenge) WWW-Authenticate header, so params belonging to other
435
+ # schemes (e.g. `Basic error="insufficient_scope", Bearer realm="x"`) are
436
+ # never attributed to the Bearer challenge.
437
+ # @param header [String, nil] the WWW-Authenticate header value
438
+ # @return [String, nil] the Bearer challenge's parameters (possibly empty),
439
+ # or nil when the header has no Bearer challenge
440
+ def bearer_challenge_segment(header)
441
+ return nil unless header
442
+
443
+ # Locate the Bearer scheme token only OUTSIDE quoted strings: a quoted
444
+ # value such as realm="prefix Bearer x" must not anchor the segment.
445
+ masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
446
+ match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
447
+ return nil unless match
448
+
449
+ header[match.end(0)..][AUTH_PARAMS_RUN]
450
+ end
451
+
452
+ # The Bearer challenge segment carries an error auth-param that is exactly
453
+ # insufficient_scope (RFC 6750 / SEP-835); prefixed or extended tokens
454
+ # (e.g. insufficient_scope.extra) do not match.
455
+ # @param challenge [String, nil] the Bearer challenge segment
456
+ # @return [Boolean]
457
+ def insufficient_scope_challenge?(challenge)
458
+ return false unless challenge
459
+
460
+ challenge.match?(/(?:^|[\s,])error\s*=\s*"?insufficient_scope"?(?![\w.-])/i)
461
+ end
462
+
463
+ # @param response [Faraday::Response] an HTTP response
464
+ # @return [String, nil] the WWW-Authenticate header value, if any
465
+ def www_authenticate_header(response)
466
+ return nil unless response.respond_to?(:headers) && response.headers
467
+
468
+ response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
469
+ end
470
+
258
471
  # Get or create HTTP connection
259
472
  # @return [Faraday::Connection] the HTTP connection
260
473
  def http_connection
@@ -281,14 +494,14 @@ module MCPClient
281
494
  # Log HTTP response (to be overridden by specific transports)
282
495
  # @param response [Faraday::Response] the HTTP response
283
496
  def log_response(response)
284
- @logger.debug("Received HTTP response: #{response.status} #{response.body}")
497
+ @logger.debug("Received HTTP response: #{response.status} (#{describe_body_size(response.body)})")
285
498
  end
286
499
 
287
500
  # Parse HTTP response (to be implemented by specific transports)
288
501
  # @param response [Faraday::Response] the HTTP response
289
502
  # @return [Hash] the parsed result
290
503
  # @raise [NotImplementedError] if not implemented by concrete transport
291
- def parse_response(response)
504
+ def parse_response(response, _request = nil)
292
505
  raise NotImplementedError, 'Subclass must implement parse_response'
293
506
  end
294
507
  end