Merge pull request #500 from koic/client_input_required_driver
Drive multi round-trip `input_required` results on the client per SEP-2322
This commit is contained in:
commit
33ed4a80aa
5 changed files with 463 additions and 77 deletions
|
|
@ -71,7 +71,13 @@ It implements the Model Context Protocol specification, handling model context r
|
||||||
client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via `Server.new(request_state_security:)` to
|
client-controlled input: pass `MCP::Server::RequestStateSecurity.new(key:)` (a 32-byte key) via `Server.new(request_state_security:)` to
|
||||||
have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, all transparently to handlers.
|
have it sealed with AES-256-GCM and bound to a TTL plus the originating method, target, and arguments, all transparently to handlers.
|
||||||
Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses the wire exactly as
|
Multi-process deployments must share the key across workers; without `request_state_security:` the state crosses the wire exactly as
|
||||||
the handler wrote it and protecting it is the handler author's responsibility
|
the handler wrote it and protecting it is the handler author's responsibility. On the client, register handlers with
|
||||||
|
`on_elicitation` / `on_sampling` / `on_roots` - the same registrations that answer a real server-to-client request - and
|
||||||
|
declare the matching capabilities on `connect` (a server embeds only the request kinds the client declared);
|
||||||
|
`call_tool` / `get_prompt` / `read_resource` then resume `input_required` results automatically: each embedded request is fulfilled by
|
||||||
|
the matching handler and the original request is re-issued with `inputResponses` plus the echoed `requestState`
|
||||||
|
(with exponential backoff for `requestState`-only load-shedding legs). Without a matching handler they raise `MCP::Client::InputRequiredError`,
|
||||||
|
and the `input_responses:` / `request_state:` keyword arguments support manual driving
|
||||||
- `ping` - Simple health check
|
- `ping` - Simple health check
|
||||||
- `logging/setLevel` - Configures the minimum log level for the server
|
- `logging/setLevel` - Configures the minimum log level for the server
|
||||||
- `tools/list` - Lists all registered tools and their schemas
|
- `tools/list` - Lists all registered tools and their schemas
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,13 @@ end
|
||||||
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
|
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
|
||||||
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
|
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
|
||||||
client = MCP::Client.new(transport: transport)
|
client = MCP::Client.new(transport: transport)
|
||||||
|
|
||||||
|
# SEP-2322: the MRTR scenario exercises the automatic driver; this elicitation handler accepts
|
||||||
|
# every embedded confirmation request, and the driver echoes `requestState` byte-exactly.
|
||||||
|
# The same registration answers a real `elicitation/create` request, so nothing here is MRTR-specific.
|
||||||
|
if scenario == "sep-2322-client-request-state"
|
||||||
|
client.on_elicitation { |_params| { action: "accept", content: { confirmed: true } } }
|
||||||
|
end
|
||||||
capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {}
|
capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {}
|
||||||
# The conformance harness asserts on the observed protocol exchange, not on the client's exit status,
|
# The conformance harness asserts on the observed protocol exchange, not on the client's exit status,
|
||||||
# and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios
|
# and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios
|
||||||
|
|
@ -171,13 +178,28 @@ capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation
|
||||||
# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace
|
# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace
|
||||||
# and a failing exit status.
|
# and a failing exit status.
|
||||||
begin
|
begin
|
||||||
# The conformance referee validates the legacy `initialize` handshake, so pin the legacy lifecycle:
|
# The harness names the wire version per scenario run: the 2026 draft selects the stateless modern lifecycle,
|
||||||
# the default `:auto` negotiation would prepend a `server/discover` probe the scenario servers do not expect.
|
# and every dated version through 2025-11-25 keeps the legacy `initialize` handshake
|
||||||
client.connect(
|
# (pinned rather than `:auto`, whose `server/discover` probe the legacy scenario servers do not expect).
|
||||||
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
mode = ENV["MCP_CONFORMANCE_PROTOCOL_VERSION"] == "2026-07-28" ? :modern : :legacy
|
||||||
capabilities: capabilities,
|
connect = lambda do
|
||||||
mode: :legacy,
|
client.connect(
|
||||||
)
|
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
||||||
|
capabilities: capabilities,
|
||||||
|
mode: mode,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
connect.call
|
||||||
|
rescue MCP::Client::RequestHandlerError
|
||||||
|
# SEP-2575 version negotiation: a server may reject the first request with `-32022` naming
|
||||||
|
# its supported versions. Retry once so the harness observes a follow-up request carrying
|
||||||
|
# a mutually supported version (this client already speaks the latest draft).
|
||||||
|
raise unless mode == :modern
|
||||||
|
|
||||||
|
connect.call
|
||||||
|
end
|
||||||
|
|
||||||
case scenario
|
case scenario
|
||||||
when "initialize"
|
when "initialize"
|
||||||
|
|
@ -190,6 +212,35 @@ begin
|
||||||
focal = tools.find { |t| t.name == "json_schema_2020_12_tool" }
|
focal = tools.find { |t| t.name == "json_schema_2020_12_tool" }
|
||||||
abort("Tool json_schema_2020_12_tool not found") unless focal
|
abort("Tool json_schema_2020_12_tool not found") unless focal
|
||||||
client.call_tool(name: "json_schema_echo", arguments: { "schema" => focal.input_schema })
|
client.call_tool(name: "json_schema_echo", arguments: { "schema" => focal.input_schema })
|
||||||
|
when "request-metadata"
|
||||||
|
# SEP-2575: the checks read the envelope and headers of the requests the modern connect
|
||||||
|
# and this listing already produce.
|
||||||
|
client.tools
|
||||||
|
when "sep-2322-client-request-state"
|
||||||
|
# SEP-2322: the driver resumes `test_mrtr_echo_state` automatically (fresh JSON-RPC id,
|
||||||
|
# byte-exact `requestState` echo); `test_mrtr_unrelated` must carry neither retry field;
|
||||||
|
# `test_mrtr_no_state` retries without inventing a state; `test_mrtr_no_result_type`
|
||||||
|
# has no `resultType` and therefore must be treated as complete, with no retry.
|
||||||
|
client.tools
|
||||||
|
client.call_tool(name: "test_mrtr_echo_state", arguments: {})
|
||||||
|
client.call_tool(name: "test_mrtr_unrelated", arguments: {})
|
||||||
|
client.call_tool(name: "test_mrtr_no_state", arguments: {})
|
||||||
|
client.call_tool(name: "test_mrtr_no_result_type", arguments: {})
|
||||||
|
when "http-custom-headers"
|
||||||
|
# SEP-2243: the harness names the exact tool calls (with encoding edge-case values) via context;
|
||||||
|
# listing first teaches the transport the `x-mcp-header` declarations it mirrors.
|
||||||
|
client.tools
|
||||||
|
(conformance_context["toolCalls"] || []).each do |tool_call|
|
||||||
|
client.call_tool(name: tool_call["name"], arguments: tool_call["arguments"])
|
||||||
|
end
|
||||||
|
when "http-invalid-tool-headers"
|
||||||
|
# SEP-2243: the listing carries one valid tool among invalid `x-mcp-header` definitions;
|
||||||
|
# calling only the valid one shows the invalid definitions did not block it.
|
||||||
|
client.tools
|
||||||
|
client.call_tool(name: "valid_tool", arguments: { "message" => "hello" })
|
||||||
|
when "json-schema-ref-no-deref"
|
||||||
|
# SEP-2106: listing tools whose schemas carry `$ref` must not trigger network dereferencing.
|
||||||
|
client.tools
|
||||||
when "tools_call"
|
when "tools_call"
|
||||||
tools = client.tools
|
tools = client.tools
|
||||||
add_numbers = tools.find { |t| t.name == "add_numbers" }
|
add_numbers = tools.find { |t| t.name == "add_numbers" }
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,6 @@
|
||||||
server: []
|
server: []
|
||||||
client:
|
client:
|
||||||
# Pending client-side support for the 2026-07-28 stateless lifecycle: the conformance client
|
|
||||||
# still connects through the legacy handshake and has no SEP-2322 MRTR driver yet.
|
|
||||||
- request-metadata
|
|
||||||
- sep-2322-client-request-state
|
|
||||||
# SEP-2243 client-side custom-header mirroring and invalid-tool header gating
|
|
||||||
# are not implemented in `MCP::Client::HTTP` yet.
|
|
||||||
- http-custom-headers
|
|
||||||
- http-invalid-tool-headers
|
|
||||||
# Client-side JSON Schema handling: `$ref` values must reach the caller unresolved,
|
|
||||||
# and the conformance client does not run the scenario over the modern lifecycle yet.
|
|
||||||
- json-schema-ref-no-deref
|
|
||||||
# Unimplemented authorization extensions.
|
# Unimplemented authorization extensions.
|
||||||
- auth/dpop
|
- auth/dpop
|
||||||
- auth/dpop-nonce
|
- auth/dpop-nonce
|
||||||
- auth/wif-jwt-bearer
|
- auth/wif-jwt-bearer
|
||||||
# Fails one check added to the suite after the 0.2.0-alpha.10 requirements anchor.
|
|
||||||
- auth/authorization-server-migration
|
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,9 @@ module MCP
|
||||||
# `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
|
# `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
|
||||||
# `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue
|
# `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue
|
||||||
# the original request with `inputResponses` plus the echoed opaque `requestState`.
|
# the original request with `inputResponses` plus the echoed opaque `requestState`.
|
||||||
# This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests`
|
# With handlers registered through `on_elicitation`, `on_sampling`, or `on_roots`, the resume loop runs
|
||||||
# and respond manually.
|
# automatically; this error surfaces when no matching handler exists (manual driving via
|
||||||
|
# `input_requests` and the `input_responses:`/`request_state:` kwargs), or when the round cap is exhausted.
|
||||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
||||||
class InputRequiredError < StandardError
|
class InputRequiredError < StandardError
|
||||||
attr_reader :input_requests, :request_state, :result
|
attr_reader :input_requests, :request_state, :result
|
||||||
|
|
@ -93,16 +94,36 @@ module MCP
|
||||||
keyword_init: true,
|
keyword_init: true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rounds the SEP-2322 driver runs before giving up, matching the TypeScript and
|
||||||
|
# Python SDK defaults. Every leg counts, including `requestState`-only retries.
|
||||||
|
DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10
|
||||||
|
|
||||||
|
# Backoff for `requestState`-only (load shedding) legs: exponential from 50ms to
|
||||||
|
# a 250ms cap, matching the Python SDK (the TypeScript SDK uses a fixed 250ms).
|
||||||
|
STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05
|
||||||
|
STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25
|
||||||
|
|
||||||
# Initializes a new MCP::Client instance.
|
# Initializes a new MCP::Client instance.
|
||||||
#
|
#
|
||||||
# @param transport [Object] The transport object to use for communication with the server.
|
# @param transport [Object] The transport object to use for communication with the server.
|
||||||
# The transport should be a duck type that responds to `send_request`. See the README for more details.
|
# The transport should be a duck type that responds to `send_request`. See the README for more details.
|
||||||
|
# @param input_required_max_rounds [Integer] Cap on SEP-2322 driver rounds.
|
||||||
|
#
|
||||||
|
# Once a handler is registered through `on_elicitation`, `on_sampling`, or `on_roots`, `call_tool`,
|
||||||
|
# `get_prompt`, and `read_resource` resume `input_required` results automatically; without handlers
|
||||||
|
# (or when a requested kind has no handler) they raise `InputRequiredError` for manual driving,
|
||||||
|
# exactly as before.
|
||||||
#
|
#
|
||||||
# @example
|
# @example
|
||||||
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
|
# transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
|
||||||
# client = MCP::Client.new(transport: transport)
|
# client = MCP::Client.new(transport: transport)
|
||||||
def initialize(transport:)
|
def initialize(transport:, input_required_max_rounds: DEFAULT_INPUT_REQUIRED_MAX_ROUNDS)
|
||||||
@transport = transport
|
@transport = transport
|
||||||
|
# Populated by `on_elicitation`, `on_sampling`, and `on_roots`. The same handler answers both ways
|
||||||
|
# the server can ask for input: a real server-to-client request, and an embedded request inside
|
||||||
|
# a SEP-2322 `input_required` result.
|
||||||
|
@input_required_handlers = {}
|
||||||
|
@input_required_max_rounds = input_required_max_rounds
|
||||||
end
|
end
|
||||||
|
|
||||||
# The user may want to access additional transport-specific methods/attributes
|
# The user may want to access additional transport-specific methods/attributes
|
||||||
|
|
@ -431,7 +452,10 @@ module MCP
|
||||||
# @note
|
# @note
|
||||||
# The exact requirements for `arguments` are determined by the transport layer in use.
|
# The exact requirements for `arguments` are determined by the transport layer in use.
|
||||||
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
# Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
|
||||||
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil)
|
# @param input_responses [Hash, nil] SEP-2322 answers to a previous `input_required` result's `inputRequests`,
|
||||||
|
# keyed identically (manual retry legs).
|
||||||
|
# @param request_state [String, nil] The opaque `requestState` echoed back byte-exactly.
|
||||||
|
def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil, input_responses: nil, request_state: nil)
|
||||||
tool_name = name || tool&.name
|
tool_name = name || tool&.name
|
||||||
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
|
||||||
|
|
||||||
|
|
@ -442,8 +466,10 @@ module MCP
|
||||||
meta_entries[:progressToken] = progress_token
|
meta_entries[:progressToken] = progress_token
|
||||||
end
|
end
|
||||||
params[:_meta] = meta_entries unless meta_entries.empty?
|
params[:_meta] = meta_entries unless meta_entries.empty?
|
||||||
|
params[:inputResponses] = input_responses if input_responses
|
||||||
|
params[:requestState] = request_state if request_state
|
||||||
|
|
||||||
request(method: "tools/call", params: params, cancellation: cancellation)
|
drive_input_required(method: "tools/call", params: params, cancellation: cancellation)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Reads a resource from the server by URI and returns the contents.
|
# Reads a resource from the server by URI and returns the contents.
|
||||||
|
|
@ -453,8 +479,13 @@ module MCP
|
||||||
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
||||||
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
||||||
# @return [Array<Hash>] An array of resource contents (text or blob).
|
# @return [Array<Hash>] An array of resource contents (text or blob).
|
||||||
def read_resource(uri:, meta: nil, cancellation: nil)
|
def read_resource(uri:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil)
|
||||||
response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation)
|
params = { uri: uri }
|
||||||
|
params = params.merge(_meta: meta) if meta && !meta.empty?
|
||||||
|
params[:inputResponses] = input_responses if input_responses
|
||||||
|
params[:requestState] = request_state if request_state
|
||||||
|
|
||||||
|
response = drive_input_required(method: "resources/read", params: params, cancellation: cancellation)
|
||||||
|
|
||||||
response.dig("result", "contents") || []
|
response.dig("result", "contents") || []
|
||||||
end
|
end
|
||||||
|
|
@ -466,8 +497,13 @@ module MCP
|
||||||
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
# e.g. SEP-414 trace context (see {MCP::TraceContext}).
|
||||||
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
# @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
|
||||||
# @return [Hash] A hash containing the prompt details.
|
# @return [Hash] A hash containing the prompt details.
|
||||||
def get_prompt(name:, meta: nil, cancellation: nil)
|
def get_prompt(name:, meta: nil, cancellation: nil, input_responses: nil, request_state: nil)
|
||||||
response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation)
|
params = { name: name }
|
||||||
|
params = params.merge(_meta: meta) if meta && !meta.empty?
|
||||||
|
params[:inputResponses] = input_responses if input_responses
|
||||||
|
params[:requestState] = request_state if request_state
|
||||||
|
|
||||||
|
response = drive_input_required(method: "prompts/get", params: params, cancellation: cancellation)
|
||||||
|
|
||||||
response.fetch("result", {})
|
response.fetch("result", {})
|
||||||
end
|
end
|
||||||
|
|
@ -496,8 +532,11 @@ module MCP
|
||||||
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
|
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
|
||||||
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
|
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
|
||||||
#
|
#
|
||||||
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`);
|
# The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that
|
||||||
# pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them.
|
# carries server-to-client requests (e.g. `MCP::Client::HTTP`); and an `elicitation/create` embedded in a SEP-2322
|
||||||
|
# `input_required` result, which needs no server-to-client route, and is how the modern lifecycle asks now that it
|
||||||
|
# forbids server-to-client requests. Both routes need `capabilities: { elicitation: {} }` passed to `connect`: per SEP-2322,
|
||||||
|
# a server MUST NOT embed input requests of a kind the client has not declared.
|
||||||
#
|
#
|
||||||
# @example Accept with schema defaults applied (SEP-1034)
|
# @example Accept with schema defaults applied (SEP-1034)
|
||||||
# client.on_elicitation do |params|
|
# client.on_elicitation do |params|
|
||||||
|
|
@ -508,11 +547,7 @@ module MCP
|
||||||
# end
|
# end
|
||||||
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
||||||
def on_elicitation(&handler)
|
def on_elicitation(&handler)
|
||||||
unless transport.respond_to?(:on_server_request)
|
register_input_handler(Methods::ELICITATION_CREATE, &handler)
|
||||||
raise ArgumentError, "The transport does not support server-to-client requests"
|
|
||||||
end
|
|
||||||
|
|
||||||
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight.
|
# Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight.
|
||||||
|
|
@ -523,8 +558,11 @@ module MCP
|
||||||
# For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response
|
# For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response
|
||||||
# before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`.
|
# before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`.
|
||||||
#
|
#
|
||||||
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to
|
# The same handler answers both ways a server can ask: a real request mid-call, which needs a transport that
|
||||||
# `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them.
|
# carries server-to-client requests (e.g. `MCP::Client::HTTP`); and a `sampling/createMessage` embedded in a SEP-2322
|
||||||
|
# `input_required` result, which needs no server-to-client route. Both routes need `capabilities: { sampling: {} }` passed to
|
||||||
|
# `connect` (or `{ sampling: { tools: {} } }` for tool-enabled requests): per SEP-2322, a server MUST NOT embed input requests of
|
||||||
|
# a kind the client has not declared.
|
||||||
#
|
#
|
||||||
# @example Forward the request to an LLM and return its completion
|
# @example Forward the request to an LLM and return its completion
|
||||||
#
|
#
|
||||||
|
|
@ -546,11 +584,23 @@ module MCP
|
||||||
# Register this handler only to interoperate with servers that still send sampling requests during the deprecation window;
|
# Register this handler only to interoperate with servers that still send sampling requests during the deprecation window;
|
||||||
# new servers should call LLM provider APIs directly.
|
# new servers should call LLM provider APIs directly.
|
||||||
def on_sampling(&handler)
|
def on_sampling(&handler)
|
||||||
unless transport.respond_to?(:on_server_request)
|
register_input_handler(Methods::SAMPLING_CREATE_MESSAGE, &handler)
|
||||||
raise ArgumentError, "The transport does not support server-to-client requests"
|
end
|
||||||
end
|
|
||||||
|
|
||||||
transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler)
|
# Registers a handler for `roots/list`, answering both a server-to-client request on transports that
|
||||||
|
# support one and an embedded `roots/list` inside a SEP-2322 `input_required` result. The handler
|
||||||
|
# receives the request `params` (`nil` for `roots/list`) and must return a `ListRootsResult`-shaped Hash:
|
||||||
|
# `{ roots: [{ uri: "file:///project", name: "Project" }] }`.
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# client.on_roots { { roots: [{ uri: "file:///project", name: "Project" }] } }
|
||||||
|
#
|
||||||
|
# @deprecated MCP Roots (`roots/list`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
||||||
|
# Register this handler only to interoperate with servers that still ask for roots.
|
||||||
|
#
|
||||||
|
# https://modelcontextprotocol.io/specification/2025-11-25/client/roots
|
||||||
|
def on_roots(&handler)
|
||||||
|
register_input_handler(Methods::ROOTS_LIST, &handler)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sends a `ping` request to the server to verify the connection is alive.
|
# Sends a `ping` request to the server to verify the connection is alive.
|
||||||
|
|
@ -576,6 +626,19 @@ module MCP
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Records a handler for one of the three kinds of input a server can ask this client for, and wires it to
|
||||||
|
# the transport when the transport can carry server-to-client requests. One registration serves both routes:
|
||||||
|
# the real request a 2025-11-25 server sends mid-call, and the request embedded in a SEP-2322
|
||||||
|
# `input_required` result, which is how the modern lifecycle asks now that it forbids server-to-client
|
||||||
|
# requests outright. Registering is therefore valid on a transport with no `on_server_request` (stdio,
|
||||||
|
# and every modern-lifecycle connection); only the wire route is skipped there.
|
||||||
|
def register_input_handler(method, &handler)
|
||||||
|
@input_required_handlers[method] = handler
|
||||||
|
transport.on_server_request(method, &handler) if transport.respond_to?(:on_server_request)
|
||||||
|
|
||||||
|
handler
|
||||||
|
end
|
||||||
|
|
||||||
# SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate
|
# SEP-2243: on the modern lifecycle, a tool definition whose `x-mcp-header` annotations violate
|
||||||
# the spec constraints MUST be excluded from `tools/list` results, so one malformed definition
|
# the spec constraints MUST be excluded from `tools/list` results, so one malformed definition
|
||||||
# does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way.
|
# does not block the valid tools. The TypeScript and Python SDKs filter their listings the same way.
|
||||||
|
|
@ -655,7 +718,7 @@ module MCP
|
||||||
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
# without mutating the caller's hashes. Per SEP-414, `_meta` carries
|
||||||
# request-specific metadata such as W3C trace context (`traceparent`,
|
# request-specific metadata such as W3C trace context (`traceparent`,
|
||||||
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
# `tracestate`, `baggage`); see {MCP::TraceContext}.
|
||||||
def request(method:, params: nil, meta: nil, cancellation: nil)
|
def request(method:, params: nil, meta: nil, cancellation: nil, raise_on_input_required: true)
|
||||||
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
|
||||||
|
|
||||||
request_body = {
|
request_body = {
|
||||||
|
|
@ -677,20 +740,104 @@ module MCP
|
||||||
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
|
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
|
||||||
end
|
end
|
||||||
|
|
||||||
raise_on_input_required(response)
|
raise_on_input_required(response) if raise_on_input_required
|
||||||
|
|
||||||
response
|
response
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Drives the SEP-2322 multi round-trip loop for `tools/call`, `prompts/get`, and `resources/read`.
|
||||||
|
# With no configured handlers this degrades to the plain request (an `input_required` result raises
|
||||||
|
# `InputRequiredError` for manual driving). Otherwise each `inputRequests` entry is fulfilled by
|
||||||
|
# the matching handler and the ORIGINAL request is re-issued with `inputResponses` under
|
||||||
|
# the same keys plus the byte-exact echoed `requestState`, on a fresh JSON-RPC id per leg.
|
||||||
|
# A `requestState`-only result (load shedding) retries after an exponential backoff.
|
||||||
|
# Every leg counts against `input_required_max_rounds`.
|
||||||
|
def drive_input_required(method:, params:, cancellation:)
|
||||||
|
response = request(
|
||||||
|
method: method,
|
||||||
|
params: params,
|
||||||
|
cancellation: cancellation,
|
||||||
|
raise_on_input_required: @input_required_handlers.empty?,
|
||||||
|
)
|
||||||
|
return response unless input_required?(response)
|
||||||
|
|
||||||
|
original_params = params.dup
|
||||||
|
original_params.delete(:inputResponses)
|
||||||
|
original_params.delete(:requestState)
|
||||||
|
rounds = 0
|
||||||
|
backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS
|
||||||
|
|
||||||
|
loop do
|
||||||
|
result = response["result"]
|
||||||
|
rounds += 1
|
||||||
|
if rounds > @input_required_max_rounds
|
||||||
|
raise InputRequiredError.new(
|
||||||
|
"Server still returned `input_required` after #{@input_required_max_rounds} rounds (SEP-2322).",
|
||||||
|
input_requests: result["inputRequests"] || {},
|
||||||
|
request_state: result["requestState"],
|
||||||
|
result: result,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
input_requests = result["inputRequests"] || {}
|
||||||
|
responses = nil
|
||||||
|
if input_requests.empty?
|
||||||
|
sleep(backoff)
|
||||||
|
backoff = [backoff * 2, STATE_ONLY_BACKOFF_CAP_SECONDS].min
|
||||||
|
else
|
||||||
|
backoff = STATE_ONLY_BACKOFF_INITIAL_SECONDS
|
||||||
|
responses = fulfill_input_requests(input_requests, result)
|
||||||
|
end
|
||||||
|
|
||||||
|
retry_params = original_params.dup
|
||||||
|
retry_params[:inputResponses] = responses if responses
|
||||||
|
retry_params[:requestState] = result["requestState"] if result["requestState"]
|
||||||
|
|
||||||
|
response = request(
|
||||||
|
method: method,
|
||||||
|
params: retry_params,
|
||||||
|
cancellation: cancellation,
|
||||||
|
raise_on_input_required: false,
|
||||||
|
)
|
||||||
|
return response unless input_required?(response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dispatches every embedded request to its configured handler and collects
|
||||||
|
# the answers under the same keys. A kind without a handler falls back to
|
||||||
|
# the manual path by raising `InputRequiredError` with the full result.
|
||||||
|
def fulfill_input_requests(input_requests, result)
|
||||||
|
input_requests.each_with_object({}) do |(key, entry), responses|
|
||||||
|
entry_method = entry.is_a?(Hash) ? entry["method"] || entry[:method] : nil
|
||||||
|
handler = @input_required_handlers[entry_method]
|
||||||
|
unless handler
|
||||||
|
raise InputRequiredError.new(
|
||||||
|
"Server requested #{entry_method.inspect} input (key #{key.inspect}) but no matching handler " \
|
||||||
|
"is configured; inspect `input_requests` to respond manually (SEP-2322).",
|
||||||
|
input_requests: input_requests,
|
||||||
|
request_state: result["requestState"],
|
||||||
|
result: result,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
responses[key] = handler.call(entry.is_a?(Hash) ? entry["params"] || entry[:params] : nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def input_required?(response)
|
||||||
|
result = response.is_a?(Hash) ? response["result"] : nil
|
||||||
|
result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
|
||||||
|
end
|
||||||
|
|
||||||
# Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
|
# Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
|
||||||
# Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
|
# Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
|
||||||
def raise_on_input_required(response)
|
def raise_on_input_required(response)
|
||||||
result = response.is_a?(Hash) ? response["result"] : nil
|
return unless input_required?(response)
|
||||||
return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
|
|
||||||
|
|
||||||
|
result = response["result"]
|
||||||
raise InputRequiredError.new(
|
raise InputRequiredError.new(
|
||||||
"Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \
|
"Server returned `input_required` (SEP-2322). Register a handler with `on_elicitation`, " \
|
||||||
"Inspect `input_requests` to respond manually.",
|
"`on_sampling`, or `on_roots` to resume automatically, or inspect `input_requests` to respond manually.",
|
||||||
input_requests: result["inputRequests"] || {},
|
input_requests: result["inputRequests"] || {},
|
||||||
request_state: result["requestState"],
|
request_state: result["requestState"],
|
||||||
result: result,
|
result: result,
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,14 @@ module MCP
|
||||||
Client.new(transport: transport).on_elicitation(&handler)
|
Client.new(transport: transport).on_elicitation(&handler)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_on_elicitation_raises_when_transport_does_not_support_server_requests
|
def test_on_elicitation_is_accepted_when_transport_does_not_support_server_requests
|
||||||
|
# The handler still answers embedded `elicitation/create` requests of SEP-2322 `input_required` results,
|
||||||
|
# which need no server-to-client route. Refusing to register it would lock stdio clients,
|
||||||
|
# and every modern-lifecycle connection, out of the only way the spec leaves for asking.
|
||||||
transport = mock
|
transport = mock
|
||||||
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
|
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
|
||||||
|
|
||||||
error = assert_raises(ArgumentError) do
|
Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } }
|
||||||
Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } }
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_includes(error.message, "does not support server-to-client requests")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_on_sampling_registers_handler_on_transport
|
def test_on_sampling_registers_handler_on_transport
|
||||||
|
|
@ -74,15 +73,19 @@ module MCP
|
||||||
Client.new(transport: transport).on_sampling(&handler)
|
Client.new(transport: transport).on_sampling(&handler)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_on_sampling_raises_when_transport_does_not_support_server_requests
|
def test_on_sampling_is_accepted_when_transport_does_not_support_server_requests
|
||||||
transport = mock
|
transport = mock
|
||||||
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
|
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
|
||||||
|
|
||||||
error = assert_raises(ArgumentError) do
|
Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } }
|
||||||
Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } }
|
end
|
||||||
end
|
|
||||||
|
|
||||||
assert_includes(error.message, "does not support server-to-client requests")
|
def test_on_roots_registers_handler_on_transport
|
||||||
|
transport = mock
|
||||||
|
handler = proc { { roots: [{ uri: "file:///project", name: "Project" }] } }
|
||||||
|
transport.expects(:on_server_request).with("roots/list")
|
||||||
|
|
||||||
|
Client.new(transport: transport).on_roots(&handler)
|
||||||
end
|
end
|
||||||
|
|
||||||
# A transport that declares `mode:` on `connect`, recording what it received.
|
# A transport that declares `mode:` on `connect`, recording what it received.
|
||||||
|
|
@ -380,16 +383,20 @@ module MCP
|
||||||
# constraints MUST be excluded from `tools/list` results, with a warning naming the tool.
|
# constraints MUST be excluded from `tools/list` results, with a warning naming the tool.
|
||||||
transport = mock
|
transport = mock
|
||||||
transport.stubs(:modern?).returns(true)
|
transport.stubs(:modern?).returns(true)
|
||||||
mock_response = { "result" => { "tools" => [
|
mock_response = {
|
||||||
{ "name" => "valid_tool", "inputSchema" => { "type" => "object" } },
|
"result" => {
|
||||||
{
|
"tools" => [
|
||||||
"name" => "invalid_tool",
|
{ "name" => "valid_tool", "inputSchema" => { "type" => "object" } },
|
||||||
"inputSchema" => {
|
{
|
||||||
"type" => "object",
|
"name" => "invalid_tool",
|
||||||
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
|
"inputSchema" => {
|
||||||
},
|
"type" => "object",
|
||||||
|
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
] } }
|
}
|
||||||
transport.expects(:send_request).returns(mock_response).once
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
client = Client.new(transport: transport)
|
client = Client.new(transport: transport)
|
||||||
|
|
||||||
|
|
@ -408,15 +415,19 @@ module MCP
|
||||||
def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection
|
def test_tools_keeps_invalid_x_mcp_header_definitions_on_a_legacy_connection
|
||||||
transport = mock
|
transport = mock
|
||||||
transport.stubs(:modern?).returns(false)
|
transport.stubs(:modern?).returns(false)
|
||||||
mock_response = { "result" => { "tools" => [
|
mock_response = {
|
||||||
{
|
"result" => {
|
||||||
"name" => "invalid_tool",
|
"tools" => [
|
||||||
"inputSchema" => {
|
{
|
||||||
"type" => "object",
|
"name" => "invalid_tool",
|
||||||
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
|
"inputSchema" => {
|
||||||
},
|
"type" => "object",
|
||||||
|
"properties" => { "value" => { "type" => "string", "x-mcp-header" => "bad name" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
] } }
|
}
|
||||||
transport.expects(:send_request).returns(mock_response).once
|
transport.expects(:send_request).returns(mock_response).once
|
||||||
client = Client.new(transport: transport)
|
client = Client.new(transport: transport)
|
||||||
|
|
||||||
|
|
@ -542,6 +553,190 @@ module MCP
|
||||||
assert_raises(Client::InputRequiredError) { client.list_tools }
|
assert_raises(Client::InputRequiredError) { client.list_tools }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_call_tool_drives_the_input_required_loop_with_a_configured_handler
|
||||||
|
transport = mock
|
||||||
|
sent_requests = []
|
||||||
|
input_required_response = {
|
||||||
|
"result" => {
|
||||||
|
"resultType" => "input_required",
|
||||||
|
"inputRequests" => {
|
||||||
|
"region" => { "method" => "elicitation/create", "params" => { "message" => "Which region?" } },
|
||||||
|
},
|
||||||
|
"requestState" => "state-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
final_response = { "result" => { "content" => [{ "type" => "text", "text" => "done" }] } }
|
||||||
|
transport.expects(:send_request).twice.with do |args|
|
||||||
|
sent_requests << args[:request]
|
||||||
|
true
|
||||||
|
end.returns(input_required_response, final_response)
|
||||||
|
|
||||||
|
handled_params = nil
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
client.on_elicitation do |params|
|
||||||
|
handled_params = params
|
||||||
|
{ action: "accept", content: { value: "us-east-1" } }
|
||||||
|
end
|
||||||
|
|
||||||
|
response = client.call_tool(name: "my_tool", arguments: { city: "Tokyo" })
|
||||||
|
|
||||||
|
assert_equal("done", response.dig("result", "content", 0, "text"))
|
||||||
|
assert_equal({ "message" => "Which region?" }, handled_params)
|
||||||
|
|
||||||
|
first_leg, retry_leg = sent_requests
|
||||||
|
# The ORIGINAL params are preserved; the answers ride under the same keys with
|
||||||
|
# the byte-exact `requestState` echo, on a fresh JSON-RPC id.
|
||||||
|
assert_equal("my_tool", retry_leg.dig(:params, :name))
|
||||||
|
assert_equal({ city: "Tokyo" }, retry_leg.dig(:params, :arguments))
|
||||||
|
assert_equal(
|
||||||
|
{ "region" => { action: "accept", content: { value: "us-east-1" } } },
|
||||||
|
retry_leg.dig(:params, :inputResponses),
|
||||||
|
)
|
||||||
|
assert_equal("state-1", retry_leg.dig(:params, :requestState))
|
||||||
|
refute_equal(first_leg[:id], retry_leg[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_input_required_loop_backs_off_on_request_state_only_legs
|
||||||
|
transport = mock
|
||||||
|
state_only_response = {
|
||||||
|
"result" => { "resultType" => "input_required", "requestState" => "busy-state" },
|
||||||
|
}
|
||||||
|
final_response = { "result" => { "content" => [] } }
|
||||||
|
transport.stubs(:send_request).returns(state_only_response, state_only_response, final_response)
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
client.on_elicitation { |_params| {} }
|
||||||
|
slept = []
|
||||||
|
client.stubs(:sleep).with { |seconds| slept << seconds }
|
||||||
|
|
||||||
|
response = client.call_tool(name: "my_tool")
|
||||||
|
|
||||||
|
assert_equal(final_response, response)
|
||||||
|
|
||||||
|
# Exponential from 50ms, matching the Python SDK.
|
||||||
|
assert_equal([0.05, 0.1], slept)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_input_required_loop_raises_after_max_rounds
|
||||||
|
transport = mock
|
||||||
|
input_required_response = {
|
||||||
|
"result" => {
|
||||||
|
"resultType" => "input_required",
|
||||||
|
"inputRequests" => { "q" => { "method" => "elicitation/create", "params" => {} } },
|
||||||
|
"requestState" => "state-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
transport.stubs(:send_request).returns(input_required_response)
|
||||||
|
|
||||||
|
client = Client.new(transport: transport, input_required_max_rounds: 2)
|
||||||
|
client.on_elicitation { |_params| { action: "decline" } }
|
||||||
|
|
||||||
|
error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") }
|
||||||
|
|
||||||
|
assert_includes(error.message, "after 2 rounds")
|
||||||
|
assert_equal("state-1", error.request_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_input_required_loop_falls_back_to_the_error_for_unhandled_request_kinds
|
||||||
|
transport = mock
|
||||||
|
input_required_response = {
|
||||||
|
"result" => {
|
||||||
|
"resultType" => "input_required",
|
||||||
|
"inputRequests" => { "llm" => { "method" => "sampling/createMessage", "params" => {} } },
|
||||||
|
"requestState" => "state-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
transport.expects(:send_request).returns(input_required_response).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
client.on_elicitation { |_params| {} }
|
||||||
|
|
||||||
|
error = assert_raises(Client::InputRequiredError) { client.call_tool(name: "my_tool") }
|
||||||
|
|
||||||
|
assert_includes(error.message, "sampling/createMessage")
|
||||||
|
assert_equal("state-1", error.request_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_input_required_loop_runs_on_a_transport_without_server_request_support
|
||||||
|
# The modern lifecycle forbids server-to-client requests, so an `input_required` result is the only
|
||||||
|
# way a server can ask. A transport with no `on_server_request` must still drive the loop.
|
||||||
|
transport = mock
|
||||||
|
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
|
||||||
|
input_required_response = {
|
||||||
|
"result" => {
|
||||||
|
"resultType" => "input_required",
|
||||||
|
"inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "Region?" } } },
|
||||||
|
"requestState" => "state-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
final_response = { "result" => { "content" => [{ "text" => "done" }] } }
|
||||||
|
transport.expects(:send_request).twice.returns(input_required_response, final_response)
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
client.on_elicitation { |_params| { action: "accept", content: { value: "us-east-1" } } }
|
||||||
|
|
||||||
|
response = client.call_tool(name: "my_tool")
|
||||||
|
|
||||||
|
assert_equal("done", response.dig("result", "content", 0, "text"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_input_required_loop_uses_a_roots_handler_registered_with_on_roots
|
||||||
|
transport = mock
|
||||||
|
input_required_response = {
|
||||||
|
"result" => {
|
||||||
|
"resultType" => "input_required",
|
||||||
|
"inputRequests" => { "r" => { "method" => "roots/list" } },
|
||||||
|
"requestState" => "state-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
final_response = { "result" => { "content" => [{ "text" => "done" }] } }
|
||||||
|
transport.expects(:on_server_request).with("roots/list")
|
||||||
|
transport.expects(:send_request).twice.returns(input_required_response, final_response)
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
client.on_roots { |_params| { roots: [{ uri: "file:///project", name: "Project" }] } }
|
||||||
|
|
||||||
|
response = client.call_tool(name: "my_tool")
|
||||||
|
|
||||||
|
assert_equal("done", response.dig("result", "content", 0, "text"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_call_tool_sends_manual_retry_fields
|
||||||
|
transport = mock
|
||||||
|
transport.expects(:send_request).with do |args|
|
||||||
|
params = args[:request][:params]
|
||||||
|
params[:inputResponses] == { "region" => { action: "accept" } } && params[:requestState] == "state-1" && params[:name] == "my_tool"
|
||||||
|
end.returns({ "result" => { "content" => [] } }).once
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
client.call_tool(name: "my_tool", input_responses: { "region" => { action: "accept" } }, request_state: "state-1")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_get_prompt_drives_the_input_required_loop
|
||||||
|
transport = mock
|
||||||
|
input_required_response = {
|
||||||
|
"result" => {
|
||||||
|
"resultType" => "input_required",
|
||||||
|
"inputRequests" => { "q" => { "method" => "elicitation/create", "params" => { "message" => "?" } } },
|
||||||
|
"requestState" => "prompt-state",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
final_response = { "result" => { "messages" => [] } }
|
||||||
|
sent_requests = []
|
||||||
|
transport.expects(:send_request).twice.with do |args|
|
||||||
|
sent_requests << args[:request]
|
||||||
|
true
|
||||||
|
end.returns(input_required_response, final_response)
|
||||||
|
|
||||||
|
client = Client.new(transport: transport)
|
||||||
|
client.on_elicitation { |_params| { action: "accept", content: {} } }
|
||||||
|
|
||||||
|
result = client.get_prompt(name: "my_prompt")
|
||||||
|
|
||||||
|
assert_equal({ "messages" => [] }, result)
|
||||||
|
assert_equal("prompt-state", sent_requests[1].dig(:params, :requestState))
|
||||||
|
end
|
||||||
|
|
||||||
def test_call_tool_raises_when_no_name_or_tool
|
def test_call_tool_raises_when_no_name_or_tool
|
||||||
client = Client.new(transport: mock)
|
client = Client.new(transport: mock)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue