Bound server-to-client requests with a timeout
## Motivation and Context The specification asks implementations to establish timeouts for all sent requests, "to prevent hung connections and resource exhaustion", to cancel the request once the deadline passes, and to let SDK users configure the deadline per request. This SDK did none of the three: `send_request` blocked on `Queue#pop` with no deadline, so a client that opens a session, triggers a handler that elicits input, and then simply never answers holds that worker thread for good. A handful of such requests exhausts a Rack server's thread pool. The existing escape hatches are weak: the idle reaper waits 1800 seconds by default and refreshes on any traffic for the session, so a client that keeps sending other requests never trips it, and only the GET stream has a keepalive to notice a vanished peer. `StreamableHTTPTransport` now waits `server_to_client_request_timeout:` seconds (`DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT`, 600) for a response, and `timeout:` overrides it per request through `ServerContext` and `ServerSession` down to the transport. The option spells out its direction because the same constructor already takes `max_request_bytes:` for the requests arriving the other way. Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation prompt, or the client's own model producing a sample. The Python SDK leaves the deadline unset and bounds nothing by default. On expiry the transport sends `notifications/cancelled` for the request it stopped waiting on, so a late answer does not act on something the server abandoned, and raises the new `MCP::Server::RequestTimeoutError`. Both reference SDKs send the same courtesy cancel on timeout, and it is what the revision governing this path asks for: 2025-11-25 lets either side cancel and tells the sender to "issue a cancellation notification for that request and stop waiting". Only that revision and earlier reach the wait, since the modern lifecycle forbids these requests and its sessionless requests never register the session `send_request` looks up. Uncaught in a handler, the error answers the client's originating request with `-32001` rather than a generic internal error. The code is not spec-allocated, but it sits in the implementation-defined server range and is what the Python SDK reports for this condition, so a peer that recognizes it there reads the same meaning here. The wait itself moves from `Queue` to a small `MCP::Server::PendingResponse`: `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later and this gem supports 2.7, so the wait is a `ConditionVariable` with a monotonic deadline. It keeps the `push`/`pop` names, leaving the three resolving call sites (a client response, a cancellation, session teardown) unchanged, and keeps first-writer-wins so a cancellation racing a real response still cannot overwrite it. The expiry block runs after the lock is released, since it cancels the request and would otherwise re-enter the same non-reentrant mutex. stdio is untouched and ignores `timeout:`, which the README now states. Its `send_request` waits inside a `$stdin.gets` loop rather than on a queue, and the process model differs enough (the server owns the client process, whose exit surfaces as EOF) that it deserves its own change; `forward_to_transport` already filters `timeout:` out for transports that do not declare it, so custom transports and stdio keep their existing signatures. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover a request timing out with the transport default, `timeout:` overriding it per request, the error code it reports, expiry reaching the peer as `notifications/cancelled`, a response arriving before the deadline still returning normally, and the constructor rejecting a non-positive `server_to_client_request_timeout`. `test/mcp/server_session_request_timeout_test.rb` covers the deadline reaching the transport from all five `ServerSession` entry points, and being dropped for transports that never declared it. `test/mcp/server_context_test.rb` covers the same through `ServerContext`, including an omitted `timeout:` not being forwarded at all. The pre-existing `send_request` tests (the response path, cross-session rejection, cancellation, the cancel/response race, client errors, and session teardown) pass unchanged against the new wait primitive. `bundle exec rake` is green, and both conformance legs pass their baseline check. `PendingResponse` was exercised directly on Ruby 2.7.0, the minimum this gem supports and the reason the wait avoids `Queue#pop(timeout:)`: the deadline fires on time, a pushed value is delivered, the first writer wins, and the expiry block can resolve the same object without deadlocking. ## Breaking Changes Server-to-client requests that previously waited forever now fail after ten minutes by default. This is the incompatible-but-required kind of change VERSIONING.md admits into a minor release: the unbounded wait deviates from the specification and is the resource-exhaustion path it exists to prevent. Handlers that legitimately wait longer pass a larger `timeout:`; the release notes and CHANGELOG entry need to call this out and point at that knob.
This commit is contained in:
parent
b09708365b
commit
416b7e8a6e
9 changed files with 449 additions and 26 deletions
16
README.md
16
README.md
|
|
@ -1409,6 +1409,22 @@ Roots define the boundaries of where a server can operate, providing a list of d
|
|||
> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding
|
||||
> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning.
|
||||
|
||||
**Timeouts:** every server-to-client request is bounded, so a client that never answers cannot park the handler's thread indefinitely.
|
||||
`MCP::Server::Transports::StreamableHTTPTransport` waits `server_to_client_request_timeout:` seconds (600 by default), then tells
|
||||
the client the request was abandoned and raises `MCP::Server::RequestTimeoutError`. Individual calls override the deadline with `timeout:`,
|
||||
which is the knob to reach for when a prompt legitimately waits on a person:
|
||||
|
||||
```ruby
|
||||
server_context.create_form_elicitation(
|
||||
message: "Approve this deployment?",
|
||||
requested_schema: { type: "object", properties: { approved: { type: "boolean" } } },
|
||||
timeout: 3600, # This one waits up to an hour.
|
||||
)
|
||||
```
|
||||
|
||||
`StdioTransport` is not bounded and ignores `timeout:`: it owns the client process, so a client that stops answering
|
||||
surfaces as end-of-file rather than as a wait that never ends.
|
||||
|
||||
**Using Roots in Tools:**
|
||||
|
||||
Tools that accept a `server_context:` parameter can call `list_roots` on it.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ require_relative "server_context"
|
|||
require_relative "server/capabilities"
|
||||
require_relative "server/input_required_result"
|
||||
require_relative "server/pagination"
|
||||
require_relative "server/pending_response"
|
||||
require_relative "server/request_state_security"
|
||||
require_relative "server/transports"
|
||||
|
||||
|
|
@ -125,6 +126,27 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout.
|
||||
# The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources,
|
||||
# and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this.
|
||||
# These requests exist only on connections speaking 2025-11-25 or earlier, since the modern lifecycle forbids them.
|
||||
#
|
||||
# Left uncaught in a handler, this answers the client's originating request with `-32001` rather than a generic
|
||||
# internal error, so the peer that failed to answer can tell a timeout apart from a server fault. The code is not
|
||||
# spec-allocated: it sits in the implementation-defined server range and is the value the Python SDK reports for
|
||||
# this condition, so a client that already recognizes it there reads the same meaning here.
|
||||
#
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
|
||||
class RequestTimeoutError < RequestHandlerError
|
||||
attr_reader :request_id, :timeout
|
||||
|
||||
def initialize(message, request_id:, timeout:)
|
||||
super(message, nil, error_type: :request_timeout, error_code: -32001)
|
||||
@request_id = request_id
|
||||
@timeout = timeout
|
||||
end
|
||||
end
|
||||
|
||||
class MethodAlreadyDefinedError < StandardError
|
||||
attr_reader :method_name
|
||||
|
||||
|
|
|
|||
62
lib/mcp/server/pending_response.rb
Normal file
62
lib/mcp/server/pending_response.rb
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module MCP
|
||||
class Server
|
||||
# A one-shot, timeout-aware handoff between the thread awaiting a server-to-client response
|
||||
# and whichever thread resolves it (the client's response, a cancellation, or session teardown).
|
||||
#
|
||||
# `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later, and this gem supports 2.7,
|
||||
# so the wait is expressed with a `ConditionVariable`. The `push`/`pop` names mirror the `Queue`
|
||||
# this replaces, keeping the resolving call sites unchanged.
|
||||
#
|
||||
# First writer wins: a second `push` is ignored, so a cancellation that races a real response
|
||||
# cannot overwrite it. `pop` returns the pushed value, or the `on_timeout` result when
|
||||
# the deadline passes with nothing pushed.
|
||||
class PendingResponse
|
||||
def initialize
|
||||
@mutex = Mutex.new
|
||||
@condition = ConditionVariable.new
|
||||
@delivered = false
|
||||
@value = nil
|
||||
end
|
||||
|
||||
# Resolves the wait. Ignored when a value was already delivered.
|
||||
def push(value)
|
||||
@mutex.synchronize do
|
||||
next if @delivered
|
||||
|
||||
@delivered = true
|
||||
@value = value
|
||||
@condition.broadcast
|
||||
end
|
||||
end
|
||||
|
||||
# Blocks until a value is pushed or `timeout` seconds elapse, and yields to the caller
|
||||
# on expiry so it can decide what a timeout means. `ConditionVariable#wait` can return spuriously,
|
||||
# so the deadline is re-checked against the monotonic clock.
|
||||
#
|
||||
# The expiry block runs after the lock is released: it typically cancels the request,
|
||||
# which resolves this same object, and Ruby's `Mutex` is not reentrant.
|
||||
def pop(timeout:)
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
||||
expired = false
|
||||
|
||||
value = @mutex.synchronize do
|
||||
until @delivered
|
||||
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
if remaining <= 0
|
||||
expired = true
|
||||
break
|
||||
end
|
||||
|
||||
@condition.wait(@mutex, remaining)
|
||||
end
|
||||
|
||||
@value
|
||||
end
|
||||
|
||||
expired ? yield : value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -52,6 +52,21 @@ module MCP
|
|||
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
||||
private_constant :UNSET_IDLE_TIMEOUT
|
||||
|
||||
# Default deadline in seconds for a server-to-client request (sampling, elicitation, `roots/list`, `ping`).
|
||||
# The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust
|
||||
# the sender's resources; without one, a client that opens a session and simply never replies parks
|
||||
# a worker thread for good.
|
||||
#
|
||||
# Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds
|
||||
# for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default
|
||||
# is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation
|
||||
# prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds
|
||||
# nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler
|
||||
# that legitimately waits longer passes `timeout:`.
|
||||
#
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
|
||||
DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
|
||||
|
||||
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
|
||||
# body into memory and parses it, so without a cap a single unauthenticated POST
|
||||
# can allocate gigabytes and OOM the worker. 4 MiB comfortably
|
||||
|
|
@ -114,6 +129,9 @@ module MCP
|
|||
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
|
||||
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
|
||||
# when an upstream proxy already keeps the stream alive.
|
||||
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
|
||||
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
|
||||
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
|
||||
def initialize(
|
||||
server,
|
||||
stateless: false,
|
||||
|
|
@ -126,7 +144,8 @@ module MCP
|
|||
session_request_validator: nil,
|
||||
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
||||
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
||||
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL
|
||||
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
||||
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
|
||||
)
|
||||
super(server)
|
||||
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
|
||||
|
|
@ -193,6 +212,12 @@ module MCP
|
|||
|
||||
@listen_keepalive_interval = listen_keepalive_interval
|
||||
|
||||
unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
|
||||
raise ArgumentError, "server_to_client_request_timeout must be a positive number"
|
||||
end
|
||||
|
||||
@server_to_client_request_timeout = server_to_client_request_timeout
|
||||
|
||||
start_reaper_thread if @session_idle_timeout
|
||||
end
|
||||
|
||||
|
|
@ -442,14 +467,18 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
|
||||
# blocks until the client responds.
|
||||
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until
|
||||
# the client responds.
|
||||
#
|
||||
# Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
|
||||
# sends the request via SSE stream, then blocks on `queue.pop`.
|
||||
# When the client POSTs a response, `handle_response` matches it by `request_id`
|
||||
# and pushes the result onto the queue, unblocking this thread.
|
||||
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
|
||||
# Uses a `PendingResponse` for cross-thread synchronization: this method registers one,
|
||||
# sends the request via SSE stream, then waits on it. When the client POSTs a response,
|
||||
# `handle_response` matches it by `request_id` and resolves the pending response,
|
||||
# unblocking this thread. A cancellation and session teardown resolve it the same way.
|
||||
#
|
||||
# The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`),
|
||||
# so a client that never answers cannot park the calling thread for good. On expiry the peer is
|
||||
# sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised.
|
||||
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
|
||||
if @stateless
|
||||
raise "Stateless mode does not support server-to-client requests."
|
||||
end
|
||||
|
|
@ -463,7 +492,8 @@ module MCP
|
|||
end
|
||||
|
||||
request_id = generate_request_id
|
||||
queue = Queue.new
|
||||
pending_response = PendingResponse.new
|
||||
wait_timeout = timeout || @server_to_client_request_timeout
|
||||
cancel_hook = nil
|
||||
|
||||
request = { jsonrpc: "2.0", id: request_id, method: method }
|
||||
|
|
@ -476,7 +506,7 @@ module MCP
|
|||
raise "Session not found: #{session_id}."
|
||||
end
|
||||
|
||||
@pending_responses[request_id] = { queue: queue, session_id: session_id }
|
||||
@pending_responses[request_id] = { queue: pending_response, session_id: session_id }
|
||||
|
||||
active_stream(session, related_request_id: related_request_id)
|
||||
end
|
||||
|
|
@ -512,7 +542,27 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
response = queue.pop
|
||||
response = pending_response.pop(timeout: wait_timeout) do
|
||||
# Expiry cancels as well as stops waiting, so a client that answers late does not act on
|
||||
# a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here:
|
||||
# the modern lifecycle forbids server-to-client requests outright, and its sessionless requests
|
||||
# never register the session this method looks up. Those revisions ask the sender to "issue
|
||||
# a cancellation notification for that request and stop waiting", letting either side send one.
|
||||
# (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown
|
||||
# governs the era that has no such requests to cancel.) Both reference SDKs send this same
|
||||
# courtesy cancel on timeout.
|
||||
server_session&.send_peer_cancellation(
|
||||
nested_request_id: request_id,
|
||||
related_request_id: related_request_id,
|
||||
reason: "Timed out after #{wait_timeout} seconds",
|
||||
)
|
||||
|
||||
raise RequestTimeoutError.new(
|
||||
"#{method} request timed out after #{wait_timeout} seconds",
|
||||
request_id: request_id,
|
||||
timeout: wait_timeout,
|
||||
)
|
||||
end
|
||||
|
||||
if response.is_a?(Hash) && response.key?(:error)
|
||||
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
|
||||
|
|
|
|||
|
|
@ -138,9 +138,9 @@ module MCP
|
|||
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
|
||||
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
||||
# server configuration, or environment variables instead.
|
||||
def list_roots
|
||||
def list_roots(timeout: nil)
|
||||
if @notification_target.respond_to?(:list_roots)
|
||||
@notification_target.list_roots(related_request_id: @related_request_id)
|
||||
@notification_target.list_roots(related_request_id: @related_request_id, **timeout_kwarg(timeout))
|
||||
else
|
||||
raise NoMethodError, "undefined method 'list_roots' for #{self}"
|
||||
end
|
||||
|
|
@ -160,9 +160,9 @@ module MCP
|
|||
# end
|
||||
#
|
||||
# @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
|
||||
def ping
|
||||
def ping(timeout: nil)
|
||||
if @notification_target.respond_to?(:ping)
|
||||
@notification_target.ping(related_request_id: @related_request_id)
|
||||
@notification_target.ping(related_request_id: @related_request_id, **timeout_kwarg(timeout))
|
||||
else
|
||||
raise NoMethodError, "undefined method 'ping' for #{self}"
|
||||
end
|
||||
|
|
@ -245,5 +245,15 @@ module MCP
|
|||
def respond_to_missing?(name, include_private = false)
|
||||
@context.respond_to?(name) || super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# An omitted `timeout:` is not forwarded at all, so the delegated call keeps the shape it had
|
||||
# before per-request timeouts existed. A notification target that predates the keyword
|
||||
# (a custom object standing in for a session) keeps working until a caller actually asks for a timeout,
|
||||
# and the transport applies its own default in that case.
|
||||
def timeout_kwarg(timeout)
|
||||
timeout.nil? ? {} : { timeout: timeout }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ module MCP
|
|||
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
|
||||
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
||||
# server configuration, or environment variables instead.
|
||||
def list_roots(related_request_id: nil)
|
||||
def list_roots(related_request_id: nil, timeout: nil)
|
||||
@server.send(:warn_if_deprecated_protocol_feature, :roots, session: self, uplevel: 2)
|
||||
warn_unassociated_request(__method__, related_request_id)
|
||||
|
||||
|
|
@ -139,12 +139,12 @@ module MCP
|
|||
raise "Client does not support roots."
|
||||
end
|
||||
|
||||
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
|
||||
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout)
|
||||
end
|
||||
|
||||
# Sends a `ping` request scoped to this session.
|
||||
def ping(related_request_id: nil)
|
||||
result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id)
|
||||
def ping(related_request_id: nil, timeout: nil)
|
||||
result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout)
|
||||
raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
|
||||
|
||||
result
|
||||
|
|
@ -158,12 +158,17 @@ module MCP
|
|||
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
|
||||
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
|
||||
# APIs instead.
|
||||
def create_sampling_message(related_request_id: nil, **kwargs)
|
||||
def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs)
|
||||
@server.send(:warn_if_deprecated_protocol_feature, :sampling, session: self, uplevel: 2)
|
||||
warn_unassociated_request(__method__, related_request_id)
|
||||
|
||||
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
||||
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
|
||||
send_to_transport_request(
|
||||
Methods::SAMPLING_CREATE_MESSAGE,
|
||||
params,
|
||||
related_request_id: related_request_id,
|
||||
timeout: timeout,
|
||||
)
|
||||
end
|
||||
|
||||
# Sends an `elicitation/create` request (form mode) scoped to this session.
|
||||
|
|
@ -171,7 +176,7 @@ module MCP
|
|||
# Per SEP-2260, the request must be associated with an originating client
|
||||
# request; prefer `server_context.create_form_elicitation` inside a handler,
|
||||
# which stamps the association automatically.
|
||||
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
|
||||
def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil)
|
||||
warn_unassociated_request(__method__, related_request_id)
|
||||
|
||||
unless client_capabilities&.dig(:elicitation)
|
||||
|
|
@ -180,7 +185,12 @@ module MCP
|
|||
end
|
||||
|
||||
params = { mode: "form", message: message, requestedSchema: requested_schema }
|
||||
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
|
||||
send_to_transport_request(
|
||||
Methods::ELICITATION_CREATE,
|
||||
params,
|
||||
related_request_id: related_request_id,
|
||||
timeout: timeout,
|
||||
)
|
||||
end
|
||||
|
||||
# Sends an `elicitation/create` request (URL mode) scoped to this session.
|
||||
|
|
@ -188,7 +198,7 @@ module MCP
|
|||
# Per SEP-2260, the request must be associated with an originating client
|
||||
# request; prefer `server_context.create_url_elicitation` inside a handler,
|
||||
# which stamps the association automatically.
|
||||
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
|
||||
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil)
|
||||
warn_unassociated_request(__method__, related_request_id)
|
||||
|
||||
unless client_capabilities&.dig(:elicitation, :url)
|
||||
|
|
@ -197,7 +207,12 @@ module MCP
|
|||
end
|
||||
|
||||
params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
|
||||
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
|
||||
send_to_transport_request(
|
||||
Methods::ELICITATION_CREATE,
|
||||
params,
|
||||
related_request_id: related_request_id,
|
||||
timeout: timeout,
|
||||
)
|
||||
end
|
||||
|
||||
# Sends `notifications/cancelled` to the peer for a nested server-to-client request
|
||||
|
|
@ -296,7 +311,7 @@ module MCP
|
|||
# `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing.
|
||||
# When `related_request_id` names an in-flight request, its `Cancellation` token is looked up
|
||||
# so that cancelling the parent also cancels this nested server-to-client request.
|
||||
def send_to_transport_request(method, params, related_request_id: nil)
|
||||
def send_to_transport_request(method, params, related_request_id: nil, timeout: nil)
|
||||
parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil
|
||||
|
||||
kwargs = {
|
||||
|
|
@ -304,6 +319,7 @@ module MCP
|
|||
related_request_id: related_request_id,
|
||||
parent_cancellation: parent_cancellation,
|
||||
server_session: self,
|
||||
timeout: timeout,
|
||||
}.compact
|
||||
|
||||
forward_to_transport(@transport.method(:send_request), method, params, kwargs)
|
||||
|
|
|
|||
|
|
@ -3041,6 +3041,89 @@ module MCP
|
|||
assert_equal "Hi there", result[:content][:text]
|
||||
end
|
||||
|
||||
test "send_request times out when the client never answers" do
|
||||
# The spec asks implementations to bound every sent request so an unanswered one cannot
|
||||
# exhaust the sender's resources.
|
||||
transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05)
|
||||
session_id = open_sse_session(transport)
|
||||
|
||||
error = assert_raises(MCP::Server::RequestTimeoutError) do
|
||||
transport.send_request("sampling/createMessage", { messages: [] }, session_id: session_id)
|
||||
end
|
||||
|
||||
assert_equal 0.05, error.timeout
|
||||
assert_includes error.message, "sampling/createMessage"
|
||||
|
||||
# Left uncaught in a handler, the originating request is answered with a timeout code rather than
|
||||
# a generic internal error, so the peer that failed to answer can tell the two apart.
|
||||
assert_equal(-32001, error.error_code)
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "send_request timeout is overridable per request" do
|
||||
transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 60)
|
||||
session_id = open_sse_session(transport)
|
||||
|
||||
error = assert_raises(MCP::Server::RequestTimeoutError) do
|
||||
transport.send_request("ping", nil, session_id: session_id, timeout: 0.05)
|
||||
end
|
||||
|
||||
assert_equal 0.05, error.timeout
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "send_request tells the peer it gave up when the timeout fires" do
|
||||
# These requests only happen on 2025-11-25 and earlier connections, whose spec asks the sender
|
||||
# to issue a cancellation notification for the request it stops waiting on.
|
||||
transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0.05)
|
||||
io = StringIO.new
|
||||
session_id = open_sse_session(transport, io: io)
|
||||
session = transport.instance_variable_get(:@sessions)[session_id][:server_session]
|
||||
|
||||
assert_raises(MCP::Server::RequestTimeoutError) do
|
||||
transport.send_request("ping", nil, session_id: session_id, server_session: session)
|
||||
end
|
||||
|
||||
events = io.string.lines.grep(/\Adata: /).map { |line| JSON.parse(line.sub("data: ", "")) }
|
||||
cancellation = events.find { |event| event["method"] == "notifications/cancelled" }
|
||||
refute_nil cancellation, "expected the peer to be told the request was abandoned"
|
||||
assert_match(/Timed out after 0.05 seconds/, cancellation.dig("params", "reason"))
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "send_request returns the response when it arrives before the timeout" do
|
||||
transport = StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 30)
|
||||
io = StringIO.new
|
||||
session_id = open_sse_session(transport, io: io)
|
||||
|
||||
result_queue = Queue.new
|
||||
Thread.new do
|
||||
result_queue.push(transport.send_request("ping", nil, session_id: session_id))
|
||||
end
|
||||
wait_until { io.string.include?("ping") }
|
||||
|
||||
request_id = JSON.parse(io.string.lines.grep(/\Adata: /).first.sub("data: ", ""))["id"]
|
||||
transport.handle_request(create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
|
||||
{ jsonrpc: "2.0", id: request_id, result: { ok: true } }.to_json,
|
||||
))
|
||||
|
||||
assert_equal({ ok: true }, result_queue.pop)
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "server_to_client_request_timeout rejects a non-positive value" do
|
||||
assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: 0) }
|
||||
assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: -1) }
|
||||
assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, server_to_client_request_timeout: nil) }
|
||||
end
|
||||
|
||||
test "send_request with related_request_id rides the originating POST stream, not the GET stream" do
|
||||
# Per SEP-2260, server-to-client requests associated with a client request are delivered on
|
||||
# that request's POST SSE response stream rather than the standalone GET stream.
|
||||
|
|
@ -6028,6 +6111,36 @@ module MCP
|
|||
@transport.handle_request(init_request)[1]["mcp-session-id"]
|
||||
end
|
||||
|
||||
# Polls until the block is truthy; the work being awaited happens on another thread.
|
||||
def wait_until(timeout: 5)
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
||||
until yield
|
||||
flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
||||
sleep(0.01)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes a session on `transport` and attaches a GET SSE stream to it, so
|
||||
# server-to-client requests have somewhere to go. Returns the session id.
|
||||
def open_sse_session(transport, io: StringIO.new)
|
||||
session_id = transport.handle_request(create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
|
||||
))[1]["mcp-session-id"]
|
||||
|
||||
get_response = transport.handle_request(create_rack_request(
|
||||
"GET",
|
||||
"/",
|
||||
{ "HTTP_MCP_SESSION_ID" => session_id },
|
||||
))
|
||||
get_response[2].call(io) if get_response[2].is_a?(Proc)
|
||||
wait_until { transport.instance_variable_get(:@sessions).dig(session_id, :get_sse_stream) }
|
||||
|
||||
session_id
|
||||
end
|
||||
|
||||
# Installs a stream that records, on every write, whether `@mutex` was held at that moment.
|
||||
# `mutex.try_lock` fails while the transport still holds the lock, so a nonzero `writes[:under_mutex]`
|
||||
# means a blocking write is being performed under the lock.
|
||||
|
|
|
|||
|
|
@ -162,6 +162,41 @@ module MCP
|
|||
assert_equal({}, result)
|
||||
end
|
||||
|
||||
test "ServerContext forwards a per-request timeout to the notification target" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:ping).with(related_request_id: nil, timeout: 12).returns({})
|
||||
notification_target.expects(:list_roots).with(related_request_id: nil, timeout: 34).returns({ roots: [] })
|
||||
notification_target.expects(:create_form_elicitation).with(
|
||||
message: "Approve?",
|
||||
requested_schema: { type: "object", properties: {} },
|
||||
related_request_id: nil,
|
||||
timeout: 56,
|
||||
).returns({ action: "accept" })
|
||||
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target)
|
||||
|
||||
server_context.ping(timeout: 12)
|
||||
server_context.list_roots(timeout: 34)
|
||||
server_context.create_form_elicitation(
|
||||
message: "Approve?",
|
||||
requested_schema: { type: "object", properties: {} },
|
||||
timeout: 56,
|
||||
)
|
||||
end
|
||||
|
||||
test "ServerContext omits timeout entirely when the caller does not ask for one" do
|
||||
# A notification target predating the keyword (a custom object standing in for a session) keeps working,
|
||||
# and the transport applies its own default.
|
||||
notification_target = mock
|
||||
notification_target.expects(:ping).with(related_request_id: nil).returns({})
|
||||
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
server_context = ServerContext.new(mock, progress: progress, notification_target: notification_target)
|
||||
|
||||
server_context.ping
|
||||
end
|
||||
|
||||
test "ServerContext#ping raises NoMethodError when notification_target does not respond" do
|
||||
notification_target = mock
|
||||
context = mock
|
||||
|
|
|
|||
99
test/mcp/server_session_request_timeout_test.rb
Normal file
99
test/mcp/server_session_request_timeout_test.rb
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module MCP
|
||||
# Every server-to-client request accepts a per-request deadline, which the spec asks SDKs to offer
|
||||
# ("SDKs and other middleware SHOULD allow these timeouts to be configured on a per-request basis").
|
||||
# `ServerSession` only carries the value; the transport is what waits on it.
|
||||
class ServerSessionRequestTimeoutTest < ActiveSupport::TestCase
|
||||
# Declares `timeout:`, as the bundled Streamable HTTP transport does.
|
||||
class TimeoutAwareTransport < Transport
|
||||
attr_reader :requests
|
||||
|
||||
def initialize(server)
|
||||
super
|
||||
@requests = []
|
||||
end
|
||||
|
||||
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
|
||||
@requests << { method: method, timeout: timeout }
|
||||
{}
|
||||
end
|
||||
|
||||
def send_response(response); end
|
||||
def send_notification(method, params = nil, **_kwargs); end
|
||||
def open; end
|
||||
def close; end
|
||||
def handle_request(request); end
|
||||
end
|
||||
|
||||
# Implements only the abstract `send_request(method, params = nil)` contract, as `StdioTransport` does.
|
||||
class TimeoutUnawareTransport < TimeoutAwareTransport
|
||||
def send_request(method, params = nil)
|
||||
@requests << { method: method, timeout: :not_passed }
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
@server = Server.new(name: "test_server", version: "1.0.0")
|
||||
@transport = TimeoutAwareTransport.new(@server)
|
||||
@session = ServerSession.new(server: @server, transport: @transport, session_id: "session-1")
|
||||
@session.instance_variable_set(
|
||||
:@client_capabilities,
|
||||
{ roots: {}, sampling: {}, elicitation: { url: {} } },
|
||||
)
|
||||
end
|
||||
|
||||
test "every server-to-client request carries its timeout down to the transport" do
|
||||
@session.ping(related_request_id: "req-1", timeout: 11)
|
||||
@session.list_roots(related_request_id: "req-1", timeout: 22)
|
||||
@session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
timeout: 33,
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
@session.create_form_elicitation(
|
||||
message: "Approve?",
|
||||
requested_schema: { type: "object", properties: {} },
|
||||
related_request_id: "req-1",
|
||||
timeout: 44,
|
||||
)
|
||||
@session.create_url_elicitation(
|
||||
message: "Approve?",
|
||||
url: "https://example.com/approve",
|
||||
elicitation_id: "elicit-1",
|
||||
related_request_id: "req-1",
|
||||
timeout: 55,
|
||||
)
|
||||
|
||||
assert_equal(
|
||||
[
|
||||
{ method: "ping", timeout: 11 },
|
||||
{ method: "roots/list", timeout: 22 },
|
||||
{ method: "sampling/createMessage", timeout: 33 },
|
||||
{ method: "elicitation/create", timeout: 44 },
|
||||
{ method: "elicitation/create", timeout: 55 },
|
||||
],
|
||||
@transport.requests,
|
||||
)
|
||||
end
|
||||
|
||||
test "an omitted timeout leaves the transport to apply its own default" do
|
||||
@session.ping(related_request_id: "req-1")
|
||||
|
||||
assert_equal [{ method: "ping", timeout: nil }], @transport.requests
|
||||
end
|
||||
|
||||
test "a transport that does not declare timeout keeps its existing signature" do
|
||||
transport = TimeoutUnawareTransport.new(@server)
|
||||
session = ServerSession.new(server: @server, transport: transport, session_id: "session-1")
|
||||
|
||||
session.ping(related_request_id: "req-1", timeout: 11)
|
||||
|
||||
assert_equal [{ method: "ping", timeout: :not_passed }], transport.requests
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue