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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. 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 data [Hash] JSON data from MCP server
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 = @oauth_provider.complete_authorization_flow(result[:code], result[:state])
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
- @logger.error("Error handling callback request: #{e.message}")
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
- @logger.debug("Received #{method} request: #{path}")
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
- if error
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
- # Parse URL query parameters
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[CGI.unescape(key)] = CGI.unescape(value || '')
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