ruby-mcp-client 1.1.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +216 -10
- data/lib/mcp_client/auth/oauth_provider.rb +325 -27
- data/lib/mcp_client/auth.rb +38 -11
- data/lib/mcp_client/client.rb +523 -150
- data/lib/mcp_client/elicitation_validator.rb +99 -13
- data/lib/mcp_client/errors.rb +43 -1
- data/lib/mcp_client/http_transport_base.rb +254 -41
- data/lib/mcp_client/json_rpc_common.rb +196 -14
- data/lib/mcp_client/oauth_client.rb +8 -3
- data/lib/mcp_client/prompt.rb +17 -2
- data/lib/mcp_client/resource.rb +13 -2
- data/lib/mcp_client/resource_content.rb +8 -3
- data/lib/mcp_client/resource_link.rb +14 -3
- data/lib/mcp_client/resource_template.rb +13 -2
- data/lib/mcp_client/root.rb +61 -7
- data/lib/mcp_client/schema_validator.rb +329 -0
- data/lib/mcp_client/server_base.rb +66 -0
- data/lib/mcp_client/server_factory.rb +4 -1
- data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
- data/lib/mcp_client/server_http.rb +18 -12
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
- data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
- data/lib/mcp_client/server_sse.rb +132 -35
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
- data/lib/mcp_client/server_stdio.rb +98 -20
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
- data/lib/mcp_client/server_streamable_http.rb +472 -108
- data/lib/mcp_client/tool.rb +16 -3
- data/lib/mcp_client/version.rb +6 -1
- data/lib/mcp_client.rb +9 -1
- metadata +5 -6
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
|
@@ -28,7 +28,10 @@ Built-in API conversions: `to_openai_tools()`, `to_anthropic_tools()`, `to_googl
|
|
|
28
28
|
|
|
29
29
|
## MCP Protocol Support
|
|
30
30
|
|
|
31
|
-
Implements **MCP 2025-11-25** specification
|
|
31
|
+
Implements the **MCP 2025-11-25** specification. The client negotiates the
|
|
32
|
+
protocol version during `initialize` and disconnects if the server answers
|
|
33
|
+
with a revision it cannot speak (supported: `2025-11-25`, `2025-06-18`,
|
|
34
|
+
`2025-03-26`, `2024-11-05`):
|
|
32
35
|
|
|
33
36
|
- **Tools**: list, call, streaming, annotations (hint-style), structured outputs, title
|
|
34
37
|
- **Prompts**: list, get with parameters
|
|
@@ -40,7 +43,13 @@ Implements **MCP 2025-11-25** specification:
|
|
|
40
43
|
- **Logging**: Server log messages with level filtering
|
|
41
44
|
- **Tasks**: Task-augmented `tools/call` — create with a `ttl`, poll `tasks/get`, retrieve via `tasks/result`, plus `tasks/list` and `tasks/cancel`
|
|
42
45
|
- **Audio**: Audio content type support
|
|
43
|
-
- **
|
|
46
|
+
- **Progress & Cancellation**: `progressToken` plumbing with per-call callbacks; automatic `notifications/cancelled` for abandoned requests
|
|
47
|
+
- **Metadata**: `icons`, `title` and `_meta` parsed on tools, prompts and resources
|
|
48
|
+
- **OAuth 2.1**: PKCE (S256 required), RFC 8414/9728 discovery, dynamic registration, Client ID Metadata Documents, scope step-up challenges
|
|
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.
|
|
44
53
|
|
|
45
54
|
## Quick Connect API (Recommended)
|
|
46
55
|
|
|
@@ -152,6 +161,25 @@ tool.output_schema # JSON Schema for output
|
|
|
152
161
|
|
|
153
162
|
result = client.call_tool('get_weather', { location: 'SF' })
|
|
154
163
|
data = result['structuredContent'] # Type-safe structured data
|
|
164
|
+
|
|
165
|
+
# Per MCP 2025-11-25, clients SHOULD validate structured results against the
|
|
166
|
+
# tool's output schema, and a tool that declares an outputSchema must return
|
|
167
|
+
# structuredContent in successful results. call_tool checks both automatically
|
|
168
|
+
# for the common JSON Schema keywords (type, properties, required, items, enum,
|
|
169
|
+
# numeric/string bounds). The full 2020-12 vocabulary ($ref/$dynamicRef/$defs,
|
|
170
|
+
# allOf/anyOf/oneOf/not, if/then/else, additionalProperties, patternProperties,
|
|
171
|
+
# propertyNames, prefixItems, contains/minContains/maxContains, uniqueItems,
|
|
172
|
+
# multipleOf, format, dependentRequired/dependentSchemas, minProperties/
|
|
173
|
+
# maxProperties, unevaluated*) is NOT evaluated: when a schema uses any of
|
|
174
|
+
# those keywords, call_tool logs a "validation is partial" warning naming them
|
|
175
|
+
# (in both modes), since data may pass this check that a full validator would
|
|
176
|
+
# reject. By default a violation (mismatch, or missing structuredContent on a
|
|
177
|
+
# successful result) logs a warning; opt in to strict mode to raise instead:
|
|
178
|
+
client = MCPClient::Client.new(
|
|
179
|
+
mcp_server_configs: [...],
|
|
180
|
+
validate_structured_content: :strict # raises MCPClient::Errors::ValidationError on violation
|
|
181
|
+
)
|
|
182
|
+
# Task-delivered results (get_task_result) are not validated yet.
|
|
155
183
|
```
|
|
156
184
|
|
|
157
185
|
### Roots
|
|
@@ -184,6 +212,81 @@ client = MCPClient.connect('http://server/mcp',
|
|
|
184
212
|
)
|
|
185
213
|
```
|
|
186
214
|
|
|
215
|
+
Sampling tool calling (SEP-1577) is opt-in: pass `sampling_supports_tools: true`
|
|
216
|
+
to declare the `sampling.tools` capability. The handler then receives the full
|
|
217
|
+
request params (including `tools`/`toolChoice`) as an optional fifth argument;
|
|
218
|
+
without the opt-in, tool-enabled sampling requests are rejected with `-32602`
|
|
219
|
+
as the spec requires:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
client = MCPClient::Client.new(
|
|
223
|
+
mcp_server_configs: [...],
|
|
224
|
+
sampling_supports_tools: true,
|
|
225
|
+
sampling_handler: ->(messages, prefs, system_prompt, max_tokens, params = nil) {
|
|
226
|
+
tools = params && params['tools'] # ToolUseContent may be returned in content
|
|
227
|
+
# ...
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Progress Tracking
|
|
233
|
+
|
|
234
|
+
Attach a per-call progress callback — the client generates a unique
|
|
235
|
+
`progressToken`, places it in the request `_meta`, and routes matching
|
|
236
|
+
`notifications/progress` to your block while the request is active (stale
|
|
237
|
+
tokens after completion are dropped):
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
client.call_tool('long_running', args, progress: ->(progress, total, message) {
|
|
241
|
+
puts "#{message}: #{progress}/#{total}"
|
|
242
|
+
})
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
A request-level `_meta` (e.g. a hand-picked `progressToken`) can also be passed
|
|
246
|
+
inside the arguments under the `'_meta'` key on every transport — it is hoisted
|
|
247
|
+
to the JSON-RPC params level on the wire, never sent as a tool argument.
|
|
248
|
+
|
|
249
|
+
### Timeouts and Cancellation
|
|
250
|
+
|
|
251
|
+
Timeouts are configurable per request in addition to the per-server
|
|
252
|
+
`read_timeout`. A timed-out request raises
|
|
253
|
+
`MCPClient::Errors::RequestTimeoutError` (a `TransportError` subclass), is
|
|
254
|
+
**never** silently re-sent by the retry layer, and a best-effort
|
|
255
|
+
`notifications/cancelled` is sent for the abandoned request (never for
|
|
256
|
+
`initialize`, and task-augmented calls use `tasks/cancel` instead):
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
client.send_rpc('tools/call', params: { name: 'slow', arguments: {} }, timeout: 300)
|
|
260
|
+
server.rpc_request('tools/list', {}, timeout: 5)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Client Identity and Server Instructions
|
|
264
|
+
|
|
265
|
+
Hosts can present their own `Implementation` info (sent as `clientInfo` during
|
|
266
|
+
initialize; `name` and `version` required — `title`, `description`,
|
|
267
|
+
`websiteUrl`, `icons` optional), and read the server's `instructions` hint
|
|
268
|
+
after connecting:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
client = MCPClient::Client.new(
|
|
272
|
+
mcp_server_configs: [...],
|
|
273
|
+
client_info: { 'name' => 'my-ide', 'version' => '2.0.0', 'description' => 'An MCP-powered IDE' }
|
|
274
|
+
)
|
|
275
|
+
client.servers.first.connect
|
|
276
|
+
puts client.servers.first.instructions # e.g. "Use the search tool before answering."
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Capability Gating
|
|
280
|
+
|
|
281
|
+
Optional server features (`logging/setLevel`, `resources/subscribe`,
|
|
282
|
+
`completion/complete`, `tasks/list`, `tasks/cancel`) are only sent to servers
|
|
283
|
+
that negotiated the corresponding capability; otherwise
|
|
284
|
+
`MCPClient::Errors::CapabilityError` is raised (the lifecycle forbids using
|
|
285
|
+
capabilities that were not negotiated). `Client#log_level=` skips
|
|
286
|
+
non-logging servers instead of failing. Declared *client* capabilities are
|
|
287
|
+
derived from what the host actually registered (handlers, roots), never
|
|
288
|
+
hardcoded.
|
|
289
|
+
|
|
187
290
|
### Completion (Autocomplete)
|
|
188
291
|
|
|
189
292
|
```ruby
|
|
@@ -213,6 +316,9 @@ end
|
|
|
213
316
|
A task-capable server (one advertising `tasks.requests.tools.call`) can run a tool
|
|
214
317
|
whose `execution.taskSupport` is `optional` or `required` as a background task:
|
|
215
318
|
the call returns immediately with a task handle, and the result is fetched later.
|
|
319
|
+
Try it locally: `python3 examples/echo_server_streamable.py &` then
|
|
320
|
+
`./examples/tasks_example.rb` runs the full lifecycle against a task-capable
|
|
321
|
+
demo server.
|
|
216
322
|
|
|
217
323
|
```ruby
|
|
218
324
|
tool = client.find_tool('long_job')
|
|
@@ -225,15 +331,24 @@ task = client.call_tool_as_task('long_job', { input: 'data' }, ttl: 60_000)
|
|
|
225
331
|
# honoring the server's suggested poll interval
|
|
226
332
|
until task.terminal? || task.input_required?
|
|
227
333
|
sleep((task.poll_interval || 1000) / 1000.0)
|
|
228
|
-
task = client.get_task(task
|
|
334
|
+
task = client.get_task(task) # tasks/get, routed to the task's own server
|
|
229
335
|
end
|
|
230
336
|
|
|
231
337
|
# Retrieve the underlying result (e.g. a CallToolResult) via tasks/result
|
|
232
|
-
result = client.get_task_result(task
|
|
338
|
+
result = client.get_task_result(task)
|
|
233
339
|
|
|
234
340
|
# List and cancel tasks
|
|
235
341
|
page = client.list_tasks # { tasks: [...], next_cursor: ... }
|
|
236
|
-
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')
|
|
237
352
|
|
|
238
353
|
# React to server-pushed status updates
|
|
239
354
|
client.on_notification do |server, method, params|
|
|
@@ -290,12 +405,49 @@ The `retries:` option controls automatic retry with exponential backoff. Only
|
|
|
290
405
|
failures where the request most likely did **not** complete at the server are
|
|
291
406
|
retried: transport/network errors and HTTP **5xx** responses. Application-level
|
|
292
407
|
failures — a JSON-RPC error response or an HTTP **4xx** — are **never** retried,
|
|
293
|
-
because the server already processed or rejected the request
|
|
294
|
-
|
|
295
|
-
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
|
|
296
410
|
`MCPClient::Errors::ServerError`, so existing `rescue ServerError` handlers are
|
|
297
411
|
unaffected.
|
|
298
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
|
+
|
|
299
451
|
### Faraday Customization
|
|
300
452
|
|
|
301
453
|
```ruby
|
|
@@ -470,6 +622,20 @@ Features: PKCE, server discovery (`.well-known`), dynamic registration, token re
|
|
|
470
622
|
|
|
471
623
|
See [OAUTH.md](OAUTH.md) for full documentation.
|
|
472
624
|
|
|
625
|
+
### OAuth Extras (2025-11-25)
|
|
626
|
+
|
|
627
|
+
- **Client ID Metadata Documents (SEP-991)** — pass
|
|
628
|
+
`client_id_metadata_url: 'https://myapp.example/oauth-client.json'` (an HTTPS
|
|
629
|
+
URL with a path, which doubles as the `client_id`); when the authorization
|
|
630
|
+
server advertises `client_id_metadata_document_supported`, dynamic client
|
|
631
|
+
registration is skipped entirely.
|
|
632
|
+
- **Scope challenges (SEP-835)** — an HTTP 403 `insufficient_scope` challenge
|
|
633
|
+
raises `MCPClient::Errors::InsufficientScopeError` (a `ConnectionError`
|
|
634
|
+
subclass) exposing `#scope` and `#error_description`; the challenged scopes
|
|
635
|
+
are treated as authoritative for the next authorization flow.
|
|
636
|
+
- **PKCE** — authorization refuses to proceed when the authorization server
|
|
637
|
+
does not advertise `code_challenge_methods_supported` including `S256`.
|
|
638
|
+
|
|
473
639
|
## Server Notifications
|
|
474
640
|
|
|
475
641
|
```ruby
|
|
@@ -492,7 +658,9 @@ Both HTTP and Streamable HTTP transports automatically handle session-based serv
|
|
|
492
658
|
- **Session capture**: Extracts `Mcp-Session-Id` from initialize response
|
|
493
659
|
- **Session persistence**: Includes session header in subsequent requests
|
|
494
660
|
- **Session termination**: Sends DELETE request during cleanup
|
|
495
|
-
- **Resumability** (Streamable HTTP):
|
|
661
|
+
- **Resumability** (Streamable HTTP, SEP-1699): tracks SSE event IDs and, when a
|
|
662
|
+
response stream is interrupted, resumes via GET with `Last-Event-ID` so the
|
|
663
|
+
server can replay missed messages — honoring the server's `retry:` directive
|
|
496
664
|
|
|
497
665
|
No configuration required - works automatically.
|
|
498
666
|
|
|
@@ -519,10 +687,48 @@ tools = client.list_tools
|
|
|
519
687
|
result = client.call_tool('echo', { message: 'Hello!' })
|
|
520
688
|
```
|
|
521
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
|
+
|
|
522
724
|
## Requirements
|
|
523
725
|
|
|
524
726
|
- Ruby >= 3.2.0
|
|
525
|
-
-
|
|
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.
|
|
526
732
|
|
|
527
733
|
## License
|
|
528
734
|
|