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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
40
|
-
- **Roots
|
|
41
|
-
- **Sampling
|
|
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
|
|
44
|
-
- **Tasks**: Task-augmented `tools/call` —
|
|
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,
|
|
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/
|
|
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
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
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
|
-
#
|
|
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)
|
|
328
|
-
|
|
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
|
-
#
|
|
331
|
-
#
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
1508
|
+
plus the supported floor, 3.3.
|
|
732
1509
|
|
|
733
1510
|
## License
|
|
734
1511
|
|