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 +4 -4
- data/README.md +95 -7
- data/lib/mcp_client/auth/oauth_provider.rb +91 -3
- data/lib/mcp_client/auth.rb +0 -4
- data/lib/mcp_client/client.rb +109 -14
- data/lib/mcp_client/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +8 -0
- data/lib/mcp_client/http_transport_base.rb +29 -6
- data/lib/mcp_client/json_rpc_common.rb +70 -1
- data/lib/mcp_client/resource.rb +0 -2
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +0 -2
- data/lib/mcp_client/schema_validator.rb +56 -12
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +34 -6
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +38 -7
- data/lib/mcp_client/server_sse.rb +83 -14
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +2 -2
- data/lib/mcp_client/server_stdio.rb +6 -3
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +77 -7
- data/lib/mcp_client/server_streamable_http.rb +246 -43
- data/lib/mcp_client/tool.rb +0 -2
- data/lib/mcp_client/version.rb +1 -1
- data/lib/mcp_client.rb +8 -1
- metadata +4 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0705c6df1b589bdf048b8922a92c957125f75afd0ce3ba832ef71b99a9cbe87a
|
|
4
|
+
data.tar.gz: 486d2c9891e107acaf545a62d1845e0f3d843c634d526d80ee8d12c8f9e3bde7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
396
|
-
|
|
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
|
-
-
|
|
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
|
|
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}")
|
data/lib/mcp_client/auth.rb
CHANGED
|
@@ -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
|
data/lib/mcp_client/client.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/mcp_client/errors.rb
CHANGED
|
@@ -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
|