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.
- checksums.yaml +4 -4
- data/README.md +234 -7
- data/lib/mcp_client/auth/oauth_provider.rb +553 -50
- data/lib/mcp_client/auth.rb +47 -9
- data/lib/mcp_client/client.rb +613 -172
- data/lib/mcp_client/elicitation_validator.rb +43 -0
- data/lib/mcp_client/errors.rb +44 -1
- data/lib/mcp_client/http_transport_base.rb +232 -38
- data/lib/mcp_client/json_rpc_common.rb +140 -12
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +15 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +16 -3
- data/lib/mcp_client/resource_template.rb +15 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +285 -0
- data/lib/mcp_client/server_base.rb +139 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +2 -1
- data/lib/mcp_client/server_http.rb +45 -29
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +67 -9
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +11 -0
- data/lib/mcp_client/server_sse/sse_parser.rb +51 -11
- data/lib/mcp_client/server_sse.rb +98 -57
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +42 -9
- data/lib/mcp_client/server_stdio.rb +216 -37
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +149 -23
- data/lib/mcp_client/server_streamable_http.rb +249 -107
- data/lib/mcp_client/task.rb +93 -61
- data/lib/mcp_client/tool.rb +63 -10
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +1 -0
- metadata +19 -4
|
@@ -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
|
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
89
|
-
#
|
|
90
|
-
session_id.match?(/\A[
|
|
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
|
-
|
|
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
|
-
@
|
|
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]
|
|
324
|
+
# @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
|
|
218
325
|
def handle_auth_error(error)
|
|
219
|
-
|
|
220
|
-
if
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|