mcp 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +50 -2725
- data/lib/json_rpc_handler.rb +3 -2
- data/lib/mcp/client/http.rb +338 -63
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/bounded_body.rb +67 -0
- data/lib/mcp/client/oauth/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +139 -17
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/stdio.rb +243 -66
- data/lib/mcp/client.rb +363 -41
- data/lib/mcp/configuration.rb +72 -10
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/instrumentation.rb +6 -0
- data/lib/mcp/methods.rb +21 -0
- data/lib/mcp/prompt.rb +8 -1
- data/lib/mcp/protocol_deprecations.rb +61 -0
- data/lib/mcp/request_envelope.rb +39 -17
- data/lib/mcp/server/input_required_result.rb +163 -0
- data/lib/mcp/server/pending_response.rb +62 -0
- data/lib/mcp/server/request_state_security.rb +131 -0
- data/lib/mcp/server/transports/stdio_transport.rb +39 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +745 -24
- data/lib/mcp/server.rb +574 -48
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +104 -20
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +11 -2
data/lib/mcp/client.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "client/elicitation"
|
|
4
|
+
require_relative "client/mcp_param_headers"
|
|
5
|
+
require_relative "client/modern_envelope"
|
|
4
6
|
require_relative "client/oauth"
|
|
5
7
|
require_relative "client/stdio"
|
|
6
8
|
require_relative "client/http"
|
|
@@ -10,6 +12,12 @@ require_relative "result_type"
|
|
|
10
12
|
|
|
11
13
|
module MCP
|
|
12
14
|
class Client
|
|
15
|
+
# Upper bound on the number of pages the all-pages methods (`tools`, `resources`,
|
|
16
|
+
# `resource_templates`, `prompts`) will walk. The cursor guard in `fetch_all_pages` only
|
|
17
|
+
# stops a server that repeats or cycles cursors; one that returns a fresh `nextCursor` on
|
|
18
|
+
# every response would otherwise be followed indefinitely, growing the retained pages with it.
|
|
19
|
+
MAX_PAGES = 1_000
|
|
20
|
+
|
|
13
21
|
class ServerError < StandardError
|
|
14
22
|
attr_reader :code, :data
|
|
15
23
|
|
|
@@ -50,13 +58,19 @@ module MCP
|
|
|
50
58
|
# server-returned JSON-RPC error, which is raised as `ServerError`.
|
|
51
59
|
class ValidationError < StandardError; end
|
|
52
60
|
|
|
61
|
+
# Raised when an all-pages method reaches `max_pages` while the server is still offering
|
|
62
|
+
# another cursor. Use the single-page `list_*` methods to walk such a collection with
|
|
63
|
+
# a policy of your own.
|
|
64
|
+
class PaginationLimitError < StandardError; end
|
|
65
|
+
|
|
53
66
|
# Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of
|
|
54
67
|
# a final result. The result is not an error on the wire: it asks the client to fulfill the server's
|
|
55
68
|
# `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
|
|
56
69
|
# `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue
|
|
57
70
|
# the original request with `inputResponses` plus the echoed opaque `requestState`.
|
|
58
|
-
#
|
|
59
|
-
#
|
|
71
|
+
# With handlers registered through `on_elicitation`, `on_sampling`, or `on_roots`, the resume loop runs
|
|
72
|
+
# automatically; this error surfaces when no matching handler exists (manual driving via
|
|
73
|
+
# `input_requests` and the `input_responses:`/`request_state:` kwargs), or when the round cap is exhausted.
|
|
60
74
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
|
61
75
|
class InputRequiredError < StandardError
|
|
62
76
|
attr_reader :input_requests, :request_state, :result
|
|
@@ -79,30 +93,100 @@ module MCP
|
|
|
79
93
|
end
|
|
80
94
|
end
|
|
81
95
|
|
|
96
|
+
# The server's `DiscoverResult` (MCP 2026-07-28, SEP-2575): the modern protocol versions it serves,
|
|
97
|
+
# its capabilities and identity, optional instructions, and the REQUIRED `ttlMs`/`cacheScope` cache hints.
|
|
98
|
+
DiscoverResult = Struct.new(
|
|
99
|
+
:supported_versions,
|
|
100
|
+
:capabilities,
|
|
101
|
+
:server_info,
|
|
102
|
+
:instructions,
|
|
103
|
+
:ttl_ms,
|
|
104
|
+
:cache_scope,
|
|
105
|
+
keyword_init: true,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Rounds the SEP-2322 driver runs before giving up, matching the TypeScript and
|
|
109
|
+
# Python SDK defaults. Every leg counts, including `requestState`-only retries.
|
|
110
|
+
DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10
|
|
111
|
+
|
|
112
|
+
# Backoff for `requestState`-only (load shedding) legs: exponential from 50ms to
|
|
113
|
+
# a 250ms cap, matching the Python SDK (the TypeScript SDK uses a fixed 250ms).
|
|
114
|
+
STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05
|
|
115
|
+
STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25
|
|
116
|
+
|
|
82
117
|
# Initializes a new MCP::Client instance.
|
|
83
118
|
#
|
|
84
119
|
# @param transport [Object] The transport object to use for communication with the server.
|
|
85
120
|
# The transport should be a duck type that responds to `send_request`. See the README for more details.
|
|
121
|
+
# @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds.
|
|
122
|
+
# @param max_pages [Integer] Maximum number of pages the all-pages methods ({#tools}, {#resources},
|
|
123
|
+
# {#resource_templates}, {#prompts}) will walk before raising {MCP::Client::PaginationLimitError}.
|
|
124
|
+
#
|
|
125
|
+
# Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`,
|
|
126
|
+
# `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers
|
|
127
|
+
# (or when a requested kind has no handler) they raise `InputRequiredError` for manual driving,
|
|
128
|
+
# exactly as before.
|
|
86
129
|
#
|
|
87
130
|
# @example
|
|
88
131
|
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
|
|
89
132
|
# client = MCP::Client.new(transport: transport)
|
|
90
|
-
def initialize(transport:)
|
|
133
|
+
def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, max_pages: MAX_PAGES)
|
|
134
|
+
# `nil` or a non-positive value would make the pagination unbounded and silently
|
|
135
|
+
# disable the protection, so reject it up front.
|
|
136
|
+
unless max_pages.is_a?(Integer) && max_pages > 0
|
|
137
|
+
raise ArgumentError, "max_pages must be a positive Integer"
|
|
138
|
+
end
|
|
139
|
+
|
|
91
140
|
@transport = transport
|
|
141
|
+
@max_pages = max_pages
|
|
142
|
+
# Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways
|
|
143
|
+
# the server can ask for input: a real server-to-client request, and an embedded request inside
|
|
144
|
+
# a SEP-2322 `input_required` result.
|
|
145
|
+
@input_required_handlers = {}
|
|
146
|
+
@input_required_max_rounds = input_required_max_rounds
|
|
92
147
|
end
|
|
93
148
|
|
|
94
149
|
# The user may want to access additional transport-specific methods/attributes
|
|
95
150
|
# So keeping it public
|
|
96
151
|
attr_reader :transport
|
|
97
152
|
|
|
98
|
-
# The
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
153
|
+
# The raw handshake result exactly as the server returned it, so its shape depends on the connection's era (SEP-2575):
|
|
154
|
+
# after the legacy handshake it is an `InitializeResult` (`protocolVersion`, top-level `serverInfo`), after modern adoption
|
|
155
|
+
# it is a `DiscoverResult` (`supportedVersions`, `ttlMs`/`cacheScope`, `serverInfo` optionally under `_meta`).
|
|
156
|
+
# Code that must work against both eras should prefer the era-independent readers {#protocol_version}, {#server_capabilities},
|
|
157
|
+
# {#instructions}, and {#server_implementation}; this raw form remains the window to everything they do not cover
|
|
158
|
+
# (`supportedVersions`, cache hints, `_meta`, extension data). Returns `nil` before `connect`, after `close`,
|
|
159
|
+
# or when the transport does not expose a cached handshake result.
|
|
102
160
|
def server_info
|
|
103
161
|
transport.server_info if transport.respond_to?(:server_info)
|
|
104
162
|
end
|
|
105
163
|
|
|
164
|
+
# The protocol version in use on this connection, independent of its era:
|
|
165
|
+
# the version negotiated by `initialize` (legacy) or adopted via `server/discover` (modern).
|
|
166
|
+
# Returns `nil` before `connect`.
|
|
167
|
+
def protocol_version
|
|
168
|
+
return transport.protocol_version if transport.respond_to?(:protocol_version)
|
|
169
|
+
|
|
170
|
+
server_info&.dig("protocolVersion")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The server's capabilities Hash, present in both eras. Returns `nil` before `connect`.
|
|
174
|
+
def server_capabilities
|
|
175
|
+
server_info&.dig("capabilities")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The server's instructions text, present in both eras when provided.
|
|
179
|
+
def instructions
|
|
180
|
+
server_info&.dig("instructions")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The server's identity (`name`/`version`), independent of where the era puts it: top-level `serverInfo`
|
|
184
|
+
# on legacy results, the optional `_meta` `io.modelcontextprotocol/serverInfo` stamp on modern results.
|
|
185
|
+
# Returns `nil` when a modern server does not identify itself.
|
|
186
|
+
def server_implementation
|
|
187
|
+
server_info&.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || server_info&.dig("serverInfo")
|
|
188
|
+
end
|
|
189
|
+
|
|
106
190
|
# Performs the MCP `initialize` handshake by delegating to the transport
|
|
107
191
|
# (e.g. `MCP::Client::HTTP`, `MCP::Client::Stdio`). Returns the server's
|
|
108
192
|
# `InitializeResult`.
|
|
@@ -115,16 +199,62 @@ module MCP
|
|
|
115
199
|
# @param capabilities [Hash] Capabilities advertised by the client. May include
|
|
116
200
|
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
|
|
117
201
|
# e.g. `{ extensions: { "com.example/feature" => {} } }`.
|
|
118
|
-
# @
|
|
119
|
-
#
|
|
202
|
+
# @param mode [Symbol, nil] Lifecycle selection (SEP-2575). When omitted, transports whose
|
|
203
|
+
# `connect` declares `mode:` (the bundled `MCP::Client::HTTP` and `MCP::Client::Stdio`)
|
|
204
|
+
# negotiate with `:auto`: probe `server/discover` first and fall back to the legacy handshake
|
|
205
|
+
# when the server does not serve a mutually supported modern version. Transports without `mode:`
|
|
206
|
+
# keep receiving the historical legacy call shape. `:legacy` forces the `initialize` handshake
|
|
207
|
+
# exactly as before; `:modern` requires the modern lifecycle and fails without a mutual modern version.
|
|
208
|
+
# Passing an explicit `protocol_version` from a legacy generation (e.g. `"2025-11-25"`) pins
|
|
209
|
+
# the legacy handshake without a probe, so an explicitly requested version is never overridden
|
|
210
|
+
# by the default negotiation.
|
|
211
|
+
# @return [Hash, nil] The server's `InitializeResult` (legacy) or `DiscoverResult` (modern),
|
|
212
|
+
# or `nil` when the transport does not expose an explicit handshake.
|
|
213
|
+
# Prefer the era-independent readers over inspecting this Hash directly.
|
|
120
214
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
|
121
|
-
def connect(client_info: nil, protocol_version: nil, capabilities: {})
|
|
215
|
+
def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: nil)
|
|
122
216
|
return unless transport.respond_to?(:connect)
|
|
123
217
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
218
|
+
effective_mode = resolve_connect_mode(mode, protocol_version)
|
|
219
|
+
|
|
220
|
+
if effective_mode == :legacy
|
|
221
|
+
transport.connect(
|
|
222
|
+
client_info: client_info,
|
|
223
|
+
protocol_version: protocol_version,
|
|
224
|
+
capabilities: capabilities,
|
|
225
|
+
)
|
|
226
|
+
else
|
|
227
|
+
transport.connect(
|
|
228
|
+
client_info: client_info,
|
|
229
|
+
protocol_version: protocol_version,
|
|
230
|
+
capabilities: capabilities,
|
|
231
|
+
mode: effective_mode,
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery
|
|
237
|
+
# that works before (or instead of) `connect`.
|
|
238
|
+
#
|
|
239
|
+
# @param meta [Hash, nil] Additional `_meta` entries to send with the request.
|
|
240
|
+
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
241
|
+
# @return [MCP::Client::DiscoverResult]
|
|
242
|
+
# @raise [ServerError] If the server returns a JSON-RPC error.
|
|
243
|
+
# @raise [ValidationError] If the response `result` is missing or not a Hash.
|
|
244
|
+
def discover(meta: nil, cancellation: nil)
|
|
245
|
+
response = request(method: Methods::SERVER_DISCOVER, meta: meta, cancellation: cancellation)
|
|
246
|
+
result = response.is_a?(Hash) ? response["result"] : nil
|
|
247
|
+
raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
|
|
248
|
+
|
|
249
|
+
DiscoverResult.new(
|
|
250
|
+
supported_versions: result["supportedVersions"],
|
|
251
|
+
capabilities: result["capabilities"],
|
|
252
|
+
# The finalized spec (PR #3002) stamps the server identity into the result `_meta`;
|
|
253
|
+
# the top-level fallback tolerates servers built against the frozen SEP text.
|
|
254
|
+
server_info: result.dig("_meta", RequestEnvelope::SERVER_INFO_META_KEY) || result["serverInfo"],
|
|
255
|
+
instructions: result["instructions"],
|
|
256
|
+
ttl_ms: result["ttlMs"],
|
|
257
|
+
cache_scope: result["cacheScope"],
|
|
128
258
|
)
|
|
129
259
|
end
|
|
130
260
|
|
|
@@ -160,7 +290,9 @@ module MCP
|
|
|
160
290
|
response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation)
|
|
161
291
|
result = response["result"] || {}
|
|
162
292
|
|
|
163
|
-
tools = (result["tools"] || []).
|
|
293
|
+
tools = (result["tools"] || []).filter_map do |tool|
|
|
294
|
+
next if exclude_invalid_x_mcp_header?(tool)
|
|
295
|
+
|
|
164
296
|
Tool.new(
|
|
165
297
|
name: tool["name"],
|
|
166
298
|
description: tool["description"],
|
|
@@ -189,6 +321,7 @@ module MCP
|
|
|
189
321
|
# Cancelling it aborts whichever page is currently in flight; pages already returned are kept,
|
|
190
322
|
# but the call raises `MCP::CancelledError` instead of returning the partial set.
|
|
191
323
|
# @return [Array<MCP::Client::Tool>] An array of available tools.
|
|
324
|
+
# @raise [MCP::Client::PaginationLimitError] If the server offers more than `max_pages` pages.
|
|
192
325
|
#
|
|
193
326
|
# @example
|
|
194
327
|
# tools = client.tools
|
|
@@ -230,6 +363,7 @@ module MCP
|
|
|
230
363
|
#
|
|
231
364
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
232
365
|
# @return [Array<Hash>] An array of available resources.
|
|
366
|
+
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
|
233
367
|
def resources(cancellation: nil)
|
|
234
368
|
# TODO: consider renaming to `list_all_resources`.
|
|
235
369
|
fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources)
|
|
@@ -265,6 +399,7 @@ module MCP
|
|
|
265
399
|
#
|
|
266
400
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
267
401
|
# @return [Array<Hash>] An array of available resource templates.
|
|
402
|
+
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
|
268
403
|
def resource_templates(cancellation: nil)
|
|
269
404
|
# TODO: consider renaming to `list_all_resource_templates`.
|
|
270
405
|
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates)
|
|
@@ -300,6 +435,7 @@ module MCP
|
|
|
300
435
|
#
|
|
301
436
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
302
437
|
# @return [Array<Hash>] An array of available prompts.
|
|
438
|
+
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
|
303
439
|
def prompts(cancellation: nil)
|
|
304
440
|
# TODO: consider renaming to `list_all_prompts`.
|
|
305
441
|
fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts)
|
|
@@ -340,7 +476,10 @@ module MCP
|
|
|
340
476
|
# @note
|
|
341
477
|
# The exact requirements for `arguments` are determined by the transport layer in use.
|
|
342
478
|
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
|
343
|
-
|
|
479
|
+
# @param input_responses [Hash, nil] SEP-2322 answers to a previous `input_required` result's `inputRequests`,
|
|
480
|
+
# keyed identically (manual retry legs).
|
|
481
|
+
# @param request_state [String, nil] The opaque `requestState` echoed back byte-exactly.
|
|
482
|
+
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil, input_responses: nil, request_state: nil)
|
|
344
483
|
tool_name = name || tool&.name
|
|
345
484
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
|
346
485
|
|
|
@@ -351,8 +490,10 @@ module MCP
|
|
|
351
490
|
meta_entries[:progressToken] = progress_token
|
|
352
491
|
end
|
|
353
492
|
params[:_meta] = meta_entries unless meta_entries.empty?
|
|
493
|
+
params[:inputResponses] = input_responses if input_responses
|
|
494
|
+
params[:requestState] = request_state if request_state
|
|
354
495
|
|
|
355
|
-
|
|
496
|
+
drive_input_required(method: "tools/call", params: params, cancellation: cancellation)
|
|
356
497
|
end
|
|
357
498
|
|
|
358
499
|
# Reads a resource from the server by URI and returns the contents.
|
|
@@ -362,8 +503,13 @@ module MCP
|
|
|
362
503
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
363
504
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
364
505
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
|
365
|
-
def read_resource(uri:, meta: nil, cancellation: nil)
|
|
366
|
-
|
|
506
|
+
def read_resource(uri:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil)
|
|
507
|
+
params = { uri: uri }
|
|
508
|
+
params = params.merge(_meta: meta) if meta && !meta.empty?
|
|
509
|
+
params[:inputResponses] = input_responses if input_responses
|
|
510
|
+
params[:requestState] = request_state if request_state
|
|
511
|
+
|
|
512
|
+
response = drive_input_required(method: "resources/read", params: params, cancellation: cancellation)
|
|
367
513
|
|
|
368
514
|
response.dig("result", "contents") || []
|
|
369
515
|
end
|
|
@@ -375,8 +521,13 @@ module MCP
|
|
|
375
521
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
376
522
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
377
523
|
# @return [Hash] A hash containing the prompt details.
|
|
378
|
-
def get_prompt(name:, meta: nil, cancellation: nil)
|
|
379
|
-
|
|
524
|
+
def get_prompt(name:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil)
|
|
525
|
+
params = { name: name }
|
|
526
|
+
params = params.merge(_meta: meta) if meta && !meta.empty?
|
|
527
|
+
params[:inputResponses] = input_responses if input_responses
|
|
528
|
+
params[:requestState] = request_state if request_state
|
|
529
|
+
|
|
530
|
+
response = drive_input_required(method: "prompts/get", params: params, cancellation: cancellation)
|
|
380
531
|
|
|
381
532
|
response.fetch("result", {})
|
|
382
533
|
end
|
|
@@ -405,8 +556,11 @@ module MCP
|
|
|
405
556
|
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
|
|
406
557
|
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
|
|
407
558
|
#
|
|
408
|
-
#
|
|
409
|
-
#
|
|
559
|
+
# The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that
|
|
560
|
+
# carries server-to-client requests (e.g. `MCP::Client::HTTP`); and an `elicitation/create` embedded in a SEP-2322
|
|
561
|
+
# `input_required` result, which needs no server-to-client route, and is how the modern lifecycle asks now that it
|
|
562
|
+
# forbids server-to-client requests. Both routes need `capabilities: { elicitation: {} }` passed to `connect`: per SEP-2322,
|
|
563
|
+
# a server MUST NOT embed input requests of a kind the client has not declared.
|
|
410
564
|
#
|
|
411
565
|
# @example Accept with schema defaults applied (SEP-1034)
|
|
412
566
|
# client.on_elicitation do |params|
|
|
@@ -417,11 +571,7 @@ module MCP
|
|
|
417
571
|
# end
|
|
418
572
|
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
|
419
573
|
def on_elicitation(&handler)
|
|
420
|
-
|
|
421
|
-
raise ArgumentError, "The transport does not support server-to-client requests"
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
|
|
574
|
+
register_input_handler(Methods::ELICITATION_CREATE, &handler)
|
|
425
575
|
end
|
|
426
576
|
|
|
427
577
|
# Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight.
|
|
@@ -432,8 +582,11 @@ module MCP
|
|
|
432
582
|
# For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response
|
|
433
583
|
# before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`.
|
|
434
584
|
#
|
|
435
|
-
#
|
|
436
|
-
#
|
|
585
|
+
# The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that
|
|
586
|
+
# carries server-to-client requests (e.g. `MCP::Client::HTTP`); and a `sampling/createMessage` embedded in a SEP-2322
|
|
587
|
+
# `input_required` result, which needs no server-to-client route. Both routes need `capabilities: { sampling: {} }` passed to
|
|
588
|
+
# `connect` (or `{ sampling: { tools: {} } }` for tool-enabled requests): per SEP-2322, a server MUST NOT embed input requests of
|
|
589
|
+
# a kind the client has not declared.
|
|
437
590
|
#
|
|
438
591
|
# @example Forward the request to an LLM and return its completion
|
|
439
592
|
#
|
|
@@ -455,11 +608,23 @@ module MCP
|
|
|
455
608
|
# Register this handler only to interoperate with servers that still send sampling requests during the deprecation window;
|
|
456
609
|
# new servers should call LLM provider APIs directly.
|
|
457
610
|
def on_sampling(&handler)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
end
|
|
611
|
+
register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler)
|
|
612
|
+
end
|
|
461
613
|
|
|
462
|
-
|
|
614
|
+
# Registers a handler for `roots/list`, answering both a server-to-client request on transports that
|
|
615
|
+
# support one and an embedded `roots/list` inside a SEP-2322 `input_required` result. The handler
|
|
616
|
+
# receives the request `params` (`nil` for `roots/list`) and must return a `ListRootsResult`-shaped Hash:
|
|
617
|
+
# `{ roots: [{ uri: "file:///project", name: "Project" }] }`.
|
|
618
|
+
#
|
|
619
|
+
# @example
|
|
620
|
+
# client.on_roots { { roots: [{ uri: "file:///project", name: "Project" }] } }
|
|
621
|
+
#
|
|
622
|
+
# @deprecated MCP Roots (`roots/list`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
|
623
|
+
# Register this handler only to interoperate with servers that still ask for roots.
|
|
624
|
+
#
|
|
625
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/client/roots
|
|
626
|
+
def on_roots(&handler)
|
|
627
|
+
register_input_handler(Methods::ROOTS_LIST, &handler)
|
|
463
628
|
end
|
|
464
629
|
|
|
465
630
|
# Sends a `ping` request to the server to verify the connection is alive.
|
|
@@ -485,9 +650,77 @@ module MCP
|
|
|
485
650
|
|
|
486
651
|
private
|
|
487
652
|
|
|
653
|
+
# Records a handler for one of the three kinds of input a server can ask this client for, and wires it to
|
|
654
|
+
# the transport when the transport can carry server-to-client requests. One registration serves both routes:
|
|
655
|
+
# the real request a 2025-11-25 server sends mid-call, and the request embedded in a SEP-2322
|
|
656
|
+
# `input_required` result, which is how the modern lifecycle asks now that it forbids server-to-client
|
|
657
|
+
# requests outright. Registering is therefore valid on a transport with no `on_server_request` (stdio,
|
|
658
|
+
# and every modern-lifecycle connection); only the wire route is skipped there.
|
|
659
|
+
def register_input_handler(method, &handler)
|
|
660
|
+
@input_required_handlers[method] = handler
|
|
661
|
+
transport.on_server_request(method, &handler) if transport.respond_to?(:on_server_request)
|
|
662
|
+
|
|
663
|
+
handler
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate
|
|
667
|
+
# the spec constraints MUST be excluded from `tools/list` results, so one malformed definition
|
|
668
|
+
# does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way.
|
|
669
|
+
# Legacy connections, and transports without a lifecycle notion, list everything as before.
|
|
670
|
+
def exclude_invalid_x_mcp_header?(tool)
|
|
671
|
+
return false unless transport.respond_to?(:modern?) && transport.modern?
|
|
672
|
+
|
|
673
|
+
scan = McpParamHeaders.scan(tool["inputSchema"])
|
|
674
|
+
return false if scan[:valid]
|
|
675
|
+
|
|
676
|
+
warn("MCP::Client: excluding tool #{tool["name"].inspect} from tools/list: #{scan[:reason]}")
|
|
677
|
+
true
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# Resolves the effective SEP-2575 lifecycle mode for `connect`:
|
|
681
|
+
#
|
|
682
|
+
# - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe,
|
|
683
|
+
# so the default negotiation can never override a version the caller asked for.
|
|
684
|
+
# - Absent an explicit mode, transports declaring `mode:` negotiate with `:auto`; other transports keep
|
|
685
|
+
# the historical legacy call shape.
|
|
686
|
+
# - An explicit `:modern`/`:auto` on a transport without `mode:` raises, rather than silently downgrading to
|
|
687
|
+
# a lifecycle the caller did not ask for.
|
|
688
|
+
def resolve_connect_mode(mode, protocol_version)
|
|
689
|
+
unless [nil, :legacy, :modern, :auto].include?(mode)
|
|
690
|
+
raise ArgumentError, "mode must be :legacy, :modern, or :auto"
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
return :legacy if mode == :legacy
|
|
694
|
+
return :legacy if mode.nil? && protocol_version && !Configuration.modern_protocol_version?(protocol_version)
|
|
695
|
+
|
|
696
|
+
if transport_connect_accepts_mode?
|
|
697
|
+
mode || :auto
|
|
698
|
+
elsif mode.nil?
|
|
699
|
+
:legacy
|
|
700
|
+
else
|
|
701
|
+
raise ArgumentError, "transport does not support mode: #{mode.inspect}"
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# `mode:` is forwarded only when the transport's `connect` declares it as a keyword.
|
|
706
|
+
# A bare `**kwargs` deliberately does not count, so wrappers and test doubles that
|
|
707
|
+
# absorb arbitrary keywords keep the historical legacy call shape. Objects that
|
|
708
|
+
# dispatch `connect` through `method_missing` (e.g. mocks) may not support `method`,
|
|
709
|
+
# which reads as not declaring `mode:`.
|
|
710
|
+
def transport_connect_accepts_mode?
|
|
711
|
+
connect_method = begin
|
|
712
|
+
transport.method(:connect)
|
|
713
|
+
rescue NameError
|
|
714
|
+
return false
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
connect_method.parameters.any? { |type, name| [:key, :keyreq].include?(type) && name == :mode }
|
|
718
|
+
end
|
|
719
|
+
|
|
488
720
|
# Walks every page of a list endpoint, following `next_cursor`, and returns
|
|
489
721
|
# the page results. The `seen` set guards against a server that repeats or
|
|
490
|
-
# cycles cursors,
|
|
722
|
+
# cycles cursors, and `@max_pages` bounds one that returns a fresh cursor every
|
|
723
|
+
# time, so the loop always terminates.
|
|
491
724
|
def fetch_all_pages
|
|
492
725
|
pages = []
|
|
493
726
|
seen = Set.new
|
|
@@ -499,6 +732,11 @@ module MCP
|
|
|
499
732
|
next_cursor = page.next_cursor
|
|
500
733
|
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
501
734
|
|
|
735
|
+
if pages.size >= @max_pages
|
|
736
|
+
raise PaginationLimitError, "Server returned more than #{@max_pages} pages; pass a larger `max_pages:` to " \
|
|
737
|
+
"`MCP::Client.new` if this is expected."
|
|
738
|
+
end
|
|
739
|
+
|
|
502
740
|
seen << next_cursor
|
|
503
741
|
cursor = next_cursor
|
|
504
742
|
end
|
|
@@ -510,7 +748,7 @@ module MCP
|
|
|
510
748
|
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
|
511
749
|
# request-specific metadata such as W3C trace context (`traceparent`,
|
|
512
750
|
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
|
513
|
-
def request(method:, params: nil, meta: nil, cancellation: nil)
|
|
751
|
+
def request(method:, params: nil, meta: nil, cancellation: nil, raise_on_input_required: true)
|
|
514
752
|
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
|
515
753
|
|
|
516
754
|
request_body = {
|
|
@@ -532,20 +770,104 @@ module MCP
|
|
|
532
770
|
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
|
|
533
771
|
end
|
|
534
772
|
|
|
535
|
-
raise_on_input_required(response)
|
|
773
|
+
raise_on_input_required(response) if raise_on_input_required
|
|
536
774
|
|
|
537
775
|
response
|
|
538
776
|
end
|
|
539
777
|
|
|
778
|
+
# Drives the SEP-2322 multi round-trip loop for `tools/call`, `prompts/get`, and `resources/read`.
|
|
779
|
+
# With no configured handlers this degrades to the plain request (an `input_required` result raises
|
|
780
|
+
# `InputRequiredError` for manual driving). Otherwise each `inputRequests` entry is fulfilled by
|
|
781
|
+
# the matching handler and the ORIGINAL request is re-issued with `inputResponses` under
|
|
782
|
+
# the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg.
|
|
783
|
+
# A `requestState`-only result (load shedding) retries after an exponential backoff.
|
|
784
|
+
# Every leg counts against `input_required_max_rounds`.
|
|
785
|
+
def drive_input_required(method:, params:, cancellation:)
|
|
786
|
+
response = request(
|
|
787
|
+
method: method,
|
|
788
|
+
params: params,
|
|
789
|
+
cancellation: cancellation,
|
|
790
|
+
raise_on_input_required: @input_required_handlers.empty?,
|
|
791
|
+
)
|
|
792
|
+
return response unless input_required?(response)
|
|
793
|
+
|
|
794
|
+
original_params = params.dup
|
|
795
|
+
original_params.delete(:inputResponses)
|
|
796
|
+
original_params.delete(:requestState)
|
|
797
|
+
rounds = 0
|
|
798
|
+
backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS
|
|
799
|
+
|
|
800
|
+
loop do
|
|
801
|
+
result = response["result"]
|
|
802
|
+
rounds += 1
|
|
803
|
+
if rounds > @input_required_max_rounds
|
|
804
|
+
raise InputRequiredError.new(
|
|
805
|
+
"Server still returned `input_required` after #{@input_required_max_rounds} rounds (SEP-2322).",
|
|
806
|
+
input_requests: result["inputRequests"] || {},
|
|
807
|
+
request_state: result["requestState"],
|
|
808
|
+
result: result,
|
|
809
|
+
)
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
input_requests = result["inputRequests"] || {}
|
|
813
|
+
responses = nil
|
|
814
|
+
if input_requests.empty?
|
|
815
|
+
sleep(backoff)
|
|
816
|
+
backoff = [backoff * 2, STATE_ONLY_BACKOFF_CAP_SECONDS].min
|
|
817
|
+
else
|
|
818
|
+
backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS
|
|
819
|
+
responses = fulfill_input_requests(input_requests, result)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
retry_params = original_params.dup
|
|
823
|
+
retry_params[:inputResponses] = responses if responses
|
|
824
|
+
retry_params[:requestState] = result["requestState"] if result["requestState"]
|
|
825
|
+
|
|
826
|
+
response = request(
|
|
827
|
+
method: method,
|
|
828
|
+
params: retry_params,
|
|
829
|
+
cancellation: cancellation,
|
|
830
|
+
raise_on_input_required: false,
|
|
831
|
+
)
|
|
832
|
+
return response unless input_required?(response)
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Dispatches every embedded request to its configured handler and collects
|
|
837
|
+
# the answers under the same keys. A kind without a handler falls back to
|
|
838
|
+
# the manual path by raising `InputRequiredError` with the full result.
|
|
839
|
+
def fulfill_input_requests(input_requests, result)
|
|
840
|
+
input_requests.each_with_object({}) do |(key, entry), responses|
|
|
841
|
+
entry_method = entry.is_a?(Hash) ? entry["method"] || entry[:method] : nil
|
|
842
|
+
handler = @input_required_handlers[entry_method]
|
|
843
|
+
unless handler
|
|
844
|
+
raise InputRequiredError.new(
|
|
845
|
+
"Server requested #{entry_method.inspect} input (key #{key.inspect}) but no matching handler " \
|
|
846
|
+
"is configured; inspect `input_requests` to respond manually (SEP-2322).",
|
|
847
|
+
input_requests: input_requests,
|
|
848
|
+
request_state: result["requestState"],
|
|
849
|
+
result: result,
|
|
850
|
+
)
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
responses[key] = handler.call(entry.is_a?(Hash) ? entry["params"] || entry[:params] : nil)
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def input_required?(response)
|
|
858
|
+
result = response.is_a?(Hash) ? response["result"] : nil
|
|
859
|
+
result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
|
|
860
|
+
end
|
|
861
|
+
|
|
540
862
|
# Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
|
|
541
863
|
# Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
|
|
542
864
|
def raise_on_input_required(response)
|
|
543
|
-
|
|
544
|
-
return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
|
|
865
|
+
return unless input_required?(response)
|
|
545
866
|
|
|
867
|
+
result = response["result"]
|
|
546
868
|
raise InputRequiredError.new(
|
|
547
|
-
"Server returned `input_required
|
|
548
|
-
"
|
|
869
|
+
"Server returned `input_required` (SEP-2322). Register a handler with `on_elicitation`, " \
|
|
870
|
+
"`on_sampling`, or `on_roots` to resume automatically, or inspect `input_requests` to respond manually.",
|
|
549
871
|
input_requests: result["inputRequests"] || {},
|
|
550
872
|
request_state: result["requestState"],
|
|
551
873
|
result: result,
|