ruby-mcp-client 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
data/README.md CHANGED
@@ -20,7 +20,7 @@ gem install ruby-mcp-client
20
20
  MCP enables AI assistants to discover and invoke external tools via different transport mechanisms:
21
21
 
22
22
  - **stdio** - Local processes implementing the MCP protocol
23
- - **SSE** - Server-Sent Events with streaming support
23
+ - **SSE** *(deprecated)* - Server-Sent Events with streaming support; the HTTP+SSE transport is deprecated and new integrations should use **Streamable HTTP** instead (see [Deprecated features](#deprecated-features))
24
24
  - **HTTP** - Simple request/response (non-streaming)
25
25
  - **Streamable HTTP** - HTTP POST with SSE-formatted responses
26
26
 
@@ -28,24 +28,32 @@ Built-in API conversions: `to_openai_tools()`, `to_anthropic_tools()`, `to_googl
28
28
 
29
29
  ## MCP Protocol Support
30
30
 
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`):
35
-
36
- - **Tools**: list, call, streaming, annotations (hint-style), structured outputs, title
31
+ Implements the **MCP 2026-07-28** specification and stays compatible with
32
+ every earlier revision (`2025-11-25`, `2025-06-18`, `2025-03-26`,
33
+ `2024-11-05`). The client is dual-era: it probes each server with
34
+ `server/discover` and talks the stateless 2026-07-28 protocol (per-request
35
+ `_meta`, no `initialize`, no sessions) to servers that answer it, and runs
36
+ the classic `initialize` handshake with everyone else — disconnecting if a
37
+ server answers with a revision it cannot speak. See
38
+ [MCP 2026-07-28 Features](#mcp-2026-07-28-features) for the new
39
+ capabilities and [Deprecated features](#deprecated-features) for what the
40
+ revision retires.
41
+
42
+ - **Tools**: list, call, streaming, annotations (hint-style), structured outputs validated against `outputSchema` (JSON Schema 2020-12 / 2019-09 / draft-07), title, `x-mcp-header` parameters
37
43
  - **Prompts**: list, get with parameters
38
44
  - **Resources**: list, read, templates, subscriptions, pagination, ResourceLink content
39
- - **Elicitation**: Server-initiated user interactions (stdio, SSE, Streamable HTTP)
40
- - **Roots**: Filesystem scope boundaries with change notifications
41
- - **Sampling**: Server-requested LLM completions with modelPreferences
45
+ - **Elicitation**: Server-initiated user interactions (stdio, Streamable HTTP, and the *deprecated* HTTP+SSE transport) and multi round-trip `input_required` results
46
+ - **Roots** *(deprecated in 2026-07-28)*: Filesystem scope boundaries with change notifications
47
+ - **Sampling** *(deprecated in 2026-07-28)*: Server-requested LLM completions with modelPreferences
42
48
  - **Completion**: Autocomplete for prompts/resources with context
43
- - **Logging**: Server log messages with level filtering
44
- - **Tasks**: Task-augmented `tools/call` — create with a `ttl`, poll `tasks/get`, retrieve via `tasks/result`, plus `tasks/list` and `tasks/cancel`
49
+ - **Logging** *(deprecated in 2026-07-28)*: Server log messages with level filtering
50
+ - **Tasks**: Task-augmented `tools/call` — declare the `io.modelcontextprotocol/tasks` extension, poll `tasks/get`, answer `inputRequests` with `tasks/update`, and take the outcome from `tasks/get`; the 2025-11-25 surface stays as legacy
51
+ - **Subscriptions**: `subscriptions/listen` notification streams (2026-07-28)
52
+ - **Caching**: `ttlMs` / `cacheScope` freshness hints on lists, reads and discovery (2026-07-28)
45
53
  - **Audio**: Audio content type support
46
- - **Progress & Cancellation**: `progressToken` plumbing with per-call callbacks; automatic `notifications/cancelled` for abandoned requests
54
+ - **Progress & Cancellation**: `progressToken` plumbing with per-call callbacks; automatic `notifications/cancelled` for abandoned requests (on a modern Streamable HTTP session closing the response stream is itself the cancellation, so no notification is sent)
47
55
  - **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
56
+ - **OAuth 2.1**: PKCE (S256 required), RFC 8414/9728 discovery, RFC 9207 issuer validation, Client ID Metadata Documents, dynamic registration *(deprecated)*, scope step-up challenges
49
57
 
50
58
  Transports treat the server as untrusted input — see
51
59
  [Treating the Server as Untrusted](#treating-the-server-as-untrusted) for the
@@ -59,9 +67,9 @@ The simplest way to connect to an MCP server:
59
67
  require 'mcp_client'
60
68
 
61
69
  # Auto-detect transport from URL
62
- client = MCPClient.connect('http://localhost:8000/sse') # SSE
63
70
  client = MCPClient.connect('http://localhost:8931/mcp') # Streamable HTTP
64
71
  client = MCPClient.connect('npx -y @modelcontextprotocol/server-filesystem /home') # stdio
72
+ client = MCPClient.connect('http://localhost:8000/sse') # SSE (HTTP+SSE: deprecated, use Streamable HTTP)
65
73
 
66
74
  # With options
67
75
  client = MCPClient.connect('http://api.example.com/mcp',
@@ -72,26 +80,41 @@ client = MCPClient.connect('http://api.example.com/mcp',
72
80
  )
73
81
 
74
82
  # Multiple servers
75
- client = MCPClient.connect(['http://server1/mcp', 'http://server2/sse'])
83
+ client = MCPClient.connect(['http://server1/mcp', 'http://server2/mcp'])
76
84
 
77
85
  # Force specific transport
78
86
  client = MCPClient.connect('http://custom.com/api', transport: :streamable_http)
79
87
 
88
+ # Protocol era and extensions (see "MCP 2026-07-28 Features" below)
89
+ client = MCPClient.connect('http://api.example.com/mcp',
90
+ protocol: :modern, # :auto (default), :modern or :legacy
91
+ discover_timeout: 5, # bound on the server/discover probe
92
+ extensions: ['io.modelcontextprotocol/tasks'] # extensions the client declares
93
+ )
94
+
80
95
  # Use the client
81
96
  tools = client.list_tools
82
97
  result = client.call_tool('example_tool', { param: 'value' })
83
98
  client.cleanup
84
99
  ```
85
100
 
101
+ **Configured headers:** `headers:` values are sent on every request, with one
102
+ reserved namespace. On a modern (MCP 2026-07-28) HTTP session the client owns
103
+ `Mcp-Param-*`: those headers are derived from a `tools/call`'s own arguments
104
+ (the tool's `x-mcp-header` annotations), so any header of that name given in
105
+ `headers:` is dropped from modern requests rather than standing in for an
106
+ argument the call did not carry. Legacy sessions, where the namespace has no
107
+ protocol meaning, send it unchanged.
108
+
86
109
  **Transport Detection:**
87
110
 
88
111
  | URL Pattern | Transport |
89
112
  |-------------|-----------|
90
- | Ends with `/sse` | SSE |
113
+ | Ends with `/sse` | SSE — HTTP+SSE is *deprecated*, prefer Streamable HTTP ([Deprecated features](#deprecated-features)) |
91
114
  | Ends with `/mcp` | Streamable HTTP |
92
115
  | `stdio://command` or Array | stdio |
93
116
  | `npx`, `node`, `python`, etc. | stdio |
94
- | Other HTTP URLs | Auto-detect (Streamable HTTP → SSE → HTTP) |
117
+ | Other HTTP URLs | Auto-detect (Streamable HTTP → SSE → HTTP) — the SSE step is the *deprecated* HTTP+SSE transport, tried only after Streamable HTTP fails ([Deprecated features](#deprecated-features)) |
95
118
 
96
119
  ## Working with Tools, Prompts & Resources
97
120
 
@@ -128,8 +151,404 @@ contents.each do |content|
128
151
  puts content.text if content.text?
129
152
  data = Base64.decode64(content.blob) if content.binary?
130
153
  end
154
+
155
+ # Resource update subscriptions (per server)
156
+ server = client.servers.first
157
+ server.subscribe_resource('file:///example.txt')
158
+ server.unsubscribe_resource('file:///example.txt')
159
+ ```
160
+
161
+ `subscribe_resource` answers `true` only once the server has confirmed the
162
+ subscription, and raises otherwise — the server's own error, or
163
+ `MCPClient::Errors::ResourceReadError` (including when the confirmation does
164
+ not arrive within the transport's `read_timeout`). On a 2025-11-25 server the
165
+ confirmation is the `resources/subscribe` response; on a 2026-07-28 one it is
166
+ the `subscriptions/listen` acknowledgment naming that URI, which the call
167
+ waits for. Updates then arrive as `notifications/resources/updated` through
168
+ `on_notification`. Every later acknowledgment of that stream is rechecked too:
169
+ a stream re-opened after a dropped HTTP connection or a stdio restart is a new
170
+ listen request the server may acknowledge more narrowly, so one that comes back
171
+ without the URI closes the subscription instead of leaving the resource
172
+ reported as watched while nothing watches it — including one that arrives in
173
+ the window between the acknowledgment `subscribe_resource` waited for and the
174
+ URI being mapped to the stream, which is checked once more with the mapping in
175
+ place before the call answers. A stream already mapped to the URI is reused
176
+ only while the server is honouring that URI on it *now*: one that has dropped
177
+ is waited for — through the HTTP re-open backoff or the stdio handshake, and
178
+ on past the replacement request until the server answers it — rather than
179
+ reported as a watch on the strength of what the stream that is gone had been
180
+ granted, because no server-side subscription exists between listen attempts
181
+ and the request that replaces one is a new listen the server holds no state
182
+ for and may reject or acknowledge without the URI. Only a stream the server is
183
+ actively honouring counts as a live watch, and one that does not become
184
+ one — its replacement is refused, or nothing answers within the
185
+ acknowledgment timeout — is closed as well as unmapped, so it cannot come back
186
+ and deliver the same updates beside the stream that replaces it, and
187
+ `unsubscribe_resource` is never left looking for a subscription the mapping no
188
+ longer names. The subscriber waiting on its own
189
+ listen request is a different matter and still gets its answer: a connection
190
+ that drops the moment the acknowledgment arrives does not unanswer it, so the
191
+ call does not wait out its acknowledgment timeout for a grant it already
192
+ has — while a replacement request that has actually gone out is unanswered
193
+ until the server answers *it*.
194
+
195
+ On a 2026-07-28 server a host can also open a stream of its own with
196
+ `server.listen(notifications: { tools_list_changed: true }) { |method, params| … }`
197
+ and end it with `subscription.close`. A listen the server never acknowledges is
198
+ given up on rather than left pending for ever: `ack_timeout:` bounds the wait
199
+ for the acknowledgment (the transport's read timeout by default, `false` to
200
+ wait for ever), and one that expires cancels the request and closes the handle
201
+ with a `RequestTimeoutError`. It bounds every listen request made for that
202
+ subscription, not only the first: an HTTP stream re-opened after a drop, and a
203
+ subscription re-sent to the process that replaced the one it was on, are new
204
+ requests the server has to acknowledge afresh. The stream itself is not
205
+ bounded — once acknowledged it runs for as long as the server keeps it. The block runs on the
206
+ subscription's own dispatcher thread, never on the transport's reader, so a
207
+ listener may issue requests of its own; the notifications waiting for it are bounded both in
208
+ number (`MCPClient::Subscription::MAX_PENDING_NOTIFICATIONS`) and in the bytes
209
+ they retain (`MCPClient::Subscription::MAX_PENDING_NOTIFICATION_BYTES`) — a
210
+ count alone is not a memory bound when the peer chooses how big each payload
211
+ is. Everything a queued notification retains is charged, its method name as
212
+ well as its params: a peer tagging `{}` params with a multi-megabyte method
213
+ name would otherwise pay two bytes apiece and put a thousand of them behind a
214
+ slow listener without touching the byte ceiling. A listener that cannot keep
215
+ up with the server loses **repeats**, not signals: whichever ceiling the
216
+ arriving notification would breach, the queue gives up the oldest notification
217
+ about the same thing as it (same method and
218
+ same `uri`/`taskId`), or failing that the oldest of whichever thing has the
219
+ most queued, so a stream watching several resources or tasks never loses the
220
+ only queued update for a quiet one to make room for a busy one. Every MCP
221
+ notification is a "look again" signal about state the host re-reads for itself,
222
+ so a later notice of the same thing carries what the dropped one said, while
223
+ the only notice of another thing carries what nothing else would. Two rules hold
224
+ this together: every queued notification is charged exactly what it retains,
225
+ and every eviction gives up an entry whose removal relieves the pressure that
226
+ caused it — the byte budget considers only the entries charged against it, the
227
+ count ceiling considers them all — so overflow always makes progress and no
228
+ signal is spent on pressure that discarding it cannot relieve. A notification
229
+ larger than the whole byte budget is not charged against it: it is held in a
230
+ slot of its own, and there is only ever one such slot, so it is neither lost
231
+ for being large nor able to displace anything else, and what the queue retains
232
+ stays within the budget plus one peer-sized payload.
233
+ `pending_notifications` / `pending_notification_bytes` /
234
+ `dropped_notifications` report how far behind a listener fell. An incoming
235
+ notification is routed in a fixed order: subscription bookkeeping (an
236
+ acknowledgment, a server-side teardown) first, then the transport and client
237
+ cache invalidation, then the delivery to the subscription's listeners, and the
238
+ host's `on_notification` listeners **last**. Caches are therefore dropped
239
+ *before* a notification reaches the listeners, so a listener reacting to a
240
+ `list_changed` notification by calling `list_tools` (or the prompt or resource
241
+ equivalents) always re-fetches rather than reading the entry the notification
242
+ just invalidated. That holds for the client's caches as well as the
243
+ transport's: the client drops its `tool_cache` / `prompt_cache` /
244
+ `resource_cache` on the transport's `on_cache_invalidation` hook, which runs at
245
+ the invalidation step, rather than on the host callback that runs after the
246
+ delivery. A custom transport that emits no such hook — one written against the
247
+ older interface, which only calls the notification callback — still has those
248
+ caches dropped, on the callback and ahead of everything else there. Everything else the client does with a notification (logging,
249
+ progress callbacks, task status) stays behind the delivery, because it is host
250
+ code or leads to it. Transports that carry no subscription stream announce the
251
+ same hook before their notification callback — the legacy SSE parser, and the
252
+ synthetic `tools/list_changed` a `Mcp-Param-*` header-mismatch refresh emits —
253
+ so nothing that invalidates a cache is announced on only one of the two.
254
+ The host callback comes last because it is the only step
255
+ that can block: it is host code and it runs on whatever thread is routing — on
256
+ stdio the server process's sole reader — so a callback that issues a
257
+ synchronous request of its own would otherwise hold up the delivery while
258
+ waiting for a response only that reader can deliver. Queueing the delivery is
259
+ all the routing thread does (the listeners themselves run on the
260
+ subscription's dispatcher thread), so nothing host-supplied runs ahead of the
261
+ callback either way. Being last, the callback can prevent nothing: one that
262
+ raises is logged and stops neither the invalidation nor the delivery, and one
263
+ that edits the payload it is handed can neither drop nor redirect it — the
264
+ subscription a notification belongs to is resolved, and the delivery queued,
265
+ before the callback sees it. The requested
266
+ filter is copied and frozen when the subscription is created, so a caller that
267
+ keeps and mutates the array it passed cannot change the request that goes out
268
+ (Streamable HTTP builds it on the stream's own thread) or what a reconnect asks
269
+ for. `unsupported` names the requested fields the acknowledgment did not really
270
+ grant, read from its values rather than its keys: a `resourceSubscriptions`
271
+ echoed with none of the requested URIs, or a flag acknowledged as `false`,
272
+ counts as unsupported. `acknowledged` is a frozen copy of what the server
273
+ granted, arrays and strings included: the notification it arrives in is handed
274
+ to `on_notification` and to the subscription's listeners, and host code editing
275
+ it in place must not be able to rewrite the subscription's own record of the
276
+ watch. `active?` answers false while a dropped stream
277
+ waits to re-open, and a closing response the client cannot recognize (an unknown
278
+ `resultType`, a missing or scalar result) fails the subscription instead of
279
+ closing it gracefully — as does one that is recognized but says the request has
280
+ not finished (`input_required`, which is valid on `tools/call`,
281
+ `resources/read` and `prompts/get` alone). On Streamable HTTP closing the SSE response stream *is*
282
+ the cancellation, including against a connection that is still opening its
283
+ socket: once `close` (or the transport's `cleanup`) returns, either the listen
284
+ request was never sent — that session refuses to send one for a closed
285
+ subscription, however long its connect takes — or its response stream has been
286
+ closed. A listen POST the server answers with a 5xx is treated as a dropped
287
+ stream and re-opened on the usual backoff, the way a connection failure or a
288
+ read timeout on the very same request is: a brief 500 or 503 no longer ends a
289
+ long-lived subscription for good. Only a 4xx, or an authorization challenge,
290
+ is the server refusing this subscription, and those still end it.
291
+ On stdio a server process that exits on its own is restarted at once
292
+ while subscriptions are open, since a host that is only waiting for
293
+ notifications never makes the request that would otherwise restart it, and the
294
+ subscriptions are re-sent on the new process. An exit *during* the
295
+ initialization that established the process counts too: a replacement that
296
+ answers the discovery probe and then dies is noticed by its own reader, which
297
+ waits out the initialization still in flight and then restarts — otherwise
298
+ nothing noticed, the dead connection was marked initialized, the re-sent
299
+ listens failed into the "wait for the next process" path, and no reader was
300
+ left to establish one. If the process cannot be restarted, or if
301
+ the process they were last re-sent to died less than
302
+ `MCPClient::ServerStdio::SUBSCRIPTION_RESTART_MIN_INTERVAL` after receiving
303
+ them, they end with that error rather than waiting for ever. Only an exit
304
+ counts against that bound, never a teardown the client asked for: a host that
305
+ calls `cleanup` and reconnects — which every `cleanup`/request cycle does —
306
+ tears the process down whenever it likes, and reading that as a crash closed
307
+ the very subscriptions the reconnect exists to carry across. The record of
308
+ that process answers only that one question, and asking it spends it: a
309
+ subscription opened directly on the replacement, after a refusal had already
310
+ closed the ones the corpse carried, is judged on its own process's uptime
311
+ rather than refused for a crash it had no part in. That interval runs
312
+ from the moment that process received them, not from the moment a restart was
313
+ attempted, so a server whose start-up alone takes longer is not credited with
314
+ its own handshake and respawned for ever; both moments are recorded on the
315
+ record of the process itself, and the question is asked in the one place the
316
+ subscriptions are handed over, so the bound holds however a host request and
317
+ the reader's own restart interleave — whichever of them re-establishes the
318
+ process. A restarted process that negotiates a pre-2026-07-28 version cannot
319
+ carry them either, and ends them with a `CapabilityError` rather than leaving
320
+ them `:reconnecting`. A listen write that fails after the process is gone
321
+ leaves the subscription for the next process to be re-sent to instead of
322
+ closing it — including when it fails on the hand-over itself, where taking the
323
+ new listen id has already moved the subscription off `:reconnecting` and the
324
+ stream being torn down would have been the very one the restart was
325
+ re-establishing. One that fails after a newer stream replaced it raises only
326
+ if that newer stream has itself failed. The subscriptions waiting for a
327
+ process live on a single queue behind one lock, and a handle is on it at most
328
+ once: a `cleanup` moving the open subscriptions onto it overlaps a failed
329
+ hand-over putting one back, and two threads appending to a bare Array can lose
330
+ an entry — stranding a stream the spec requires to be re-sent — or duplicate
331
+ it and send two listen requests for one subscription. `close` cancels what is
332
+ actually outstanding: `notifications/cancelled` names every listen request the
333
+ client wrote for that subscription on the live process, not only the id it
334
+ happens to be on, while ids written to a process that has since gone are
335
+ forgotten rather than cancelled on the one that replaced it. That accounting
336
+ holds because a listen request is written to the pipe it was recorded against
337
+ rather than to whichever process is current when the write finally happens: a
338
+ write still pending when the process exits goes to the process it was opening
339
+ on (failing into the paths above once its pipe is closed), never to the
340
+ replacement, which would otherwise be serving a second stream whose id the
341
+ teardown had already forgotten. On Streamable HTTP the mirror image is
342
+ refused rather than deferred — a `listen` whose connection is closed while it
343
+ is being opened raises `ConnectionError` instead of POSTing onto a transport
344
+ the host has closed, which no later `cleanup` would find.
345
+
346
+ ## MCP 2026-07-28 Features
347
+
348
+ The 2026-07-28 revision makes the protocol stateless: there is no
349
+ `initialize` handshake, no session and no server-to-client request channel.
350
+ Every request carries its own metadata, and anything the server needs from
351
+ the client is asked for through the result itself. The client detects which
352
+ era a server speaks and adapts; existing code keeps working unchanged.
353
+
354
+ ### Discovery and per-request metadata
355
+
356
+ ```ruby
357
+ client = MCPClient.connect('http://localhost:8931/mcp')
358
+ server = client.servers.first
359
+ server.modern? # => true for a 2026-07-28 server
360
+ server.protocol_version # => "2026-07-28"
361
+ server.protocol_era # => :modern or :legacy
362
+ ```
363
+
364
+ A modern server is recognised by its answer to `server/discover`; every
365
+ request then carries `io.modelcontextprotocol/protocolVersion`,
366
+ `io.modelcontextprotocol/clientInfo` and
367
+ `io.modelcontextprotocol/clientCapabilities` in `_meta`, and on HTTP the
368
+ `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers. Host-supplied
369
+ metadata (a Hash or a callable evaluated per request) is merged into every
370
+ request with `MCPClient::Client.new(request_meta: ...)`. Force an era with
371
+ `protocol: :modern` / `protocol: :legacy` (default `:auto`) and tune the
372
+ probe with `discover_timeout:` on `stdio_config`, `http_config` and
373
+ `streamable_http_config` (or the matching server constructors), and on
374
+ `MCPClient.connect` for those same three transports. The deprecated HTTP+SSE
375
+ transport has no era of its own: `MCPClient::ServerSSE` takes neither option,
376
+ so `MCPClient.connect` drops both for an `/sse` URL rather than passing them
377
+ on ([Deprecated features](#deprecated-features)) — except `protocol: :modern`,
378
+ which an explicit `transport: :sse` refuses with `ArgumentError` rather than
379
+ silently connecting legacy.
380
+ `ping` maps to `server/discover` and `log_level=` to the per-request log
381
+ level on modern servers.
382
+
383
+ > `log_level=` is deprecated in MCP 2026-07-28 (SEP-2577). On a modern
384
+ > server it writes `_meta["io.modelcontextprotocol/logLevel"]`, part of the
385
+ > Logging utility the revision marks Deprecated as a whole: new
386
+ > implementations SHOULD NOT adopt it. See
387
+ > [Deprecated features](#deprecated-features).
388
+
389
+ Typed errors
390
+ carry the JSON-RPC code: `MCPClient::Errors::HeaderMismatchError`
391
+ (-32020), `MissingRequiredClientCapabilityError` (-32021) and
392
+ `UnsupportedProtocolVersionError` (-32022).
393
+
394
+ ### Multi round-trip requests
395
+
396
+ On a modern server `tools/call`, `resources/read` and `prompts/get` may
397
+ answer `resultType: "input_required"`. The client fulfils each request in
398
+ `inputRequests` with the handlers it already has — the elicitation handler,
399
+ the sampling handler and the configured roots — and re-sends the original
400
+ request with `inputResponses` and the server's opaque `requestState`. More
401
+ than 10 rounds, a request the client cannot honour or a malformed
402
+ `inputRequests` raise `MCPClient::Errors::InputRequiredError` (exposing
403
+ `input_requests` and `request_state`).
404
+
405
+ ### URL-mode elicitation
406
+
407
+ An elicitation handler of arity 2 (or more) is called with the message and a
408
+ metadata hash when the server asks for an out-of-band (`url`) interaction.
409
+ On a **2025-11-25** server that hash is
410
+ `{ 'mode' => 'url', 'url' => ..., 'elicitationId' => ... }`; on a
411
+ **2026-07-28** server it is `{ 'mode' => 'url', 'url' => ... }` — the
412
+ revision removed `elicitationId` together with
413
+ `notifications/elicitation/complete`, because the outcome is learned by
414
+ retrying the original request rather than from a server-initiated signal, and
415
+ a server that must correlate an elicitation across retries carries its own
416
+ identifier in the opaque `requestState`. A modern server that sends
417
+ `elicitationId` anyway does not reach the host with it: the field is dropped
418
+ and a warning is logged.
419
+
420
+ ### Custom headers from tool parameters
421
+
422
+ Tool parameters annotated with `x-mcp-header` are mirrored into
423
+ `Mcp-Param-{name}` request headers on `tools/call` (Streamable HTTP and
424
+ plain HTTP). A `-32020` HeaderMismatch rejection triggers one `tools/list`
425
+ refresh and a retry; tools with invalid annotations are excluded from the
426
+ list with a warning. `MCPClient::HeaderParams` exposes the validation.
427
+
428
+ ### Subscriptions (`subscriptions/listen`)
429
+
430
+ ```ruby
431
+ subscription = client.listen(notifications: { tools_list_changed: true,
432
+ resource_subscriptions: ['file:///etc/hosts'] }) do |method, params|
433
+ puts "#{method}: #{params.inspect}"
434
+ end
435
+ subscription.acknowledged # what the server agreed to watch
436
+ subscription.unsupported # what it did not
437
+ subscription.close
438
+ ```
439
+
440
+ Each subscription is one long-lived `subscriptions/listen` request; on
441
+ Streamable HTTP it runs on its own stream and is re-opened with backoff when
442
+ the stream drops, on stdio it is re-sent when the process restarts.
443
+ `subscribe_resource` / `unsubscribe_resource` map onto listen streams on
444
+ modern servers.
445
+
446
+ ### Cacheable results
447
+
448
+ `server/discover`, the `*/list` requests and `resources/read` carry
449
+ `ttlMs` and `cacheScope`. Lists are served while fresh and re-fetched on
450
+ access once stale (a stale list is served with a warning when the re-fetch
451
+ fails transiently); `resources/read` results with a `ttlMs` are cached per
452
+ URI. Entries with `cacheScope: "private"` are bound to the credentials the
453
+ request went out with and never shared across authorization contexts.
454
+ `server.cache_info(:tools)` / `cache_info(:read, uri)` expose `ttl_ms`,
455
+ `cache_scope`, `received_at` and `fresh`.
456
+
457
+ ### Tasks extension (`io.modelcontextprotocol/tasks`)
458
+
459
+ ```ruby
460
+ client = MCPClient::Client.new(mcp_server_configs: [...],
461
+ extensions: ['io.modelcontextprotocol/tasks'])
462
+
463
+ result = client.call_tool('long_running', {}) # polls tasks/get transparently
464
+
465
+ task = client.call_tool_as_task('long_running', {})
466
+ task = client.wait_for_task(task, timeout: 120) # answers input_required via tasks/update
467
+ task.result if task.completed?
468
+ client.cancel_task(task)
131
469
  ```
132
470
 
471
+ Task-augmented calls are opt-in through `extensions:`; a server that
472
+ answers `tools/call` with a task is polled at its `pollIntervalMs` until the
473
+ task is terminal or its `ttlMs` elapses, and `input_required` tasks are
474
+ answered with the elicitation / sampling / roots handlers. `tasks/list` and
475
+ `tasks/result` do not exist on 2026-07-28 servers; the legacy task API keeps
476
+ working on 2025-11-25 servers.
477
+
478
+ ### Authorization
479
+
480
+ `OAuthProvider` records the authorization server's `issuer` with each
481
+ authorization request and validates the callback's `iss` per RFC 9207
482
+ (`complete_authorization_flow(code, state, iss:)`); client credentials and
483
+ tokens are bound to the issuer that produced them, so a server that
484
+ switches authorization servers never sees another server's token. Dynamic
485
+ Client Registration carries `application_type` and is deprecated in favour
486
+ of Client ID Metadata Documents (`client_id_metadata_url:`). See
487
+ [OAUTH.md](OAUTH.md).
488
+
489
+ ### Deprecated features
490
+
491
+ The 2026-07-28 [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated)
492
+ lists these as Deprecated under the feature lifecycle policy: they keep
493
+ working during their deprecation window, but new integrations should not
494
+ adopt them. The earliest removal is the registry's own, and it names a
495
+ *revision*, not a date: what the features 2026-07-28 deprecates wait for is
496
+ the first revision released on or after 2027-07-28, which may itself land
497
+ well after that date — do not plan around 2027-07-28 as a removal date. The
498
+ `includeContext` values follow Sampling, and only the HTTP+SSE transport has
499
+ a clock of its own. The earliest removal is when a feature becomes
500
+ *eligible* for removal; the actual removal is a Core Maintainer decision
501
+ taken during release preparation. The client logs one notice per feature per
502
+ process on first use, naming the earliest removal and the suggested
503
+ migration:
504
+
505
+ | Feature | Deprecated since | Earliest removal | Migration |
506
+ |---------|------------------|------------------|-----------|
507
+ | Roots (`roots:`, `Client#roots=`, or answering a `roots/list` request with a non-empty list) | 2026-07-28 (SEP-2577) | the first revision released on or after 2027-07-28 | Pass directories or files through tool parameters, resource URIs or server configuration |
508
+ | Sampling (`sampling_handler:` on `Client.new` or `MCPClient.connect`, or serving a request through a transport's `on_sampling_request`) | 2026-07-28 (SEP-2577) | the first revision released on or after 2027-07-28 | Integrate directly with the LLM provider API |
509
+ | Logging (`log_level=` on the client or a server, an `io.modelcontextprotocol/logLevel` in `request_meta` or a per-call `_meta`, `notifications/message`) | 2026-07-28 (SEP-2577) | the first revision released on or after 2027-07-28 | Log to stderr (stdio) or use OpenTelemetry |
510
+ | HTTP+SSE transport (`MCPClient::ServerSSE`, warned once it is connected) | 2025-03-26 (reclassified by SEP-2596) | three months after SEP-2596 reaches Final | Migrate the server to Streamable HTTP |
511
+ | `includeContext` `"thisServer"` / `"allServers"` in sampling requests | 2025-11-25 (reclassified by SEP-2596) | follows Sampling (SEP-2577) | Servers omit the field or send `"none"` |
512
+ | OAuth Dynamic Client Registration | 2026-07-28 (PR #2858) | the first revision released on or after 2027-07-28 | Client ID Metadata Documents or pre-registered credentials |
513
+
514
+ A client that never adopted Roots is never warned about them. It registers a
515
+ `roots/list` handler on every server unconditionally, so that a later
516
+ `client.roots = [...]` is served without reconnecting, and until a root is set
517
+ it answers `roots/list` with an empty list — an empty answer exposes nothing
518
+ deprecated, so it raises no notice. The notice fires when the host configures
519
+ `roots:`, calls `Client#roots=`, or serves an answer that actually carries a
520
+ root (including from a transport driven directly, without a `Client`).
521
+
522
+ `MCPClient::Deprecations::REGISTRY` lists them, each with its
523
+ `earliest_removal`; set
524
+ `MCPClient::Deprecations.enabled = false` (before constructing clients) to
525
+ silence the notices. Notices go to the logger the client or server was
526
+ given; without one they go to the default `$stdout` logger like every other
527
+ warning. A notice costs the deprecated operation exactly what one
528
+ `logger.warn` costs it, and no more: a logger that raises, drops the notice
529
+ or writes nowhere (`Logger.new(nil)`, or one whose device has been closed) is
530
+ swallowed, so the feature keeps working and the notice stays owed to a later
531
+ use — but a logger that blocks, blocks its caller here just as it does
532
+ everywhere else in the library. One failure is beyond reach: a standard
533
+ `Logger` over an open device that fails to write hides that from its caller
534
+ (it reports it on `$stderr` only and returns as if it had written), so that
535
+ notice is spent; a closed device is recognised, a broken one is not. A logger the host wrapped is asked through
536
+ the wrapper (`warn?`, and the device of the `Delegator` it holds), so a
537
+ tagged, broadcast or hand-rolled wrapper above WARN leaves the notice owed
538
+ rather than spending it on a line nobody reads; a wrapper that filters on
539
+ something a level cannot express — a tag, a source allow-list — cannot be
540
+ asked, and does spend it.
541
+ Nothing ever waits for a notice. A caller that meets one already in flight —
542
+ another thread's first use, or a formatter, log subscriber, audit hook or
543
+ `level` accessor that reaches a deprecated feature from inside the notice
544
+ being written — stands down at once instead of queueing behind it. Queueing would be worth a
545
+ deadlock, since the thread writing a notice holds the logger and the thread
546
+ that would queue may be holding a lock that logger needs (an ordinary
547
+ `logger.info` holds exactly such a lock while its device runs). And it would
548
+ buy nothing: whoever holds the notice is writing it, and if their logger
549
+ fails or filters the warning the notice is owed again, so the feature's next
550
+ use raises it.
551
+
133
552
  ## MCP 2025-11-25 Features
134
553
 
135
554
  ### Tool Annotations
@@ -165,25 +584,115 @@ data = result['structuredContent'] # Type-safe structured data
165
584
  # Per MCP 2025-11-25, clients SHOULD validate structured results against the
166
585
  # tool's output schema, and a tool that declares an outputSchema must return
167
586
  # 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:
587
+ # (so does call_tool_streaming, for a chunk that is a complete result, and so
588
+ # does a task's result), against the JSON Schema vocabulary this client
589
+ # evaluates: type, enum/const, properties/required, patternProperties,
590
+ # additionalProperties, propertyNames, minProperties/maxProperties,
591
+ # dependentRequired/dependentSchemas (draft-07 dependencies),
592
+ # items/prefixItems/additionalItems, minItems/maxItems, uniqueItems (JSON
593
+ # equality, so an object is never equal to an array), contains with
594
+ # minContains/maxContains, string bounds and pattern (an ECMA-262 regular
595
+ # expression, translated before it is matched), numeric bounds and multipleOf,
596
+ # allOf/anyOf/oneOf/not, if/then/else, $ref/$defs/definitions inside the
597
+ # document, and unevaluatedItems/unevaluatedProperties from the annotations
598
+ # the whole composition produces (what properties/patternProperties/
599
+ # additionalProperties, prefixItems/items/contains and the two keywords
600
+ # themselves evaluated, collected through every $ref/allOf/anyOf/oneOf/
601
+ # if-then-else/dependentSchemas that passed — never from a cousin). A
602
+ # $dynamicRef or $recursiveRef that names no dynamic anchor is the plain
603
+ # reference it resolves to and is applied as one; one that does binds to the
604
+ # outermost resource of the DYNAMIC SCOPE declaring that anchor — the
605
+ # resources the evaluation actually entered, tracked as it enters them, so a
606
+ # duplicate anchor in a resource the instance never enters decides nothing.
607
+ # What is NOT evaluated is the two keywords that only annotate
608
+ # (format, contentSchema). When a schema uses one of those, call_tool logs a
609
+ # "validation is partial" warning naming them (in both modes), since data may
610
+ # pass this check that a full validator would reject; an unevaluated keyword
611
+ # is never read as a match for not/oneOf/if either. A pattern is bounded to
612
+ # 10,000 characters and must be an ECMA-262 expression (Ruby-only syntax such
613
+ # as inline flags or possessive quantifiers makes the schema unusable). Two
614
+ # ECMA-262 constructs Ruby's engine cannot reproduce — a back-reference to a
615
+ # group a quantifier repeats (ECMA-262 clears it at each iteration, Ruby
616
+ # keeps it) and a variable-length lookbehind — make the schema unusable too,
617
+ # rather than being answered under the other engine's rules. By default a
618
+ # violation (mismatch, or missing structuredContent on a successful result)
619
+ # logs a warning; opt in to strict mode to raise instead:
178
620
  client = MCPClient::Client.new(
179
621
  mcp_server_configs: [...],
180
622
  validate_structured_content: :strict # raises MCPClient::Errors::ValidationError on violation
181
623
  )
182
- # Task-delivered results (get_task_result) are not validated yet.
624
+ # A task-delivered result (get_task_result) is validated the same way when the
625
+ # task is named with a Task handle: the handle call_tool_as_task returns carries
626
+ # the definition its creating call went out under, and every handle of that task
627
+ # keeps it — the one a get_task refresh returns, and the one a wait_for_task
628
+ # hands back. A bare task ID identifies no tool, so a result fetched by ID is
629
+ # not validated.
183
630
  ```
184
631
 
632
+ A result is checked against the tool definition the request that produced it
633
+ went out under. That matters on a modern HTTP session, where a `HeaderMismatch`
634
+ rejection makes the client refresh `tools/list` and retry with recomputed
635
+ `Mcp-Param-*` headers: the retry is answered under the refreshed definition, so
636
+ that is the one it is validated against. A `tools/list_changed` that merely
637
+ arrives while the call is in flight never changes the definition the result is
638
+ checked against — the server never saw the replacement.
639
+
640
+ What counts as structured content follows the revision the session was
641
+ negotiated to. MCP 2026-07-28 widened `structuredContent` to any JSON value,
642
+ so a present `null`, array, string, number or boolean is structured content
643
+ and is validated against the output schema. MCP 2025-11-25 types it as an
644
+ object: on a session negotiated to that revision anything else is no
645
+ structured content at all, and a tool that declares an `outputSchema` and
646
+ sends one is reported as having returned none. A transport that cannot say
647
+ which revision it negotiated is not assumed legacy.
648
+
649
+ #### JSON Schema dialects and references
650
+
651
+ The built-in validator reads JSON Schema 2020-12 (the MCP default), 2019-09
652
+ and draft-07. Per MCP 2026-07-28 an unsupported dialect must be reported as
653
+ an error, so a tool whose `inputSchema` declares one this client does not
654
+ implement is refused before the request is sent:
655
+
656
+ ```ruby
657
+ # inputSchema: {"$schema": "urn:unknown-dialect", ...}
658
+ client.call_tool('t', {})
659
+ # => raises MCPClient::Errors::ValidationError:
660
+ # "...input schema declares the JSON Schema dialect \"urn:unknown-dialect\":
661
+ # that dialect is not supported (supported: ...)"
662
+ ```
663
+
664
+ The same applies to an `outputSchema`: an unsupported dialect there raises a
665
+ `ValidationError` in both modes, since the client cannot read the schema at
666
+ all — unlike a structured-content mismatch, which the
667
+ `validate_structured_content` mode decides. The definition checked is the one
668
+ the answered request actually went out under, so a `HeaderMismatch` retry
669
+ under a refreshed schema is covered too — and since the rejection means the
670
+ server did not execute the attempt, a refreshed `inputSchema` whose dialect
671
+ this client cannot read stops the retry before it is sent rather than after
672
+ the tool has run. A schema that is merely unusable for another reason (a
673
+ `$ref` that would need a network fetch, a document past the resource bounds,
674
+ a malformed keyword value — the metadata keywords included, which must be
675
+ written as the type JSON Schema gives them) is warned about, and for an input
676
+ schema the call still goes out, since the server owns argument validation.
677
+
678
+ Two schema resources may not answer to one URI: a document whose `$id`s (or
679
+ whose `$anchor` names within one resource) collide is unusable, since which
680
+ declaration a reference lands on would otherwise depend on the order the
681
+ document was read in.
682
+
683
+ References are resolved inside the document only — nothing is ever fetched —
684
+ but a document that bundles the resources it uses is resolved in full: a
685
+ `$ref` naming an embedded `$id` (`"urn:example:s"`, a relative URI against the
686
+ base an enclosing `$id` established, or the empty reference `""`) resolves to
687
+ that resource, and only a reference to a resource the document does not carry
688
+ is reported as external. Under 2020-12 and 2019-09 `definitions` behaves as
689
+ the `$defs` it was renamed from, as the meta-schema of both dialects retains
690
+ it.
691
+
185
692
  ### Roots
186
693
 
694
+ > Deprecated in MCP 2026-07-28 (SEP-2577); see [Deprecated features](#deprecated-features).
695
+
187
696
  ```ruby
188
697
  # Set filesystem scope boundaries
189
698
  client.roots = [
@@ -197,6 +706,8 @@ client.roots
197
706
 
198
707
  ### Sampling (Server-requested LLM completions)
199
708
 
709
+ > Deprecated in MCP 2026-07-28 (SEP-2577); see [Deprecated features](#deprecated-features).
710
+
200
711
  ```ruby
201
712
  # Configure handler when creating client
202
713
  client = MCPClient.connect('http://server/mcp',
@@ -212,11 +723,48 @@ client = MCPClient.connect('http://server/mcp',
212
723
  )
213
724
  ```
214
725
 
726
+ Sampling histories are checked before the handler runs (MCP 2026-07-28
727
+ client/sampling: both parties SHOULD validate message content): every message
728
+ needs a `"user"`/`"assistant"` role and content, a user message holding tool
729
+ results holds nothing else, and each assistant tool use is answered by the user
730
+ message that follows it. A malformed history is refused with `-32602` (or fails
731
+ the multi round-trip locally on a 2026-07-28 server) and the handler is not
732
+ invoked.
733
+
734
+ A multi round-trip request may wait on an interaction that happens out of band
735
+ (a URL-mode elicitation): the server keeps answering with only a
736
+ `requestState`, and the client retries with a growing pause. The host steers
737
+ that wait, and can resume a request it stopped:
738
+
739
+ ```ruby
740
+ client.on_input_required_wait do |wait|
741
+ # wait.rpc_method, wait.round_trip, wait.delay, wait.request_state,
742
+ # wait.result, wait.elapsed
743
+ :cancel if user_pressed_cancel? # :retry retries now; anything else waits the pace
744
+ end
745
+
746
+ begin
747
+ client.call_tool('checkout', { 'cart' => cart })
748
+ rescue MCPClient::Errors::InputRequiredError => e
749
+ # e.resumable? — the continuation (e.request_method, e.request_params,
750
+ # e.request_state) is carried by every error the round trip raises
751
+ client.resume_input_required(e) if e.resumable? && user_pressed_retry?
752
+ end
753
+ ```
754
+
755
+ A wait never runs past the timeout the request runs under either — the one
756
+ given to the call, or the transport's configured `read_timeout` when the call
757
+ named none — and the time your own control spends deciding counts against it.
758
+ The error it raises then is resumable in the same way.
759
+
215
760
  Sampling tool calling (SEP-1577) is opt-in: pass `sampling_supports_tools: true`
216
761
  to declare the `sampling.tools` capability. The handler then receives the full
217
762
  request params (including `tools`/`toolChoice`) as an optional fifth argument;
218
763
  without the opt-in, tool-enabled sampling requests are rejected with `-32602`
219
- as the spec requires:
764
+ as the spec requires. On a 2026-07-28 server, where sampling arrives as an
765
+ input request inside a multi round-trip answer and `inputResponses` has no
766
+ per-request error channel, the same rejection fails the whole round trip with
767
+ `MCPClient::Errors::InputRequiredError` and the handler is never invoked:
220
768
 
221
769
  ```ruby
222
770
  client = MCPClient::Client.new(
@@ -285,7 +833,11 @@ that negotiated the corresponding capability; otherwise
285
833
  capabilities that were not negotiated). `Client#log_level=` skips
286
834
  non-logging servers instead of failing. Declared *client* capabilities are
287
835
  derived from what the host actually registered (handlers, roots), never
288
- hardcoded.
836
+ hardcoded — and they gate the client's own traffic too:
837
+ `notifications/roots/list_changed` goes only to a session that declared
838
+ `roots` (never to a 2026-07-28 server, which removed the notification, and
839
+ never to plain HTTP on a legacy session, which has no server-request channel
840
+ to serve roots on).
289
841
 
290
842
  ### Completion (Autocomplete)
291
843
 
@@ -299,6 +851,8 @@ result = client.complete(
299
851
 
300
852
  ### Logging
301
853
 
854
+ > Deprecated in MCP 2026-07-28 (SEP-2577); see [Deprecated features](#deprecated-features).
855
+
302
856
  ```ruby
303
857
  # Set log level
304
858
  client.log_level = 'debug' # debug/info/notice/warning/error/critical
@@ -320,28 +874,43 @@ Try it locally: `python3 examples/echo_server_streamable.py &` then
320
874
  `./examples/tasks_example.rb` runs the full lifecycle against a task-capable
321
875
  demo server.
322
876
 
877
+ On MCP 2026-07-28 the client must declare the extension for the server to be
878
+ allowed to answer with a task at all:
879
+
323
880
  ```ruby
881
+ client = MCPClient::Client.new(mcp_server_configs: [...],
882
+ extensions: ['io.modelcontextprotocol/tasks'])
883
+
324
884
  tool = client.find_tool('long_job')
325
885
  tool.supports_task? # execution.taskSupport is optional/required?
326
886
 
327
- # Create the task (returns immediately); ttl is the requested lifetime in ms
328
- task = client.call_tool_as_task('long_job', { input: 'data' }, ttl: 60_000)
887
+ # Create the task (returns immediately). The server sets the lifetime it
888
+ # grants (ttlMs) and the pace it wants to be polled at (pollIntervalMs).
889
+ task = client.call_tool_as_task('long_job', { input: 'data' })
329
890
 
330
- # Poll until the task reaches a terminal (or input-required) status,
331
- # honoring the server's suggested poll interval
891
+ # Drive the whole lifecycle: polls tasks/get at the server's pace, answers
892
+ # any inputRequests through your elicitation/sampling handlers with
893
+ # tasks/update, and returns the finished task.
894
+ finished = client.wait_for_task(task)
895
+ result = finished.result # the CallToolResult the task produced
896
+
897
+ # Or step it yourself
332
898
  until task.terminal? || task.input_required?
333
899
  sleep((task.poll_interval || 1000) / 1000.0)
334
900
  task = client.get_task(task) # tasks/get, routed to the task's own server
335
901
  end
902
+ result = client.get_task_result(task) # read from tasks/get on 2026-07-28
336
903
 
337
- # Retrieve the underlying result (e.g. a CallToolResult) via tasks/result
338
- result = client.get_task_result(task)
339
-
340
- # List and cancel tasks
341
- page = client.list_tasks # { tasks: [...], next_cursor: ... }
342
- client.cancel_task(task) # tasks/cancel
904
+ client.cancel_task(task) # tasks/cancel
343
905
  ```
344
906
 
907
+ On a 2026-07-28 server `tasks/result` and `tasks/list` are gone: the outcome
908
+ is read from `tasks/get`, `client.list_tasks` raises, and a `ttl:` passed to
909
+ `call_tool_as_task` is ignored (the server grants `ttlMs`). Against a
910
+ 2025-11-25 server the earlier surface still applies — `ttl:` is sent,
911
+ `get_task_result` uses `tasks/result`, and `client.list_tasks` pages
912
+ `tasks/list`.
913
+
345
914
  Task IDs are only unique within the server that issued them, so pass the `Task`
346
915
  returned by `call_tool_as_task` — it carries its own server. A bare task ID also
347
916
  works when the client has a single server; with several servers configured it
@@ -356,6 +925,22 @@ client.on_notification do |server, method, params|
356
925
  end
357
926
  ```
358
927
 
928
+ Notifications announce; they do not drive. A `notifications/tasks` (2026-07-28,
929
+ on a `listen` stream) that carries `inputRequests` reaches the listeners exactly
930
+ as it arrived — the client answers input requests only inside `wait_for_task`
931
+ (and `call_tool`), or when the host sends the answers itself with
932
+ `update_task`. A host that follows a task through notifications hands it to
933
+ `wait_for_task` when it wants the requests answered.
934
+
935
+ Handles outlive a process only as far as the host keeps them: `task.to_h`
936
+ serializes a handle, and `MCPClient::Task.from_json(hash, server: server)` (or
937
+ the bare `taskId` with `server:`) names the same task in another client, which
938
+ can `get_task`, `wait_for_task` or `cancel_task` it. Every handle a creation, a
939
+ `get_task` refresh, a `wait_for_task` or a cancellation hands back within one
940
+ client also names the *lifetime* of its task: once the server has handed the
941
+ same id to a new task, that handle raises `TaskReplacedError` instead of
942
+ reaching the replacement.
943
+
359
944
  ### Elicitation (Server-initiated user interactions)
360
945
 
361
946
  ```ruby
@@ -369,6 +954,22 @@ client = MCPClient::Client.new(
369
954
  )
370
955
  ```
371
956
 
957
+ In form mode the handler may return the content on its own (`{ 'field' =>
958
+ 'value' }`), which is sent as an `accept`. An explicit `action` must be one of
959
+ `accept`, `decline` or `cancel` — any other value is answered `cancel`, since
960
+ it is not consent the user gave.
961
+
962
+ In URL mode the handler's second argument is `{ 'mode' => 'url', 'url' => ... }`
963
+ on a **2026-07-28** server and
964
+ `{ 'mode' => 'url', 'url' => ..., 'elicitationId' => ... }` on a **2025-11-25**
965
+ one: the newer revision removed `elicitationId`, so a host must not expect that
966
+ key from a modern server (see
967
+ [URL-mode elicitation](#url-mode-elicitation)). Its answer is
968
+ consent, not data: only an explicit `action` of `accept`, `decline` or `cancel`
969
+ (or a literal `true` for accept) counts — anything else is answered `cancel`.
970
+ `content` is dropped, since it is form-mode only, while a handler-supplied
971
+ `_meta` is passed through.
972
+
372
973
  ## Advanced Configuration
373
974
 
374
975
  For more control, use `create_client` with explicit configs:
@@ -377,6 +978,9 @@ For more control, use `create_client` with explicit configs:
377
978
  client = MCPClient.create_client(
378
979
  mcp_server_configs: [
379
980
  MCPClient.stdio_config(command: 'npx server', name: 'local'),
981
+ # HTTP+SSE is deprecated (SEP-2596): keep this only for an existing SSE
982
+ # server, and prefer Streamable HTTP (streamable_http_config below) for
983
+ # anything new. See "Deprecated features".
380
984
  MCPClient.sse_config(
381
985
  base_url: 'https://api.example.com/sse',
382
986
  headers: { 'Authorization' => 'Bearer TOKEN' },
@@ -430,6 +1034,24 @@ an expired-session 404, the client starts a fresh session but does **not** re-se
430
1034
  the call — it raises so you can decide. Idempotent requests are re-sent against
431
1035
  the new session as before.
432
1036
 
1037
+ **One exception: a broken response stream on a modern (MCP 2026-07-28)
1038
+ Streamable HTTP connection.** That revision removed SSE resumability and requires
1039
+ that a broken response stream loses the in-flight request, which clients **MUST**
1040
+ re-issue as a new request with a new request ID — with no exception for
1041
+ `tools/call`. It can require that safely because closing the response stream *is*
1042
+ the cancellation signal: the server **MUST** treat the break as a cancellation of
1043
+ that request, stop work as soon as practical, and send nothing further for it. So
1044
+ when a modern response stream ends without the result, the client sends the call
1045
+ again once with a fresh request id; if that stream breaks too, it raises
1046
+ `MCPClient::Errors::ResponseStreamClosedError` rather than trying again. Every
1047
+ other ambiguous failure is unchanged and still never re-sent, because in none of
1048
+ those cases was the server told to stop.
1049
+
1050
+ A response that *did* arrive is not ambiguous, so it is never replaced: the
1051
+ client reads response bodies as they stream in, and a socket that dies after
1052
+ the final SSE event returns the result it already carried instead of calling
1053
+ the tool a second time.
1054
+
433
1055
  ### Response Size Limits (Streamable HTTP)
434
1056
 
435
1057
  A gzip-encoded response is decompressed incrementally and abandoned once it
@@ -457,6 +1079,13 @@ MCPClient.http_config(base_url: 'https://internal.company.com') do |faraday|
457
1079
  end
458
1080
  ```
459
1081
 
1082
+ Response middleware you add here is respected on both paths: if a
1083
+ `conn.response :json` middleware (with or without `conn.response :raise_error`)
1084
+ has already decoded the body, a successful result is read from the decoded
1085
+ object rather than parsed a second time, and the JSON-RPC error an HTTP 4xx
1086
+ carries is still recognized, so a protocol rejection keeps its `code`, `data`
1087
+ and typed error class instead of degrading to a bare `ServerError`.
1088
+
460
1089
  ### Server Definition JSON
461
1090
 
462
1091
  ```json
@@ -541,7 +1170,7 @@ See `examples/` for complete implementations:
541
1170
 
542
1171
  ## Running the Examples
543
1172
 
544
- The `examples/run_all_examples.sh` harness runs every example that can run on the current machine — self-contained stdio servers, the Python/Flask/FastMCP echo and elicitation servers, `npx`-based MCP servers, and (optionally) the paid LLM integrations. It starts and tears down each server automatically and prints a `PASS`/`FAIL`/`SKIP` summary. `tasks_example.rb` is always skipped (it needs a task-capable remote server); `oauth_browser_auth.rb` is interactive and only runs when you opt in with `RUN_OAUTH=1`.
1173
+ The `examples/run_all_examples.sh` harness runs every example that can run on the current machine — self-contained stdio servers, the Python/Flask/FastMCP echo and elicitation servers, `npx`-based MCP servers, and (optionally) the paid LLM integrations. It starts and tears down each server automatically and prints a `PASS`/`FAIL`/`SKIP` summary. `tasks_example.rb` runs against the local `echo_server_streamable.py`; `oauth_browser_auth.rb` is interactive and only runs when you opt in with `RUN_OAUTH=1`.
545
1174
 
546
1175
  ### Prerequisites
547
1176
 
@@ -618,7 +1247,7 @@ client = MCPClient::Client.new(
618
1247
  )
619
1248
  ```
620
1249
 
621
- Features: PKCE, server discovery (`.well-known`), dynamic registration, token refresh.
1250
+ Features: PKCE, server discovery (`.well-known`), RFC 9207 issuer validation, Client ID Metadata Documents, dynamic registration (deprecated fallback), token refresh.
622
1251
 
623
1252
  See [OAUTH.md](OAUTH.md) for full documentation.
624
1253
 
@@ -636,6 +1265,141 @@ See [OAUTH.md](OAUTH.md) for full documentation.
636
1265
  - **PKCE** — authorization refuses to proceed when the authorization server
637
1266
  does not advertise `code_challenge_methods_supported` including `S256`.
638
1267
 
1268
+ ### Authorization server binding (2026-07-28)
1269
+
1270
+ - **Registration state is per authorization server (SEP-2352)** — credentials
1271
+ *and tokens* are stored under the MCP server URL (the record in use) *and*
1272
+ under `provider.client_registration_key(issuer)`, so two authorization
1273
+ servers behind one MCP server each keep their own registration state
1274
+ instead of replacing one another. Seed pre-registered credentials for a
1275
+ specific authorization server with
1276
+ `storage.set_client_info(provider.client_registration_key(issuer), creds)`.
1277
+ - **Pre-registered credentials must name their authorization server** — a
1278
+ `client_id` (and any secret with it) is issued by one authorization server,
1279
+ so credentials that name none are not bound to whichever server discovery
1280
+ happens to find: that would send the secret registered with one server to
1281
+ another. Store them with `issuer:` on the `ClientInfo`, or under
1282
+ `client_registration_key(issuer)`; without it, authorization raises a
1283
+ `ConnectionError` saying so and nothing is sent anywhere. A Client ID
1284
+ Metadata Document id is portable and needs no issuer.
1285
+ - **Pre-registered credentials outrank what the client registered itself** —
1286
+ when an authorization server has credentials of its own under
1287
+ `client_registration_key(issuer)`, they are used ahead of a Client ID
1288
+ Metadata Document id (which answers for every server) and ahead of a
1289
+ dynamic registration in the slot, as the MCP registration priority order
1290
+ requires — and a dynamic registration never overwrites them.
1291
+ - **A token is kept for each authorization server** — when the authorization
1292
+ server changes, the token of the previous one is set aside under its own
1293
+ key rather than thrown away, and picked up again if that server becomes the
1294
+ one in use. A token this client retired (a 401 challenge naming another
1295
+ authorization server) is deleted wherever it is kept, and no token is ever
1296
+ presented to an authorization server other than the one that issued it.
1297
+ - **A registration a flow needs must reach storage** — a backend that cannot
1298
+ persist the credentials in use raises instead of returning an authorization
1299
+ URL whose callback would then report "Missing PKCE or client info". The
1300
+ per-authorization-server copy stays best-effort.
1301
+ - **A refresh presents the credentials in the slot a host writes to** — a
1302
+ secret rotated under the MCP server URL is used, not the older copy kept
1303
+ under the authorization server's own key. Authorization and refresh pick the
1304
+ same record.
1305
+ - **A refresh and a code exchange are both re-checked when the response
1306
+ arrives** — a token from an authorization server that stopped being this
1307
+ resource's while the request was in flight is discarded rather than stored
1308
+ over the current server's token or presented, and a code exchange that
1309
+ arrives late no longer deletes the pending authorization request another
1310
+ flow started meanwhile.
1311
+ - **One per-request record** — the `state`, the PKCE verifier, the expected
1312
+ issuer, the client id and the redirect URI of an authorization request are
1313
+ written together, and the callback's `state` is checked against the record
1314
+ the other checks read. Two flows sharing one storage backend can no longer
1315
+ interleave their writes until one flow's state names the other flow's
1316
+ request.
1317
+ - **Scopes accumulate across step-ups** — re-authorizing after an
1318
+ `insufficient_scope` challenge asks for the union of the scopes already
1319
+ requested and the ones the challenge names, so getting `files:write` does
1320
+ not give up `files:read`. "Already requested" survives a restart: it covers
1321
+ the configured scope and the scope the authorization server granted the
1322
+ token in hand, not only what this provider object last asked for. It is
1323
+ scoped to one authorization server, so another server's scopes are never
1324
+ asked of the new one.
1325
+ - **An authorization request parameter appears once** — the authorization
1326
+ endpoint's own query string is retained (RFC 6749 §3.1), but an endpoint of
1327
+ `https://as.example/authorize?scope=openid` does not add a second `scope`
1328
+ to the request; the same for `state`, `client_id` and the PKCE parameters.
1329
+ Everything else the endpoint carries (`tenant`, `brand`, a locale) is kept.
1330
+ - **Only a token type the client understands is presented** — `token_type` is
1331
+ REQUIRED (RFC 6749 §5.1) and must be `Bearer` (§7.1). A `DPoP` or `mac`
1332
+ token is refused where it is issued and where it is read back, and so is a
1333
+ response that names no type at all: §5.1 defines no default, and §7.1
1334
+ forbids using a token whose type the client does not understand.
1335
+ - **Every redirect URI is `localhost` or HTTPS** — MCP 2026-07-28
1336
+ "Communication Security". `http://app.example.com/callback` is refused when
1337
+ it is configured and when a registration response registers it; plain HTTP
1338
+ on the loopback interface and RFC 8252 private-use schemes
1339
+ (`com.example.app:/cb`) are unaffected.
1340
+ - **A callback parameter may appear once** — RFC 6749 §3.1: `BrowserOAuth`
1341
+ refuses a callback that repeats `iss`, `state`, `code` or any other
1342
+ parameter instead of silently taking the last value.
1343
+
1344
+ ## Cacheable Results (MCP 2026-07-28)
1345
+
1346
+ A 2026-07-28 server may bound a result with `ttlMs` (how long the client MAY
1347
+ consider it fresh, counted from receipt) and `cacheScope` (`"public"` or
1348
+ `"private"`). The transports honour both for `tools/list`, `prompts/list`,
1349
+ `resources/list`, `resources/templates/list`, `server/discover` and
1350
+ `resources/read`; `cache_info(:tools)` — or `cache_info(:read, uri)` — reports
1351
+ what was recorded. An absent `ttlMs` means 0 on a 2026-07-28 server, a missing
1352
+ or unrecognized `cacheScope` is treated as `"private"`, and a `resources/read`
1353
+ result is kept only when the server gave it a positive `ttlMs`.
1354
+
1355
+ A cached result is served only to a request that would carry the same things
1356
+ the request that produced it did:
1357
+
1358
+ - **the same authorization.** A `"private"` entry is bound to the
1359
+ `Authorization` its own request actually went out with — what the adapter
1360
+ sent, not what the response phase later left in the request environment — so
1361
+ a rotated token, a revoked one, or an anonymous request never reads it.
1362
+ - **the same effective parameters.** The `_meta` a request carries (your
1363
+ `request_meta` and its `baggage`, the client identity and capabilities) is
1364
+ part of what a result is bound to, whatever its scope: `"public"` permits
1365
+ sharing across callers, not across parameters a server may vary its answer
1366
+ by. A request carries a copy of that metadata, taken when it is built, so
1367
+ rewriting a string or a container you handed `request_meta` in place does
1368
+ not change what a request already sent means — set `request_meta` to the new
1369
+ value instead, and the next request carries it.
1370
+
1371
+ Anything the transport cannot read off its own configuration makes those
1372
+ unknowable, and reuse is then turned off rather than guessed at. Middleware of
1373
+ your own installed through `faraday_config` is such a case — anything with a
1374
+ request hook, and any handler carrying a callback of yours: it may set an
1375
+ `Authorization` or rewrite the request body, and no inspection can tell whether
1376
+ it does. Framework middleware the transport can read (Faraday's own retry,
1377
+ JSON, url-encoded, multipart, logger, follow-redirects, and `:authorization`
1378
+ configured with literal values) keeps caching on; an `:authorization` handed a
1379
+ proc keeps public entries but not private ones, since the credential it vends
1380
+ may differ from request to request.
1381
+
1382
+ Two rules follow the protocol rather than the cache:
1383
+
1384
+ - a `resources/templates/list` a server put **no** hint on is fetched again on
1385
+ every call, as it was before results were cached at all; only a positive
1386
+ `ttlMs` lets a template list be answered without a request;
1387
+ - a cursor the server rejects (`-32602`) ends the page sequence it belonged to.
1388
+ The pages cached for that list are dropped, an automatically paginated list
1389
+ restarts once from the first page, and an explicit `list_resources(cursor:)`
1390
+ or `list_resource_templates(cursor:)` raises — after the first page cached
1391
+ under the dead cursor has been discarded, so the next call really re-fetches.
1392
+
1393
+ A re-fetch of a list that fails for a transient reason may serve the stale copy
1394
+ on the HTTP transports ("Clients MAY serve stale responses if errors occur
1395
+ during re-fetching"); the SSE and stdio transports raise instead, and an
1396
+ authorization failure never serves a stale copy, so it reaches your auth flow.
1397
+
1398
+ A `server/discover` result is judged by the one rule every cached result
1399
+ uses: once its `ttlMs` has elapsed (an absent, zero, negative or malformed
1400
+ hint is stale at once) it is re-fetched on the next access that needs it,
1401
+ before a capability is judged, on every transport.
1402
+
639
1403
  ## Server Notifications
640
1404
 
641
1405
  ```ruby
@@ -653,7 +1417,8 @@ end
653
1417
 
654
1418
  ## Session Management
655
1419
 
656
- Both HTTP and Streamable HTTP transports automatically handle session-based servers:
1420
+ On a legacy session (a server speaking MCP 2025-11-25 or earlier), both HTTP
1421
+ and Streamable HTTP transports automatically handle session-based servers:
657
1422
 
658
1423
  - **Session capture**: Extracts `Mcp-Session-Id` from initialize response
659
1424
  - **Session persistence**: Includes session header in subsequent requests
@@ -664,6 +1429,12 @@ Both HTTP and Streamable HTTP transports automatically handle session-based serv
664
1429
 
665
1430
  No configuration required - works automatically.
666
1431
 
1432
+ A modern session (MCP 2026-07-28) has none of this: no session id, no DELETE,
1433
+ no GET stream and no resumption. A response stream that breaks loses the
1434
+ in-flight request, and the client re-issues it once as a new request — see
1435
+ [Treating the Server as Untrusted](#treating-the-server-as-untrusted) for what
1436
+ that means for `tools/call`.
1437
+
667
1438
  ## Server Compatibility
668
1439
 
669
1440
  Works with any MCP-compatible server:
@@ -700,11 +1471,13 @@ default behaviour — but it is worth knowing what the client will refuse:
700
1471
  | `retry:` directives | Honored, but floored so `retry: 0` cannot drive a reconnect loop |
701
1472
  | SSE event IDs | Bounded length, printable ASCII only (they are echoed in `Last-Event-ID`) |
702
1473
  | 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 |
1474
+ | OAuth discovery URLs from a peer | Must be HTTPS, and rejected when the host is a *literal* loopback/private/link-local address. The only exception is a local stack: a configured server URL on the loopback interface (`localhost`, `*.localhost`, 127.0.0.0/8, `::1`) may be sent to a plain-HTTP *loopback* URL — never to a link-local or otherwise private one. 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
1475
  | Unsolicited JSON-RPC responses | Discarded — only IDs with an outstanding request are accepted |
705
1476
  | Server-initiated requests | Replies are bounded by a concurrency budget rather than spawning unbounded threads |
706
1477
  | Schema `pattern` values | Matched under a whole-operation time budget; a timeout fails validation rather than silently passing |
707
1478
  | Log messages (`notifications/message`) | Control characters escaped and length-capped, so a server cannot forge log lines |
1479
+ | Broken response streams (MCP 2026-07-28) | The lost request is re-issued **once** as a new request, `tools/call` included: the revision says clients MUST re-issue and makes the closed stream the server's cancellation signal. A server that had already finished the tool before the stream broke runs it a second time. A `-32020` HeaderMismatch rejection is likewise retried once, after a `tools/list` refresh. Legacy sessions keep the 2.1.0 rule: non-idempotent requests are never re-sent |
1480
+ | `cacheScope: "private"` results | Bound to the `Authorization` header the request went out with (SHA-256 of its bytes) and never served under another. A credential carried elsewhere — a cookie, an `X-Api-Key` header, client TLS — is not part of that context |
708
1481
 
709
1482
  **Known limit:** the OAuth check is textual. A peer can still advertise a public
710
1483
  hostname whose DNS record points inside your network; catching that needs
@@ -719,16 +1492,20 @@ than the peer's:
719
1492
  chunks. Server configurations are logged with credential-bearing keys redacted.
720
1493
  - **Host exceptions are not reflected to the server.** A raising elicitation,
721
1494
  sampling or roots handler yields a constant JSON-RPC error message; the detail
722
- stays in your local log.
1495
+ stays in your local log. When a server asks for several inputs at once (MCP
1496
+ 2026-07-28 multi round-trip requests) and one of them fails, the answers your
1497
+ handler already produced are kept rather than thrown away: a task's poll loop
1498
+ sends them with its next `tasks/update` and never puts an answered request to
1499
+ your handler a second time.
723
1500
 
724
1501
  ## Requirements
725
1502
 
726
- - Ruby >= 3.2.0
1503
+ - Ruby >= 3.3.0
727
1504
  - Runtime dependencies: `faraday` (~> 2.0) with `faraday-follow_redirects` and
728
1505
  `faraday-retry`, plus `base64` — all pulled in automatically by the gem
729
1506
 
730
1507
  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.
1508
+ plus the supported floor, 3.3.
732
1509
 
733
1510
  ## License
734
1511