ruby-mcp-client 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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:
@@ -197,9 +201,48 @@ module MCPClient
197
201
  errors << "Field '#{field}' must be at most #{prop['maxLength']} characters"
198
202
  end
199
203
 
204
+ errors.concat(validate_string_format(field, value, prop['format']))
205
+
200
206
  errors
201
207
  end
202
208
 
209
+ # Validate a string value against the schema's format constraint.
210
+ # The MCP elicitation schema supports email, uri, date, and date-time.
211
+ # @param field [String] field name
212
+ # @param value [String] the value
213
+ # @param format [String, nil] declared format
214
+ # @return [Array<String>] validation errors
215
+ def self.validate_string_format(field, value, format)
216
+ valid = case format
217
+ when 'email' then value.match?(URI::MailTo::EMAIL_REGEXP)
218
+ when 'uri' then valid_uri?(value)
219
+ when 'date' then parseable?(Date, value)
220
+ when 'date-time' then parseable?(Time, value)
221
+ else true # No format declared, or an unknown format: not validated
222
+ end
223
+
224
+ valid ? [] : ["Field '#{field}' must be a valid #{format}"]
225
+ end
226
+
227
+ # @param value [String] candidate URI
228
+ # @return [Boolean] whether the value is an absolute URI
229
+ def self.valid_uri?(value)
230
+ uri = URI.parse(value)
231
+ !uri.scheme.nil?
232
+ rescue URI::InvalidURIError
233
+ false
234
+ end
235
+
236
+ # @param klass [Class] Date or Time
237
+ # @param value [String] candidate ISO 8601 value
238
+ # @return [Boolean] whether the value parses
239
+ def self.parseable?(klass, value)
240
+ klass.iso8601(value)
241
+ true
242
+ rescue ArgumentError
243
+ false
244
+ end
245
+
203
246
  # Validate a number value against its property schema.
204
247
  # @param field [String] field name
205
248
  # @param value [Object] the value
@@ -30,13 +30,56 @@ 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
 
60
+ # Raised for a server-side failure that is plausibly transient and safe to
61
+ # retry — chiefly HTTP 5xx responses, where the request likely did not
62
+ # complete at the application layer. It is a subclass of ServerError so that
63
+ # existing `rescue MCPClient::Errors::ServerError` handlers keep catching it,
64
+ # while the retry logic can single it out. Application-level failures
65
+ # (JSON-RPC error responses, HTTP 4xx) use plain ServerError and are NOT
66
+ # retried, since the server already processed/rejected the request.
67
+ class TransientServerError < ServerError; end
68
+
36
69
  # Raised when there's an error in the MCP server transport
37
70
  class TransportError < MCPError; end
38
71
 
39
- # 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 tool parameters fail validation against the tool's input
81
+ # schema, or (in strict mode) when a tool result's structuredContent fails
82
+ # validation against the tool's output schema
40
83
  class ValidationError < MCPError; end
41
84
 
42
85
  # 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
36
  with_retry 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,19 @@ module MCPClient
78
112
  end
79
113
  end
80
114
 
81
- # Validate session ID format for security
115
+ # Validate session ID format
116
+ # Per MCP 2025-11-25, the server-assigned session ID "MUST only contain
117
+ # visible ASCII characters (ranging from 0x21 to 0x7E)" — e.g. a UUID, a
118
+ # JWT, or a cryptographic hash — and the client MUST echo whatever the
119
+ # server assigned. A generous length cap guards against abuse.
82
120
  # @param session_id [String] the session ID to validate
83
121
  # @return [Boolean] true if session ID is valid
84
122
  def valid_session_id?(session_id)
85
123
  return false unless session_id.is_a?(String)
86
- return false if session_id.empty?
87
124
 
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/)
125
+ # The 4096-char cap is header-size hygiene, not MCP grammar — the spec
126
+ # imposes no length limit on session IDs.
127
+ session_id.match?(/\A[\x21-\x7E]{1,4096}\z/)
91
128
  end
92
129
 
93
130
  # Validate the server's base URL for security
@@ -116,16 +153,6 @@ module MCPClient
116
153
 
117
154
  private
118
155
 
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
156
  # Perform JSON-RPC initialize handshake with the MCP server
130
157
  # @return [void]
131
158
  # @raise [MCPClient::Errors::ConnectionError] if initialization fails
@@ -135,11 +162,16 @@ module MCPClient
135
162
  @logger.debug("Performing initialize RPC: #{json_rpc_request}")
136
163
 
137
164
  result = send_jsonrpc_request(json_rpc_request)
138
- return unless result.is_a?(Hash)
165
+ unless result.is_a?(Hash)
166
+ raise MCPClient::Errors::ConnectionError,
167
+ "Server returned invalid initialize result: #{result.inspect}"
168
+ end
139
169
 
170
+ # Disconnects if the server negotiated a version we cannot speak.
171
+ @protocol_version = validate_protocol_version!(result)
140
172
  @server_info = result['serverInfo']
141
173
  @capabilities = result['capabilities']
142
- @protocol_version = result['protocolVersion']
174
+ @instructions = result['instructions']
143
175
  end
144
176
 
145
177
  # Send a JSON-RPC request to the server and wait for result
@@ -148,12 +180,12 @@ module MCPClient
148
180
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
149
181
  # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
150
182
  # @raise [MCPClient::Errors::ToolCallError] for other errors during request execution
151
- def send_jsonrpc_request(request)
183
+ def send_jsonrpc_request(request, timeout: nil)
152
184
  @logger.debug("Sending JSON-RPC request: #{request.to_json}")
153
185
 
154
186
  begin
155
- response = send_http_request(request)
156
- parse_response(response)
187
+ response = send_http_request(request, timeout: timeout)
188
+ parse_response(response, request)
157
189
  rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError, MCPClient::Errors::ServerError
158
190
  raise
159
191
  rescue JSON::ParserError => e
@@ -170,15 +202,42 @@ module MCPClient
170
202
  # @param request [Hash] the JSON-RPC request
171
203
  # @return [Faraday::Response] the HTTP response
172
204
  # @raise [MCPClient::Errors::ConnectionError] if connection fails
173
- def send_http_request(request)
205
+ def send_http_request(request, timeout: nil)
174
206
  conn = http_connection
207
+ # Capture the session id this request goes out with — the value
208
+ # apply_request_headers attaches — so a later 404 is attributed to the
209
+ # id that actually accompanied the request, not to whatever @session_id
210
+ # holds by 404-handling time (another caller may have completed a
211
+ # restart in between, and its fresh session must not be re-initialized).
212
+ sent_session_id = @mutex.synchronize { @session_id }
175
213
 
176
214
  begin
177
215
  response = conn.post(@endpoint) do |req|
178
216
  apply_request_headers(req, request)
217
+ # Per-request timeout override (MCP lifecycle: timeouts SHOULD be
218
+ # configurable on a per-request basis)
219
+ req.options.timeout = timeout if timeout
220
+ # The wire header must match the captured id exactly: a restart
221
+ # completing between capture and header attachment would otherwise
222
+ # attach a different (or fresh) session than the one attributed to
223
+ # this request at 404-handling time.
224
+ if req.headers.key?('Mcp-Session-Id')
225
+ if sent_session_id
226
+ req.headers['Mcp-Session-Id'] = sent_session_id
227
+ else
228
+ req.headers.delete('Mcp-Session-Id')
229
+ end
230
+ end
179
231
  req.body = request.to_json
180
232
  end
181
233
 
234
+ # MCP 2025-11-25 session management: HTTP 404 for a request carrying
235
+ # Mcp-Session-Id means the session expired — the client MUST start a
236
+ # new session with a fresh InitializeRequest (without a session ID).
237
+ if response.status == 404 && session_restart_applicable?(sent_session_id)
238
+ return restart_session_and_resend(request, sent_session_id)
239
+ end
240
+
182
241
  handle_http_error_response(response) unless response.success?
183
242
  handle_successful_response(response, request)
184
243
 
@@ -186,13 +245,59 @@ module MCPClient
186
245
  response
187
246
  rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
188
247
  handle_auth_error(e)
248
+ rescue Faraday::ResourceNotFound => e
249
+ # User-configured raise_error middleware surfaces 404 as an exception;
250
+ # apply the same session-expiry recovery as the response path.
251
+ return restart_session_and_resend(request, sent_session_id) if session_restart_applicable?(sent_session_id)
252
+
253
+ raise MCPClient::Errors::ServerError, "Client error: HTTP 404 #{e.message}"
189
254
  rescue Faraday::ConnectionFailed => e
190
255
  raise MCPClient::Errors::ConnectionError, "Server connection lost: #{e.message}"
256
+ rescue Faraday::TimeoutError => e
257
+ raise MCPClient::Errors::RequestTimeoutError, "Request timed out: #{e.message}"
191
258
  rescue Faraday::Error => e
192
259
  raise MCPClient::Errors::TransportError, "HTTP request failed: #{e.message}"
193
260
  end
194
261
  end
195
262
 
263
+ # Start a new session after the server invalidated the current one, then
264
+ # resend the original request once. The @restarting_session flag prevents
265
+ # a second restart if the fresh session also answers 404.
266
+ # @param request [Hash] the JSON-RPC request that hit the expired session
267
+ # @param expired_session_id [String] the session id the 404'd request was sent with
268
+ # @return [Faraday::Response] the response to the resent request
269
+ def restart_session_and_resend(request, expired_session_id)
270
+ # Serialized on the transport monitor so concurrent 404s trigger a
271
+ # single restart; the monitor is reentrant, so the nested
272
+ # perform_initialize/id generation inside is safe.
273
+ @mutex.synchronize do
274
+ # Recheck now that the monitor is held: another caller may already
275
+ # have restarted the session while this one waited. If so, skip the
276
+ # extra initialize and just resend against the fresh session.
277
+ return send_http_request(request) if @session_id != expired_session_id
278
+
279
+ @logger.warn("Session #{@session_id} no longer valid (HTTP 404); starting a new session")
280
+ @restarting_session = true
281
+ @session_id = nil
282
+ @last_event_id = nil if instance_variable_defined?(:@last_event_id)
283
+ perform_initialize
284
+ send_http_request(request)
285
+ ensure
286
+ @restarting_session = false
287
+ end
288
+ end
289
+
290
+ # Whether a 404 should trigger a session restart: only when the 404'd
291
+ # request was actually sent with a session id and no restart is already
292
+ # in flight (a restart's own resend answering 404 must not loop).
293
+ # @param sent_session_id [String, nil] session id captured when the request was sent
294
+ # @return [Boolean] true if session restart recovery applies
295
+ def session_restart_applicable?(sent_session_id)
296
+ return false if sent_session_id.nil?
297
+
298
+ @mutex.synchronize { !@restarting_session }
299
+ end
300
+
196
301
  # Apply headers to the HTTP request (can be overridden by subclasses)
197
302
  # @param req [Faraday::Request] HTTP request
198
303
  # @param _request [Hash] JSON-RPC request
@@ -212,22 +317,30 @@ module MCPClient
212
317
  # Default: no additional handling
213
318
  end
214
319
 
215
- # Handle authentication errors
320
+ # Handle authentication errors raised by user-configured raise_error
321
+ # middleware; routes through the same challenge pipeline as the default
322
+ # response path.
216
323
  # @param error [Faraday::UnauthorizedError, Faraday::ForbiddenError] Auth error
217
- # @raise [MCPClient::Errors::ConnectionError] Connection error
324
+ # @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
218
325
  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
326
+ response = normalize_error_response(error.response)
327
+ if response
328
+ process_authorization_challenge(response)
329
+ raise_authorization_error(response)
227
330
  end
228
331
 
229
- error_status = error.response ? error.response[:status] : 'unknown'
230
- raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{error_status}"
332
+ raise MCPClient::Errors::ConnectionError, 'Authorization failed: HTTP unknown'
333
+ end
334
+
335
+ # @param raw [Faraday::Response, Hash, nil] an exception's response payload
336
+ # @return [#status, nil] a response-like object with #status and #headers
337
+ def normalize_error_response(raw)
338
+ return nil unless raw
339
+ return raw if raw.respond_to?(:status) && raw.respond_to?(:headers)
340
+
341
+ status = raw[:status] || raw['status']
342
+ headers = raw[:headers] || raw['headers'] || {}
343
+ NormalizedResponse.new(status, headers)
231
344
  end
232
345
 
233
346
  # Handle HTTP error responses
@@ -241,16 +354,97 @@ module MCPClient
241
354
 
242
355
  case response.status
243
356
  when 401, 403
244
- raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
357
+ # MCP 2025-11-25: clients MUST parse WWW-Authenticate headers on 401
358
+ # responses and use the advertised resource metadata; the challenge's
359
+ # scope parameter is authoritative for the next authorization.
360
+ process_authorization_challenge(response)
361
+ raise_authorization_error(response)
245
362
  when 400..499
363
+ # Deterministic client errors: the request was processed/rejected and
364
+ # will not succeed on retry, so raise a plain (non-retryable) ServerError.
246
365
  raise MCPClient::Errors::ServerError, "Client error: HTTP #{response.status}#{reason_text}"
247
366
  when 500..599
248
- raise MCPClient::Errors::ServerError, "Server error: HTTP #{response.status}#{reason_text}"
367
+ # Server-side failures are plausibly transient: raise the retryable
368
+ # subclass so with_retry can re-attempt them.
369
+ raise MCPClient::Errors::TransientServerError, "Server error: HTTP #{response.status}#{reason_text}"
249
370
  else
250
371
  raise MCPClient::Errors::ServerError, "HTTP error: #{response.status}#{reason_text}"
251
372
  end
252
373
  end
253
374
 
375
+ # Surface a 401/403 WWW-Authenticate challenge to the OAuth provider so
376
+ # the advertised resource metadata and challenge scope are captured before
377
+ # the error propagates. Discovery failures must not mask the original
378
+ # authorization error.
379
+ # @param response [Faraday::Response] the 401/403 response
380
+ # @return [void]
381
+ def process_authorization_challenge(response)
382
+ return unless @oauth_provider && response.respond_to?(:headers)
383
+
384
+ @oauth_provider.handle_unauthorized_response(response)
385
+ rescue StandardError => e
386
+ @logger.debug("OAuth challenge processing failed: #{e.message}")
387
+ end
388
+
389
+ # Raise the appropriate error for a 401/403: an insufficient_scope 403
390
+ # challenge (SEP-835) raises InsufficientScopeError exposing the required
391
+ # scopes so hosts can run a step-up authorization flow.
392
+ # @param response [Faraday::Response] the 401/403 response
393
+ # @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
394
+ def raise_authorization_error(response)
395
+ challenge = bearer_challenge_segment(www_authenticate_header(response))
396
+
397
+ if response.status == 403 && insufficient_scope_challenge?(challenge)
398
+ scope = challenge[/(?:^|[\s,])scope\s*=\s*"([^"]*)"/i, 1] ||
399
+ challenge[/(?:^|[\s,])scope\s*=\s*([^,\s"]+)/i, 1]
400
+ description = challenge[/(?:^|[\s,])error_description\s*=\s*"([^"]*)"/i, 1]
401
+ raise MCPClient::Errors::InsufficientScopeError.new(
402
+ "Authorization failed: HTTP 403 insufficient_scope#{" (required scopes: #{scope})" if scope}",
403
+ scope: scope, error_description: description
404
+ )
405
+ end
406
+
407
+ raise MCPClient::Errors::ConnectionError, "Authorization failed: HTTP #{response.status}"
408
+ end
409
+
410
+ # Extract the Bearer challenge's own parameter segment from a (possibly
411
+ # multi-challenge) WWW-Authenticate header, so params belonging to other
412
+ # schemes (e.g. `Basic error="insufficient_scope", Bearer realm="x"`) are
413
+ # never attributed to the Bearer challenge.
414
+ # @param header [String, nil] the WWW-Authenticate header value
415
+ # @return [String, nil] the Bearer challenge's parameters (possibly empty),
416
+ # or nil when the header has no Bearer challenge
417
+ def bearer_challenge_segment(header)
418
+ return nil unless header
419
+
420
+ # Locate the Bearer scheme token only OUTSIDE quoted strings: a quoted
421
+ # value such as realm="prefix Bearer x" must not anchor the segment.
422
+ masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
423
+ match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
424
+ return nil unless match
425
+
426
+ header[match.end(0)..][AUTH_PARAMS_RUN]
427
+ end
428
+
429
+ # The Bearer challenge segment carries an error auth-param that is exactly
430
+ # insufficient_scope (RFC 6750 / SEP-835); prefixed or extended tokens
431
+ # (e.g. insufficient_scope.extra) do not match.
432
+ # @param challenge [String, nil] the Bearer challenge segment
433
+ # @return [Boolean]
434
+ def insufficient_scope_challenge?(challenge)
435
+ return false unless challenge
436
+
437
+ challenge.match?(/(?:^|[\s,])error\s*=\s*"?insufficient_scope"?(?![\w.-])/i)
438
+ end
439
+
440
+ # @param response [Faraday::Response] an HTTP response
441
+ # @return [String, nil] the WWW-Authenticate header value, if any
442
+ def www_authenticate_header(response)
443
+ return nil unless response.respond_to?(:headers) && response.headers
444
+
445
+ response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
446
+ end
447
+
254
448
  # Get or create HTTP connection
255
449
  # @return [Faraday::Connection] the HTTP connection
256
450
  def http_connection
@@ -284,7 +478,7 @@ module MCPClient
284
478
  # @param response [Faraday::Response] the HTTP response
285
479
  # @return [Hash] the parsed result
286
480
  # @raise [NotImplementedError] if not implemented by concrete transport
287
- def parse_response(response)
481
+ def parse_response(response, _request = nil)
288
482
  raise NotImplementedError, 'Subclass must implement parse_response'
289
483
  end
290
484
  end