ruby-mcp-client 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5687a9c54b7e5c4814e80bd2e5e97d2072dd0f994e3bebe680259a1a9db64c25
4
- data.tar.gz: 05b3bce582f21e57ae07ac862daf6a74812f3ba79f6e4b806868850686c33467
3
+ metadata.gz: 0705c6df1b589bdf048b8922a92c957125f75afd0ce3ba832ef71b99a9cbe87a
4
+ data.tar.gz: 486d2c9891e107acaf545a62d1845e0f3d843c634d526d80ee8d12c8f9e3bde7
5
5
  SHA512:
6
- metadata.gz: 3aef7d0842eab30de7e84a5507298efd30ae78de3ea3223cc8f00bd31892e7665f55f533952df771a2d3ab38f874d3905e029b0d19b8147d07ffd030772106fe
7
- data.tar.gz: 324f5121352512f827f05fa55f78917b3218b7d031c418cfa884a8718b2b06fda027b8b811df2083361200ff17682dbfea8998505db0e2932db3a545f605c35a
6
+ metadata.gz: f766e8b97d5d6ec634fb2e95b000b154e0ce00a0f25011de9a33bd40531d2151ce727e5c3958894f1f79cab8294242441020b6f9184dd8ddf4df828430d4ad2d
7
+ data.tar.gz: 4a9bf198454ce1d8a9cb4b8940551faf7ddaac5962248b10b20e5b095bb46d32c1a1a642042da70b98d47a5832c3eb22aa09f9df05d54c40cf3ca1003e92824d
data/README.md CHANGED
@@ -47,6 +47,10 @@ with a revision it cannot speak (supported: `2025-11-25`, `2025-06-18`,
47
47
  - **Metadata**: `icons`, `title` and `_meta` parsed on tools, prompts and resources
48
48
  - **OAuth 2.1**: PKCE (S256 required), RFC 8414/9728 discovery, dynamic registration, Client ID Metadata Documents, scope step-up challenges
49
49
 
50
+ Transports treat the server as untrusted input — see
51
+ [Treating the Server as Untrusted](#treating-the-server-as-untrusted) for the
52
+ limits applied to peer-controlled data.
53
+
50
54
  ## Quick Connect API (Recommended)
51
55
 
52
56
  The simplest way to connect to an MCP server:
@@ -327,15 +331,24 @@ task = client.call_tool_as_task('long_job', { input: 'data' }, ttl: 60_000)
327
331
  # honoring the server's suggested poll interval
328
332
  until task.terminal? || task.input_required?
329
333
  sleep((task.poll_interval || 1000) / 1000.0)
330
- task = client.get_task(task.task_id) # tasks/get
334
+ task = client.get_task(task) # tasks/get, routed to the task's own server
331
335
  end
332
336
 
333
337
  # Retrieve the underlying result (e.g. a CallToolResult) via tasks/result
334
- result = client.get_task_result(task.task_id)
338
+ result = client.get_task_result(task)
335
339
 
336
340
  # List and cancel tasks
337
341
  page = client.list_tasks # { tasks: [...], next_cursor: ... }
338
- client.cancel_task(task.task_id) # tasks/cancel
342
+ client.cancel_task(task) # tasks/cancel
343
+ ```
344
+
345
+ Task IDs are only unique within the server that issued them, so pass the `Task`
346
+ returned by `call_tool_as_task` — it carries its own server. A bare task ID also
347
+ works when the client has a single server; with several servers configured it
348
+ raises `ArgumentError` rather than guessing, so name the server explicitly:
349
+
350
+ ```ruby
351
+ client.get_task('task-123', server: 'my-server')
339
352
 
340
353
  # React to server-pushed status updates
341
354
  client.on_notification do |server, method, params|
@@ -392,12 +405,49 @@ The `retries:` option controls automatic retry with exponential backoff. Only
392
405
  failures where the request most likely did **not** complete at the server are
393
406
  retried: transport/network errors and HTTP **5xx** responses. Application-level
394
407
  failures — a JSON-RPC error response or an HTTP **4xx** — are **never** retried,
395
- because the server already processed or rejected the request and re-sending
396
- would risk re-executing a non-idempotent `tools/call`. Retryable server failures
397
- raise `MCPClient::Errors::TransientServerError`, a subclass of
408
+ because the server already processed or rejected the request. Retryable server
409
+ failures raise `MCPClient::Errors::TransientServerError`, a subclass of
398
410
  `MCPClient::Errors::ServerError`, so existing `rescue ServerError` handlers are
399
411
  unaffected.
400
412
 
413
+ **`tools/call` is never retried automatically.** Even a "transient" failure can
414
+ arrive *after* the server executed the request, and JSON-RPC has no idempotency
415
+ key that would make a replay safe — so a retry could run a side effect twice.
416
+ Retry a tool call explicitly if your application knows it is safe to repeat, and
417
+ treat the raised error as *outcome unknown* rather than *not executed*:
418
+
419
+ ```ruby
420
+ begin
421
+ client.call_tool('send_invoice', { customer: 'acme' })
422
+ rescue MCPClient::Errors::TransportError => e
423
+ # The server may or may not have sent the invoice. Check before retrying.
424
+ end
425
+ ```
426
+
427
+ The same reasoning excludes `RequestTimeoutError` and `ResponseTooLargeError`
428
+ from retries, and applies to session recovery: if a `tools/call` comes back with
429
+ an expired-session 404, the client starts a fresh session but does **not** re-send
430
+ the call — it raises so you can decide. Idempotent requests are re-sent against
431
+ the new session as before.
432
+
433
+ ### Response Size Limits (Streamable HTTP)
434
+
435
+ A gzip-encoded response is decompressed incrementally and abandoned once it
436
+ expands past `max_decompressed_body_bytes` (default **64 MiB**), so a small
437
+ highly-compressed body cannot exhaust memory. Exceeding it raises
438
+ `MCPClient::Errors::ResponseTooLargeError`.
439
+
440
+ Raise the limit if you legitimately exchange very large payloads — base64
441
+ resource blobs or audio — so that whether a response is accepted does not depend
442
+ on the server's choice to compress it:
443
+
444
+ ```ruby
445
+ MCPClient.streamable_http_config(
446
+ base_url: 'https://api.example.com/mcp',
447
+ max_decompressed_body_bytes: 256 * 1024 * 1024
448
+ )
449
+ ```
450
+
401
451
  ### Faraday Customization
402
452
 
403
453
  ```ruby
@@ -637,10 +687,48 @@ tools = client.list_tools
637
687
  result = client.call_tool('echo', { message: 'Hello!' })
638
688
  ```
639
689
 
690
+ ## Treating the Server as Untrusted
691
+
692
+ A connected MCP server controls everything it sends you, and the transports are
693
+ written on that assumption. You do not need to configure any of this — it is the
694
+ default behaviour — but it is worth knowing what the client will refuse:
695
+
696
+ | Peer-controlled input | What the client does |
697
+ |---|---|
698
+ | Compressed response bodies (**Streamable HTTP only** — the only transport that requests gzip) | Decompressed incrementally, abandoned past `max_decompressed_body_bytes` (64 MiB default) |
699
+ | SSE streams | Per-connection buffer cap; events scanned incrementally, so an unterminated event costs bounded memory *and* CPU |
700
+ | `retry:` directives | Honored, but floored so `retry: 0` cannot drive a reconnect loop |
701
+ | SSE event IDs | Bounded length, printable ASCII only (they are echoed in `Last-Event-ID`) |
702
+ | Legacy SSE `endpoint` events | Must stay on the connection's origin; off-origin redirects are refused, so configured credential headers never reach another host |
703
+ | OAuth discovery URLs from a peer | Must be HTTPS, and rejected when the host is a *literal* loopback/private/link-local address (unless the configured server is itself local); a refused challenge fails closed. Hostnames are not resolved, so a public name pointing at a private address is not caught — see the note below |
704
+ | Unsolicited JSON-RPC responses | Discarded — only IDs with an outstanding request are accepted |
705
+ | Server-initiated requests | Replies are bounded by a concurrency budget rather than spawning unbounded threads |
706
+ | Schema `pattern` values | Matched under a whole-operation time budget; a timeout fails validation rather than silently passing |
707
+ | Log messages (`notifications/message`) | Control characters escaped and length-capped, so a server cannot forge log lines |
708
+
709
+ **Known limit:** the OAuth check is textual. A peer can still advertise a public
710
+ hostname whose DNS record points inside your network; catching that needs
711
+ resolution-time filtering in the HTTP layer, which this gem does not do. If you
712
+ run in an environment where that matters, restrict egress at the network layer.
713
+
714
+ Two related defaults worth calling out because they affect *your* data rather
715
+ than the peer's:
716
+
717
+ - **Payloads are never written to logs.** At DEBUG the client logs a method/id
718
+ summary and a byte count, not request params, response bodies or raw SSE
719
+ chunks. Server configurations are logged with credential-bearing keys redacted.
720
+ - **Host exceptions are not reflected to the server.** A raising elicitation,
721
+ sampling or roots handler yields a constant JSON-RPC error message; the detail
722
+ stays in your local log.
723
+
640
724
  ## Requirements
641
725
 
642
726
  - Ruby >= 3.2.0
643
- - No runtime dependencies
727
+ - Runtime dependencies: `faraday` (~> 2.0) with `faraday-follow_redirects` and
728
+ `faraday-retry`, plus `base64` — all pulled in automatically by the gem
729
+
730
+ Development uses Ruby 4.0.6 (see `.ruby-version`). CI runs the suite on 4.0.6
731
+ plus the supported floor, 3.2 and 3.3.
644
732
 
645
733
  ## License
646
734
 
@@ -68,6 +68,8 @@ module MCPClient
68
68
  # separately so a failed fetch is retried authoritatively by discovery.
69
69
  @challenge_resource_metadata = nil
70
70
  @challenge_metadata_url = nil
71
+ # Why a peer-advertised challenge URL was refused, if one was
72
+ @challenge_error = nil
71
73
  end
72
74
 
73
75
  # @param url [String] Server URL to normalize
@@ -189,9 +191,16 @@ module MCPClient
189
191
  # challenge as authoritative for satisfying the current request" —
190
192
  # including resetting a previously challenged scope when the current
191
193
  # challenge carries none.
192
- @challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
193
-
194
194
  url = extract_resource_metadata_url(www_authenticate)
195
+
196
+ # The challenge header is peer-controlled input: validate the
197
+ # advertised URL BEFORE storing, fetching, or recording any challenge
198
+ # state, so a malicious challenge cannot pivot this host into requests
199
+ # against internal services (SSRF) and cannot leave the provider
200
+ # holding half of a rejected challenge.
201
+ validate_peer_advertised_url!(url, 'resource metadata URL (from WWW-Authenticate challenge)') if url
202
+
203
+ @challenge_scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
195
204
  return nil unless url
196
205
 
197
206
  # Remember the advertised URL even if the fetch below fails, so a
@@ -414,6 +423,12 @@ module MCPClient
414
423
  # @return [ServerMetadata] Authorization server metadata
415
424
  # @raise [MCPClient::Errors::ConnectionError] if discovery fails
416
425
  def discover_authorization_server
426
+ # A challenge we refused is still authoritative: it says the cached
427
+ # authorization server is no longer the right one. Falling back to
428
+ # that cache (or to speculative well-known probing) would quietly
429
+ # undo the rejection, so surface it instead.
430
+ raise MCPClient::Errors::ConnectionError, @challenge_error if @challenge_error
431
+
417
432
  # A fresh 401 challenge is authoritative and overrides any cached
418
433
  # (possibly stale or direct-discovered) authorization server metadata —
419
434
  # whether the challenge-advertised PRM was already fetched or only its
@@ -453,6 +468,7 @@ module MCPClient
453
468
  storage.set_server_metadata(server_url, server_metadata)
454
469
  @challenge_resource_metadata = nil # consumed
455
470
  @challenge_metadata_url = nil # consumed
471
+ @challenge_error = nil
456
472
  server_metadata
457
473
  end
458
474
 
@@ -506,6 +522,13 @@ module MCPClient
506
522
  'Protected resource metadata does not advertise any authorization_servers'
507
523
  end
508
524
 
525
+ # authorization_servers is untrusted PRM content: validate the
526
+ # advertised origin BEFORE constructing and fetching well-known URLs
527
+ # on it, so a malicious protected resource cannot drive discovery GETs
528
+ # against internal services (SSRF).
529
+ validate_peer_advertised_url!(auth_server_url,
530
+ 'authorization server (advertised by protected resource metadata)')
531
+
509
532
  server_metadata = fetch_first_server_metadata(authorization_server_metadata_urls(auth_server_url))
510
533
  unless server_metadata
511
534
  raise MCPClient::Errors::ConnectionError,
@@ -612,6 +635,71 @@ module MCPClient
612
635
  enforce_https!(server_metadata.registration_endpoint, 'registration endpoint')
613
636
  end
614
637
 
638
+ # Validate a URL that a peer advertised to us (a 401 challenge's
639
+ # resource_metadata, or PRM authorization_servers).
640
+ #
641
+ # Stricter than enforce_https!, which exists for URLs the OPERATOR
642
+ # configured and therefore tolerates plain-HTTP loopback for local
643
+ # development. Applying that exception to peer-supplied input would
644
+ # leave the reported SSRF intact against the most sensitive targets of
645
+ # all — services listening only on localhost. The loopback exception is
646
+ # honored here only when the configured MCP server is itself loopback,
647
+ # i.e. the developer is already pointed at a local stack.
648
+ #
649
+ # The rejection is recorded so a later discovery fails closed instead of
650
+ # silently reusing cached authorization-server metadata.
651
+ #
652
+ # NOTE: hostnames are checked literally. This does not resolve DNS, so a
653
+ # public name that resolves to a private address is not caught here;
654
+ # that needs resolution-time checking in the HTTP layer.
655
+ # @param url [String] the peer-advertised URL
656
+ # @param label [String] human-readable name for errors
657
+ # @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
658
+ def validate_peer_advertised_url!(url, label)
659
+ uri = URI.parse(url)
660
+ host = uri.hostname.to_s.downcase
661
+
662
+ if uri.scheme != 'https' && !(uri.scheme == 'http' && local_development?)
663
+ reject_challenge!("OAuth #{label} must use HTTPS: #{url}")
664
+ end
665
+ reject_challenge!("OAuth #{label} must not target a loopback or private address: #{url}") if
666
+ local_address?(host) && !local_development?
667
+ rescue URI::InvalidURIError
668
+ reject_challenge!("OAuth #{label} is not a valid URL: #{url}")
669
+ end
670
+
671
+ # @param message [String] why the challenge was refused
672
+ # @raise [MCPClient::Errors::ConnectionError] always
673
+ def reject_challenge!(message)
674
+ # Drop every scrap of the refused challenge so nothing half-applied
675
+ # survives, and remember why for the next discovery attempt.
676
+ @challenge_scope = nil
677
+ @challenge_metadata_url = nil
678
+ @challenge_resource_metadata = nil
679
+ @challenge_error = message
680
+ raise MCPClient::Errors::ConnectionError, message
681
+ end
682
+
683
+ # @return [Boolean] whether the configured MCP server is itself local,
684
+ # in which case local discovery targets are expected
685
+ def local_development?
686
+ local_address?(URI.parse(server_url).hostname.to_s.downcase)
687
+ rescue URI::InvalidURIError
688
+ false
689
+ end
690
+
691
+ # @param host [String] a downcased hostname
692
+ # @return [Boolean] whether it names a loopback, private or link-local address
693
+ def local_address?(host)
694
+ return true if %w[localhost 127.0.0.1 ::1 0.0.0.0].include?(host)
695
+ return true if host.end_with?('.localhost', '.local', '.internal')
696
+ return true if host.start_with?('127.', '10.', '192.168.', '169.254.')
697
+ return true if host.match?(/\A172\.(1[6-9]|2\d|3[01])\./)
698
+ return true if host.match?(/\A\[?(fc|fd|fe80)/)
699
+
700
+ false
701
+ end
702
+
615
703
  # @param url [String, nil] endpoint URL
616
704
  # @param label [String] human-readable endpoint name for errors
617
705
  # @raise [MCPClient::Errors::ConnectionError] if the URL is not HTTPS (non-localhost)
@@ -1055,7 +1143,7 @@ module MCPClient
1055
1143
  storage.set_token(server_url, new_token)
1056
1144
  new_token
1057
1145
  rescue JSON::ParserError => e
1058
- logger.warn("Invalid token refresh response: #{e.message}")
1146
+ logger.warn("Invalid token refresh response: #{describe_parse_error(e)}")
1059
1147
  nil
1060
1148
  rescue Faraday::Error => e
1061
1149
  logger.warn("Network error during token refresh: #{e.message}")
@@ -100,13 +100,11 @@ module MCPClient
100
100
  # @param tos_uri [String, nil] URL of the client terms of service
101
101
  # @param policy_uri [String, nil] URL of the client privacy policy
102
102
  # @param contacts [Array<String>, nil] List of contact emails for the client
103
- # rubocop:disable Metrics/ParameterLists
104
103
  def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
105
104
  grant_types: %w[authorization_code refresh_token],
106
105
  response_types: ['code'], scope: nil,
107
106
  client_name: nil, client_uri: nil, logo_uri: nil,
108
107
  tos_uri: nil, policy_uri: nil, contacts: nil)
109
- # rubocop:enable Metrics/ParameterLists
110
108
  @redirect_uris = redirect_uris
111
109
  @token_endpoint_auth_method = token_endpoint_auth_method
112
110
  @grant_types = grant_types
@@ -238,11 +236,9 @@ module MCPClient
238
236
  # @param code_challenge_methods_supported [Array<String>, nil] Supported PKCE code challenge methods (RFC 8414)
239
237
  # @param client_id_metadata_document_supported [Boolean, nil] Whether the server accepts
240
238
  # Client ID Metadata Document client IDs (MCP 2025-11-25 / SEP-991)
241
- # rubocop:disable Metrics/ParameterLists
242
239
  def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
243
240
  scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil,
244
241
  code_challenge_methods_supported: nil, client_id_metadata_document_supported: nil)
245
- # rubocop:enable Metrics/ParameterLists
246
242
  @issuer = issuer
247
243
  @authorization_endpoint = authorization_endpoint
248
244
  @token_endpoint = token_endpoint
@@ -29,6 +29,20 @@ module MCPClient
29
29
  # :warn logs a warning on mismatch, :strict raises a ValidationError.
30
30
  STRUCTURED_CONTENT_MODES = %i[warn strict].freeze
31
31
 
32
+ # Server-config keys whose values carry credentials (HTTP headers, the
33
+ # subprocess environment, inline tokens). Their values are replaced before
34
+ # a config is written to the log.
35
+ SENSITIVE_CONFIG_KEYS = %i[headers env token access_token api_key auth authorization
36
+ password secret client_secret oauth_provider].freeze
37
+
38
+ # Placeholder written in place of a redacted value.
39
+ REDACTED = '[REDACTED]'
40
+
41
+ # Maximum characters of a peer-supplied log message written to the host
42
+ # log. The remote server controls this content, so an unbounded message
43
+ # would let it inflate log storage at will.
44
+ MAX_PEER_LOG_MESSAGE_LENGTH = 4096
45
+
32
46
  # Initialize a new MCPClient::Client
33
47
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
34
48
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
@@ -65,7 +79,7 @@ module MCPClient
65
79
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
66
80
  end
67
81
  @servers = mcp_server_configs.map do |config|
68
- @logger.debug("Creating server with config: #{config.inspect}")
82
+ @logger.debug("Creating server with config: #{redact_config(config).inspect}")
69
83
  MCPClient::ServerFactory.create(config, logger: @logger)
70
84
  end
71
85
  @tool_cache = {}
@@ -562,14 +576,17 @@ module MCPClient
562
576
  end
563
577
 
564
578
  # Get the current state of a task (tasks/get, MCP 2025-11-25)
565
- # @param task_id [String] the ID of the task to query
579
+ # @param task_id [String, MCPClient::Task] the task to query; passing the
580
+ # Task handle returned by #call_tool_as_task routes to its own server
566
581
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
567
582
  # @return [MCPClient::Task] the task with current status
583
+ # @raise [ArgumentError] if the server is ambiguous in a multi-server client
568
584
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
569
585
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
570
586
  # @raise [MCPClient::Errors::TaskError] if retrieving the task fails
571
587
  def get_task(task_id, server: nil)
572
- srv = select_server(server)
588
+ srv = select_task_server(task_id, server, 'get_task')
589
+ task_id = task_identifier(task_id)
573
590
 
574
591
  begin
575
592
  result = srv.rpc_request('tasks/get', { taskId: task_id })
@@ -591,13 +608,16 @@ module MCPClient
591
608
  # identify which tool (and therefore which outputSchema) produced the
592
609
  # result, and the client keeps no task-to-tool registry. Callers who need
593
610
  # validation here can run MCPClient::SchemaValidator.validate themselves.
594
- # @param task_id [String] the ID of the task
611
+ # @param task_id [String, MCPClient::Task] the task; passing the Task
612
+ # handle returned by #call_tool_as_task routes to its own server
595
613
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
596
614
  # @return [Object] the underlying task result
615
+ # @raise [ArgumentError] if the server is ambiguous in a multi-server client
597
616
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
598
617
  # @raise [MCPClient::Errors::TaskError] if retrieval fails
599
618
  def get_task_result(task_id, server: nil)
600
- srv = select_server(server)
619
+ srv = select_task_server(task_id, server, 'get_task_result')
620
+ task_id = task_identifier(task_id)
601
621
 
602
622
  begin
603
623
  srv.rpc_request('tasks/result', { taskId: task_id })
@@ -629,14 +649,17 @@ module MCPClient
629
649
  end
630
650
 
631
651
  # Cancel a task (tasks/cancel, MCP 2025-11-25)
632
- # @param task_id [String] the ID of the task to cancel
652
+ # @param task_id [String, MCPClient::Task] the task to cancel; passing the
653
+ # Task handle returned by #call_tool_as_task routes to its own server
633
654
  # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
634
655
  # @return [MCPClient::Task] the task with updated (cancelled) status
656
+ # @raise [ArgumentError] if the server is ambiguous in a multi-server client
635
657
  # @raise [MCPClient::Errors::ServerNotFound] if no server is available
636
658
  # @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
637
659
  # @raise [MCPClient::Errors::TaskError] if cancellation fails (including cancelling a terminal task)
638
660
  def cancel_task(task_id, server: nil)
639
- srv = select_server(server)
661
+ srv = select_task_server(task_id, server, 'cancel_task')
662
+ task_id = task_identifier(task_id)
640
663
  ensure_task_capability!(srv, 'cancel')
641
664
 
642
665
  begin
@@ -823,9 +846,10 @@ module MCPClient
823
846
  logger_name = params['logger']
824
847
  data = params['data']
825
848
 
826
- # Format the message
827
- prefix = logger_name ? "[#{server_id}:#{logger_name}]" : "[#{server_id}]"
828
- message = data.is_a?(String) ? data : data.inspect
849
+ # Format the message. Both the logger name and the payload come from the
850
+ # remote server, so both are sanitized before they reach the host log.
851
+ prefix = logger_name ? "[#{server_id}:#{sanitize_peer_log_text(logger_name.to_s)}]" : "[#{server_id}]"
852
+ message = sanitize_peer_log_text(data.is_a?(String) ? data : data.inspect)
829
853
 
830
854
  # Map MCP log levels to Ruby Logger levels
831
855
  case level.to_s.downcase
@@ -838,10 +862,75 @@ module MCPClient
838
862
  when 'error', 'critical', 'alert', 'emergency'
839
863
  logger.error("#{prefix} #{message}")
840
864
  else
841
- logger.info("#{prefix} [#{level}] #{message}")
865
+ # An out-of-enum level is peer-controlled text like any other: it must
866
+ # be sanitized and capped, or it becomes the log-forging vector the
867
+ # sanitizing of `data` was added to close.
868
+ logger.info("#{prefix} [#{sanitize_peer_log_text(level.to_s)}] #{message}")
842
869
  end
843
870
  end
844
871
 
872
+ # Make peer-supplied log text safe to write to the host log: control
873
+ # characters (notably newlines, which would let a server forge additional
874
+ # log entries) are escaped, and the result is capped.
875
+ # @param text [String] the peer-supplied text
876
+ # @return [String] sanitized, length-bounded text
877
+ def sanitize_peer_log_text(text)
878
+ escaped = text.gsub(/[-]/) { |c| format('\\x%02X', c.ord) }
879
+ return escaped if escaped.length <= MAX_PEER_LOG_MESSAGE_LENGTH
880
+
881
+ "#{escaped[0, MAX_PEER_LOG_MESSAGE_LENGTH]}... (truncated from #{escaped.length} chars)"
882
+ end
883
+
884
+ # Copy of a server config with credential-bearing values replaced, for
885
+ # safe logging. Nested hashes (headers, env) have every value redacted;
886
+ # sensitive scalars are replaced outright.
887
+ # @param config [Hash, Object] a server configuration
888
+ # @return [Hash, Object] a redacted copy (non-Hash input is returned as-is)
889
+ def redact_config(config)
890
+ return config unless config.is_a?(Hash)
891
+
892
+ config.to_h do |key, value|
893
+ next [key, value] unless SENSITIVE_CONFIG_KEYS.include?(key.to_s.downcase.to_sym)
894
+
895
+ redacted = value.is_a?(Hash) ? value.transform_values { REDACTED } : REDACTED
896
+ [key, redacted]
897
+ end
898
+ end
899
+
900
+ # Resolve which server a task operation targets.
901
+ #
902
+ # Task IDs are only unique within the server that issued them, so silently
903
+ # defaulting to the first configured server can poll, read or cancel an
904
+ # unrelated task on the wrong server. Resolution order:
905
+ # 1. an explicit server: argument wins;
906
+ # 2. a Task handle carries the server that issued it;
907
+ # 3. a bare ID with exactly one configured server is unambiguous;
908
+ # 4. anything else is ambiguous and fails closed.
909
+ # @param task [String, MCPClient::Task] the task or its ID
910
+ # @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] explicit selector
911
+ # @param operation [String] calling method name, for the error message
912
+ # @return [MCPClient::ServerBase]
913
+ # @raise [ArgumentError] when the target server cannot be determined
914
+ def select_task_server(task, server_arg, operation)
915
+ # nil, not falsiness: `server: false` is an invalid selector that
916
+ # select_server rejects with ArgumentError, and treating it as "omitted"
917
+ # would silently route a read or a cancel somewhere instead of failing.
918
+ return select_server(server_arg) unless server_arg.nil?
919
+ return task.server if task.is_a?(MCPClient::Task) && task.server
920
+ return select_server(nil) if @servers.size <= 1
921
+
922
+ raise ArgumentError,
923
+ "#{operation} is ambiguous with multiple servers configured: task IDs are only unique per server. " \
924
+ 'Pass the Task returned by call_tool_as_task, or name the server explicitly ' \
925
+ "(e.g. #{operation}(id, server: 'name'))."
926
+ end
927
+
928
+ # @param task [String, MCPClient::Task] a task or its ID
929
+ # @return [String] the task ID
930
+ def task_identifier(task)
931
+ task.is_a?(MCPClient::Task) ? task.task_id : task
932
+ end
933
+
845
934
  # Select a server based on index, name, type, or instance
846
935
  # @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
847
936
  # @return [MCPClient::ServerBase]
@@ -1161,9 +1250,13 @@ module MCPClient
1161
1250
 
1162
1251
  format_elicitation_response(result, params)
1163
1252
  rescue StandardError => e
1253
+ # Same reasoning as the sampling path: the handler's exception text is
1254
+ # host-internal and must not cross to the server. Because this rescue
1255
+ # runs inside the client, the transports' constant-message rescues
1256
+ # never see it — so it has to be constant here.
1164
1257
  @logger.error("Elicitation handler error: #{e.message}")
1165
1258
  @logger.debug(e.backtrace.join("\n"))
1166
- jsonrpc_error_result(-32_603, "Elicitation handler error: #{e.message}")
1259
+ jsonrpc_error_result(-32_603, 'Elicitation handler error')
1167
1260
  end
1168
1261
  end
1169
1262
 
@@ -1387,8 +1480,10 @@ module MCPClient
1387
1480
  @logger.debug(e.backtrace.join("\n"))
1388
1481
  # A handler exception is an internal client failure (-32603), not a
1389
1482
  # user rejection: sampling.mdx § Error Handling reserves -1 for
1390
- # "User rejected sampling request".
1391
- jsonrpc_error_result(-32_603, "Sampling error: #{e.message}")
1483
+ # "User rejected sampling request". The exception message itself is
1484
+ # host-internal (file paths, connection strings, library internals)
1485
+ # and stays in the local log rather than crossing to the server.
1486
+ jsonrpc_error_result(-32_603, 'Sampling error')
1392
1487
  end
1393
1488
  end
1394
1489
 
@@ -18,6 +18,18 @@ module MCPClient
18
18
  # Allowed string formats per MCP spec
19
19
  STRING_FORMATS = %w[email uri date date-time].freeze
20
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
+
21
33
  # Validate that a requestedSchema conforms to MCP elicitation constraints.
22
34
  # Returns an array of error messages (empty if valid).
23
35
  # @param schema [Hash] the requestedSchema
@@ -118,6 +130,9 @@ module MCPClient
118
130
  errors = []
119
131
  return errors unless content.is_a?(Hash) && schema.is_a?(Hash)
120
132
 
133
+ # One deadline covers every field's pattern in this call.
134
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATTERN_MATCH_TIMEOUT
135
+
121
136
  properties = schema['properties'] || {}
122
137
  required = Array(schema['required'])
123
138
 
@@ -132,7 +147,7 @@ module MCPClient
132
147
  prop = properties[field.to_s]
133
148
  next unless prop.is_a?(Hash)
134
149
 
135
- errors.concat(validate_value(field.to_s, value, prop))
150
+ errors.concat(validate_value(field.to_s, value, prop, deadline))
136
151
  end
137
152
 
138
153
  errors
@@ -143,13 +158,13 @@ module MCPClient
143
158
  # @param value [Object] the value to validate
144
159
  # @param prop [Hash] property schema
145
160
  # @return [Array<String>] validation errors
146
- def self.validate_value(field, value, prop)
161
+ def self.validate_value(field, value, prop, deadline = nil)
147
162
  errors = []
148
163
  type = prop['type']
149
164
 
150
165
  case type
151
166
  when 'string'
152
- errors.concat(validate_string_value(field, value, prop))
167
+ errors.concat(validate_string_value(field, value, prop, deadline))
153
168
  when 'number', 'integer'
154
169
  errors.concat(validate_number_value(field, value, prop))
155
170
  when 'boolean'
@@ -166,7 +181,7 @@ module MCPClient
166
181
  # @param value [Object] the value
167
182
  # @param prop [Hash] property schema
168
183
  # @return [Array<String>] validation errors
169
- def self.validate_string_value(field, value, prop)
184
+ def self.validate_string_value(field, value, prop, deadline = nil)
170
185
  errors = []
171
186
 
172
187
  unless value.is_a?(String)
@@ -183,15 +198,7 @@ module MCPClient
183
198
  errors << "Field '#{field}' must be one of: #{allowed.join(', ')}" unless allowed.include?(value)
184
199
  end
185
200
 
186
- if prop['pattern']
187
- begin
188
- unless value.match?(Regexp.new(prop['pattern']))
189
- errors << "Field '#{field}' must match pattern '#{prop['pattern']}'"
190
- end
191
- rescue RegexpError
192
- # Skip pattern validation if the pattern is invalid
193
- end
194
- end
201
+ errors.concat(validate_string_pattern(field, value, prop['pattern'], deadline)) if prop['pattern']
195
202
 
196
203
  if prop['minLength'] && value.length < prop['minLength']
197
204
  errors << "Field '#{field}' must be at least #{prop['minLength']} characters"
@@ -206,6 +213,42 @@ module MCPClient
206
213
  errors
207
214
  end
208
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
+
209
252
  # Validate a string value against the schema's format constraint.
210
253
  # The MCP elicitation schema supports email, uri, date, and date-time.
211
254
  # @param field [String] field name
@@ -77,6 +77,14 @@ module MCPClient
77
77
  # sender SHOULD cancel and stop waiting, not re-send).
78
78
  class RequestTimeoutError < TransportError; end
79
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
+
80
88
  # Raised when tool parameters fail validation against the tool's input
81
89
  # schema, or (in strict mode) when a tool result's structuredContent fails
82
90
  # validation against the tool's output schema