ruby-mcp-client 2.1.0 → 3.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/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -26,7 +26,7 @@ module MCPClient
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Create an AudioContent instance from JSON data
|
|
29
|
-
# @param
|
|
29
|
+
# @param json_data [Hash] JSON data from MCP server
|
|
30
30
|
# @return [MCPClient::AudioContent] audio content instance
|
|
31
31
|
def self.from_json(json_data)
|
|
32
32
|
new(
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require 'socket'
|
|
4
4
|
require 'uri'
|
|
5
|
-
require 'cgi'
|
|
5
|
+
require 'cgi/escape'
|
|
6
|
+
# CGI.unescape on Ruby 3.x is the pure-Ruby one from cgi/util, and it reads a
|
|
7
|
+
# class variable only cgi/util initializes; cgi/escape alone raises NameError
|
|
8
|
+
# on several 3.x patch levels. Ruby 4.0 removed cgi/util (requiring it warns)
|
|
9
|
+
# and cgi/escape carries a complete unescape.
|
|
10
|
+
require 'cgi/util' if RUBY_VERSION < '4'
|
|
11
|
+
require_relative 'peer_text'
|
|
6
12
|
require_relative 'oauth_provider'
|
|
7
13
|
|
|
8
14
|
module MCPClient
|
|
@@ -10,6 +16,8 @@ module MCPClient
|
|
|
10
16
|
# Browser-based OAuth authentication flow helper
|
|
11
17
|
# Provides a complete OAuth flow using browser authentication with a local callback server
|
|
12
18
|
class BrowserOAuth
|
|
19
|
+
include PeerText
|
|
20
|
+
|
|
13
21
|
# @!attribute [r] oauth_provider
|
|
14
22
|
# @return [OAuthProvider] The OAuth provider instance
|
|
15
23
|
# @!attribute [r] callback_port
|
|
@@ -89,7 +97,11 @@ module MCPClient
|
|
|
89
97
|
|
|
90
98
|
# Complete OAuth flow
|
|
91
99
|
@logger.debug('Completing OAuth authorization flow')
|
|
92
|
-
token =
|
|
100
|
+
token = if result.key?(:iss)
|
|
101
|
+
@oauth_provider.complete_authorization_flow(result[:code], result[:state], iss: result[:iss])
|
|
102
|
+
else
|
|
103
|
+
@oauth_provider.complete_authorization_flow(result[:code], result[:state])
|
|
104
|
+
end
|
|
93
105
|
|
|
94
106
|
@logger.info("\nAuthentication successful!")
|
|
95
107
|
token
|
|
@@ -134,7 +146,10 @@ module MCPClient
|
|
|
134
146
|
# Server was closed, exit loop
|
|
135
147
|
break
|
|
136
148
|
rescue StandardError => e
|
|
137
|
-
|
|
149
|
+
# The request being handled is whatever the browser (or
|
|
150
|
+
# anything else that reached the loopback port) sent: an
|
|
151
|
+
# exception raised over it can quote those bytes.
|
|
152
|
+
@logger.error("Error handling callback request: #{safe_error_text(e.message)}")
|
|
138
153
|
end
|
|
139
154
|
end
|
|
140
155
|
end
|
|
@@ -161,7 +176,11 @@ module MCPClient
|
|
|
161
176
|
return unless parts.length >= 2
|
|
162
177
|
|
|
163
178
|
method, path = parts[0..1]
|
|
164
|
-
|
|
179
|
+
# The query string of a callback carries the authorization code (and
|
|
180
|
+
# the state that binds it to this flow): a credential, and a
|
|
181
|
+
# single-use one only until someone reads the log. Only the path is
|
|
182
|
+
# logged, and only after the peer's bytes are made safe.
|
|
183
|
+
@logger.debug("Received #{method} request: #{safe_error_text(path.split('?', 2).first.to_s)}")
|
|
165
184
|
|
|
166
185
|
# Read and discard headers until blank line (with limit to prevent memory exhaustion)
|
|
167
186
|
header_count = 0
|
|
@@ -187,22 +206,9 @@ module MCPClient
|
|
|
187
206
|
params = parse_query_params(query_string || '')
|
|
188
207
|
@logger.debug("Callback params: #{params.keys.join(', ')}")
|
|
189
208
|
|
|
190
|
-
# Extract OAuth parameters
|
|
191
|
-
code = params['code']
|
|
192
|
-
state = params['state']
|
|
193
|
-
error = params['error']
|
|
194
|
-
error_description = params['error_description']
|
|
195
|
-
|
|
196
209
|
# Update result and signal waiting thread
|
|
197
210
|
mutex.synchronize do
|
|
198
|
-
|
|
199
|
-
result[:error] = error_description || error
|
|
200
|
-
elsif code && state
|
|
201
|
-
result[:code] = code
|
|
202
|
-
result[:state] = state
|
|
203
|
-
else
|
|
204
|
-
result[:error] = 'Invalid callback: missing code or state parameter'
|
|
205
|
-
end
|
|
211
|
+
record_callback(result, params, query_string.to_s)
|
|
206
212
|
result[:completed] = true
|
|
207
213
|
|
|
208
214
|
condition.signal
|
|
@@ -218,21 +224,125 @@ module MCPClient
|
|
|
218
224
|
client&.close
|
|
219
225
|
end
|
|
220
226
|
|
|
221
|
-
#
|
|
227
|
+
# Store what the callback carried: the error text of an error response
|
|
228
|
+
# (after the RFC 9207 issuer check), or the code, state and iss of a
|
|
229
|
+
# success response the provider accepted. The browser is answered only
|
|
230
|
+
# after that check, so a rejected callback shows the error page, never
|
|
231
|
+
# "successful".
|
|
232
|
+
# @param result [Hash] the shared result
|
|
233
|
+
# @param params [Hash] callback parameters
|
|
234
|
+
# @param query_string [String] the raw query the parameters were parsed from
|
|
235
|
+
# @return [void]
|
|
236
|
+
def record_callback(result, params, query_string = '')
|
|
237
|
+
if (repeated = repeated_parameter(query_string))
|
|
238
|
+
return result[:error] = "Invalid callback: the #{repeated} parameter is included more than once " \
|
|
239
|
+
'(RFC 6749 Section 3.1)'
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
code = params['code']
|
|
243
|
+
state = params['state']
|
|
244
|
+
if params['error']
|
|
245
|
+
result[:error] = authorization_error_text(params, params['error_description'] || params['error'])
|
|
246
|
+
elsif code && state
|
|
247
|
+
problem = success_callback_problem(params)
|
|
248
|
+
return result[:error] = problem if problem
|
|
249
|
+
|
|
250
|
+
result[:code] = code
|
|
251
|
+
result[:state] = state
|
|
252
|
+
# RFC 9207 issuer identification, validated again by the provider
|
|
253
|
+
result[:iss] = params['iss'] if params.key?('iss')
|
|
254
|
+
else
|
|
255
|
+
result[:error] = 'Invalid callback: missing code or state parameter'
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Why a success callback is not acceptable, or nil when it is.
|
|
260
|
+
# @param params [Hash] callback parameters
|
|
261
|
+
# @return [String, nil]
|
|
262
|
+
def success_callback_problem(params)
|
|
263
|
+
return nil unless @oauth_provider.respond_to?(:validate_authorization_response!)
|
|
264
|
+
|
|
265
|
+
@oauth_provider.validate_authorization_response!(params['state'], iss: params['iss'])
|
|
266
|
+
nil
|
|
267
|
+
rescue MCPClient::Errors::ConnectionError, ArgumentError => e
|
|
268
|
+
e.message
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The text to surface for an error callback: the provider validates the
|
|
272
|
+
# response's issuer first and refuses a mismatching one.
|
|
273
|
+
# @param params [Hash] callback parameters
|
|
274
|
+
# @param fallback [String] the error text when the provider cannot validate
|
|
275
|
+
# @return [String]
|
|
276
|
+
def authorization_error_text(params, fallback)
|
|
277
|
+
return fallback unless @oauth_provider.respond_to?(:authorization_error_message)
|
|
278
|
+
|
|
279
|
+
@oauth_provider.authorization_error_message(params)
|
|
280
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
281
|
+
e.message
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The name of the first callback parameter that appears more than once,
|
|
285
|
+
# or nil when each appears at most once.
|
|
286
|
+
#
|
|
287
|
+
# RFC 6749 Section 3.1: "Request and response parameters MUST NOT be
|
|
288
|
+
# included more than once." The parsed parameters are a Hash, where the
|
|
289
|
+
# last value of a repeated name silently wins — so
|
|
290
|
+
# `?iss=attacker&iss=recorded` passes every check this client makes
|
|
291
|
+
# while a reader that takes the first value (a proxy, a log pipeline, a
|
|
292
|
+
# differently written client sharing the redirect URI) sees another
|
|
293
|
+
# authorization server entirely. That disagreement is the whole reason
|
|
294
|
+
# the RFC forbids the repetition, and there is nothing to reconcile
|
|
295
|
+
# here: the response is refused, whether the values conflict or not,
|
|
296
|
+
# and whichever parameter was repeated.
|
|
297
|
+
# @param query_string [String] the raw query string of the callback
|
|
298
|
+
# @return [String, nil] the repeated parameter's name
|
|
299
|
+
# @private
|
|
300
|
+
def repeated_parameter(query_string)
|
|
301
|
+
seen = {}
|
|
302
|
+
PeerText.decodable(query_string).split('&').each do |param|
|
|
303
|
+
next if param.empty?
|
|
304
|
+
|
|
305
|
+
name = decoded_parameter(param.split('=', 2).first.to_s)
|
|
306
|
+
return safe_error_text(name) if seen.key?(name)
|
|
307
|
+
|
|
308
|
+
seen[name] = true
|
|
309
|
+
end
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Parse URL query parameters.
|
|
314
|
+
#
|
|
315
|
+
# `CGI.unescape` tags its result UTF-8 whatever the escapes decoded to,
|
|
316
|
+
# so a callback of `?error_description=%FF` yields a String that
|
|
317
|
+
# `strip`, `match` and `split` all raise `ArgumentError` on. The query
|
|
318
|
+
# of a callback is the peer's bytes as much as a response body is, so
|
|
319
|
+
# every name and value is made decodable here, once, at the point it
|
|
320
|
+
# stops being bytes and starts being text — nothing downstream (the
|
|
321
|
+
# state comparison, the log line, the error page) can then choke on it.
|
|
222
322
|
# @param query_string [String] Query string from URL
|
|
223
323
|
# @return [Hash] Parsed parameters
|
|
224
324
|
# @private
|
|
225
325
|
def parse_query_params(query_string)
|
|
226
326
|
params = {}
|
|
227
|
-
query_string.split('&').each do |param|
|
|
327
|
+
PeerText.decodable(query_string).split('&').each do |param|
|
|
228
328
|
next if param.empty?
|
|
229
329
|
|
|
230
330
|
key, value = param.split('=', 2)
|
|
231
|
-
params[
|
|
331
|
+
params[decoded_parameter(key)] = decoded_parameter(value || '')
|
|
232
332
|
end
|
|
233
333
|
params
|
|
234
334
|
end
|
|
235
335
|
|
|
336
|
+
# One percent-decoded callback parameter, as text.
|
|
337
|
+
# @param value [String] a raw name or value from the query string
|
|
338
|
+
# @return [String] the decoded value as valid UTF-8
|
|
339
|
+
# @private
|
|
340
|
+
def decoded_parameter(value)
|
|
341
|
+
PeerText.decodable(CGI.unescape(value))
|
|
342
|
+
rescue StandardError
|
|
343
|
+
PeerText::UNREADABLE_TEXT
|
|
344
|
+
end
|
|
345
|
+
|
|
236
346
|
# Send HTTP response to client
|
|
237
347
|
# @param client [TCPSocket] The client socket
|
|
238
348
|
# @param status_code [Integer] HTTP status code
|