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:
Koichi ITO 2026-08-11 01:16:31 +09:00
commit 416b7e8a6e
5 changed files with 449 additions and 26 deletions

View file

@ -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

View 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

View file

@ -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]}"

View file

@ -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

View file

@ -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)