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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +216 -10
  3. data/lib/mcp_client/auth/oauth_provider.rb +325 -27
  4. data/lib/mcp_client/auth.rb +38 -11
  5. data/lib/mcp_client/client.rb +523 -150
  6. data/lib/mcp_client/elicitation_validator.rb +99 -13
  7. data/lib/mcp_client/errors.rb +43 -1
  8. data/lib/mcp_client/http_transport_base.rb +254 -41
  9. data/lib/mcp_client/json_rpc_common.rb +196 -14
  10. data/lib/mcp_client/oauth_client.rb +8 -3
  11. data/lib/mcp_client/prompt.rb +17 -2
  12. data/lib/mcp_client/resource.rb +13 -2
  13. data/lib/mcp_client/resource_content.rb +8 -3
  14. data/lib/mcp_client/resource_link.rb +14 -3
  15. data/lib/mcp_client/resource_template.rb +13 -2
  16. data/lib/mcp_client/root.rb +61 -7
  17. data/lib/mcp_client/schema_validator.rb +329 -0
  18. data/lib/mcp_client/server_base.rb +66 -0
  19. data/lib/mcp_client/server_factory.rb +4 -1
  20. data/lib/mcp_client/server_http/json_rpc_transport.rb +3 -2
  21. data/lib/mcp_client/server_http.rb +18 -12
  22. data/lib/mcp_client/server_sse/json_rpc_transport.rb +97 -14
  23. data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
  24. data/lib/mcp_client/server_sse/reconnect_monitor.rb +17 -4
  25. data/lib/mcp_client/server_sse/sse_parser.rb +78 -10
  26. data/lib/mcp_client/server_sse.rb +132 -35
  27. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +31 -8
  28. data/lib/mcp_client/server_stdio.rb +98 -20
  29. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +222 -26
  30. data/lib/mcp_client/server_streamable_http.rb +472 -108
  31. data/lib/mcp_client/tool.rb +16 -3
  32. data/lib/mcp_client/version.rb +6 -1
  33. data/lib/mcp_client.rb +9 -1
  34. metadata +5 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e99574cdccfbab39918f6576fc7c896b5208599313bd69e2f39df5b529c82c22
4
- data.tar.gz: 823177bc1a531d773514b9d9da8d3d6f0f2aa15f6e8562e8c0123adcdb38911c
3
+ metadata.gz: 0705c6df1b589bdf048b8922a92c957125f75afd0ce3ba832ef71b99a9cbe87a
4
+ data.tar.gz: 486d2c9891e107acaf545a62d1845e0f3d843c634d526d80ee8d12c8f9e3bde7
5
5
  SHA512:
6
- metadata.gz: bc352d545dbabb90322ab473eaff7c6d52111ccd6e4812baa8b6507dc720024e23752b4a68481dc5b83722d2c9c4b6b3a36a17821998fc745cd8164e57ac8380
7
- data.tar.gz: 72b2cdc279b54f2b10cbac22755086edee67792bd18e035d167f2855ad1d5dc5999c87d98e17bef83a70632459003bbd4af516414fe0e7b78b918fc2b2c5a8ce
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
- - **OAuth 2.1**: PKCE, server discovery, dynamic registration
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.task_id) # tasks/get
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.task_id)
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.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')
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 and re-sending
294
- would risk re-executing a non-idempotent `tools/call`. Retryable server failures
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): Tracks event IDs for message replay
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
- - 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.
526
732
 
527
733
  ## License
528
734