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.
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
- # This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests`
59
- # and respond manually.
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 server's `InitializeResult` (protocol version, capabilities, server info,
99
- # instructions), as reported by the transport after a successful `connect`.
100
- # Returns `nil` before `connect`, after `close`, or when the transport does
101
- # not expose a cached handshake result.
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
- # @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
119
- # does not expose an explicit handshake.
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
- transport.connect(
125
- client_info: client_info,
126
- protocol_version: protocol_version,
127
- capabilities: capabilities,
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"] || []).map do |tool|
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
- def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil)
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
- request(method: "tools/call", params: params, cancellation: cancellation)
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
- response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation)
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
- response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation)
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
- # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`);
408
- # pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them.
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
- unless transport.respond_to?(:on_server_request)
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
- # Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to
435
- # `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them.
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
- unless transport.respond_to?(:on_server_request)
458
- raise ArgumentError, "The transport does not support server-to-client requests"
459
- end
611
+ register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler)
612
+ end
460
613
 
461
- transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler)
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, so the loop always terminates.
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
- result = response.is_a?(Hash) ? response["result"] : nil
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`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \
547
- "Inspect `input_requests` to respond manually.",
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,