mcp 1.0.0 → 1.2.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 +428 -9
- 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/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +95 -7
- data/lib/mcp/client/stdio.rb +243 -66
- data/lib/mcp/client/tool.rb +3 -2
- data/lib/mcp/client.rb +364 -41
- data/lib/mcp/configuration.rb +84 -7
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/error_codes.rb +14 -8
- 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 +117 -0
- 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 +710 -20
- data/lib/mcp/server.rb +541 -43
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +77 -15
- data/lib/mcp/tool/response.rb +8 -2
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -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,12 +290,15 @@ 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"],
|
|
167
299
|
input_schema: tool["inputSchema"],
|
|
168
300
|
output_schema: tool["outputSchema"],
|
|
301
|
+
annotations: tool["annotations"],
|
|
169
302
|
)
|
|
170
303
|
end
|
|
171
304
|
|
|
@@ -188,6 +321,7 @@ module MCP
|
|
|
188
321
|
# Cancelling it aborts whichever page is currently in flight; pages already returned are kept,
|
|
189
322
|
# but the call raises `MCP::CancelledError` instead of returning the partial set.
|
|
190
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.
|
|
191
325
|
#
|
|
192
326
|
# @example
|
|
193
327
|
# tools = client.tools
|
|
@@ -229,6 +363,7 @@ module MCP
|
|
|
229
363
|
#
|
|
230
364
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
231
365
|
# @return [Array<Hash>] An array of available resources.
|
|
366
|
+
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
|
232
367
|
def resources(cancellation: nil)
|
|
233
368
|
# TODO: consider renaming to `list_all_resources`.
|
|
234
369
|
fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources)
|
|
@@ -264,6 +399,7 @@ module MCP
|
|
|
264
399
|
#
|
|
265
400
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
266
401
|
# @return [Array<Hash>] An array of available resource templates.
|
|
402
|
+
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
|
267
403
|
def resource_templates(cancellation: nil)
|
|
268
404
|
# TODO: consider renaming to `list_all_resource_templates`.
|
|
269
405
|
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates)
|
|
@@ -299,6 +435,7 @@ module MCP
|
|
|
299
435
|
#
|
|
300
436
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
|
|
301
437
|
# @return [Array<Hash>] An array of available prompts.
|
|
438
|
+
# @raise [MCP::Client::PaginationLimitError] See {#tools}.
|
|
302
439
|
def prompts(cancellation: nil)
|
|
303
440
|
# TODO: consider renaming to `list_all_prompts`.
|
|
304
441
|
fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts)
|
|
@@ -339,7 +476,10 @@ module MCP
|
|
|
339
476
|
# @note
|
|
340
477
|
# The exact requirements for `arguments` are determined by the transport layer in use.
|
|
341
478
|
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
|
342
|
-
|
|
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)
|
|
343
483
|
tool_name = name || tool&.name
|
|
344
484
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
|
345
485
|
|
|
@@ -350,8 +490,10 @@ module MCP
|
|
|
350
490
|
meta_entries[:progressToken] = progress_token
|
|
351
491
|
end
|
|
352
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
|
|
353
495
|
|
|
354
|
-
|
|
496
|
+
drive_input_required(method: "tools/call", params: params, cancellation: cancellation)
|
|
355
497
|
end
|
|
356
498
|
|
|
357
499
|
# Reads a resource from the server by URI and returns the contents.
|
|
@@ -361,8 +503,13 @@ module MCP
|
|
|
361
503
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
362
504
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
363
505
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
|
364
|
-
def read_resource(uri:, meta: nil, cancellation: nil)
|
|
365
|
-
|
|
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)
|
|
366
513
|
|
|
367
514
|
response.dig("result", "contents") || []
|
|
368
515
|
end
|
|
@@ -374,8 +521,13 @@ module MCP
|
|
|
374
521
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
|
375
522
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
|
376
523
|
# @return [Hash] A hash containing the prompt details.
|
|
377
|
-
def get_prompt(name:, meta: nil, cancellation: nil)
|
|
378
|
-
|
|
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)
|
|
379
531
|
|
|
380
532
|
response.fetch("result", {})
|
|
381
533
|
end
|
|
@@ -404,8 +556,11 @@ module MCP
|
|
|
404
556
|
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
|
|
405
557
|
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
|
|
406
558
|
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
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.
|
|
409
564
|
#
|
|
410
565
|
# @example Accept with schema defaults applied (SEP-1034)
|
|
411
566
|
# client.on_elicitation do |params|
|
|
@@ -416,11 +571,7 @@ module MCP
|
|
|
416
571
|
# end
|
|
417
572
|
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
|
418
573
|
def on_elicitation(&handler)
|
|
419
|
-
|
|
420
|
-
raise ArgumentError, "The transport does not support server-to-client requests"
|
|
421
|
-
end
|
|
422
|
-
|
|
423
|
-
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
|
|
574
|
+
register_input_handler(Methods::ELICITATION_CREATE, &handler)
|
|
424
575
|
end
|
|
425
576
|
|
|
426
577
|
# Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight.
|
|
@@ -431,8 +582,11 @@ module MCP
|
|
|
431
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
|
|
432
583
|
# before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`.
|
|
433
584
|
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
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.
|
|
436
590
|
#
|
|
437
591
|
# @example Forward the request to an LLM and return its completion
|
|
438
592
|
#
|
|
@@ -454,11 +608,23 @@ module MCP
|
|
|
454
608
|
# Register this handler only to interoperate with servers that still send sampling requests during the deprecation window;
|
|
455
609
|
# new servers should call LLM provider APIs directly.
|
|
456
610
|
def on_sampling(&handler)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
end
|
|
611
|
+
register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler)
|
|
612
|
+
end
|
|
460
613
|
|
|
461
|
-
|
|
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)
|
|
462
628
|
end
|
|
463
629
|
|
|
464
630
|
# Sends a `ping` request to the server to verify the connection is alive.
|
|
@@ -484,9 +650,77 @@ module MCP
|
|
|
484
650
|
|
|
485
651
|
private
|
|
486
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
|
+
|
|
487
720
|
# Walks every page of a list endpoint, following `next_cursor`, and returns
|
|
488
721
|
# the page results. The `seen` set guards against a server that repeats or
|
|
489
|
-
# cycles cursors,
|
|
722
|
+
# cycles cursors, and `@max_pages` bounds one that returns a fresh cursor every
|
|
723
|
+
# time, so the loop always terminates.
|
|
490
724
|
def fetch_all_pages
|
|
491
725
|
pages = []
|
|
492
726
|
seen = Set.new
|
|
@@ -498,6 +732,11 @@ module MCP
|
|
|
498
732
|
next_cursor = page.next_cursor
|
|
499
733
|
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
500
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
|
+
|
|
501
740
|
seen << next_cursor
|
|
502
741
|
cursor = next_cursor
|
|
503
742
|
end
|
|
@@ -509,7 +748,7 @@ module MCP
|
|
|
509
748
|
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
|
510
749
|
# request-specific metadata such as W3C trace context (`traceparent`,
|
|
511
750
|
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
|
512
|
-
def request(method:, params: nil, meta: nil, cancellation: nil)
|
|
751
|
+
def request(method:, params: nil, meta: nil, cancellation: nil, raise_on_input_required: true)
|
|
513
752
|
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
|
514
753
|
|
|
515
754
|
request_body = {
|
|
@@ -531,20 +770,104 @@ module MCP
|
|
|
531
770
|
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
|
|
532
771
|
end
|
|
533
772
|
|
|
534
|
-
raise_on_input_required(response)
|
|
773
|
+
raise_on_input_required(response) if raise_on_input_required
|
|
535
774
|
|
|
536
775
|
response
|
|
537
776
|
end
|
|
538
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
|
+
|
|
539
862
|
# Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
|
|
540
863
|
# Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
|
|
541
864
|
def raise_on_input_required(response)
|
|
542
|
-
|
|
543
|
-
return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
|
|
865
|
+
return unless input_required?(response)
|
|
544
866
|
|
|
867
|
+
result = response["result"]
|
|
545
868
|
raise InputRequiredError.new(
|
|
546
|
-
"Server returned `input_required
|
|
547
|
-
"
|
|
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.",
|
|
548
871
|
input_requests: result["inputRequests"] || {},
|
|
549
872
|
request_state: result["requestState"],
|
|
550
873
|
result: result,
|