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.
- checksums.yaml +4 -4
- data/README.md +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- 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
|
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
89
|
-
#
|
|
90
|
-
session_id.match?(/\A[
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
|
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]
|
|
347
|
+
# @raise [MCPClient::Errors::InsufficientScopeError, MCPClient::Errors::ConnectionError]
|
|
218
348
|
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
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|