Skip to content

Commit edb5f11

Browse files
committed
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.
1 parent f0c9665 commit edb5f11

9 files changed

Lines changed: 449 additions & 26 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,6 +1402,22 @@ Roots define the boundaries of where a server can operate, providing a list of d
14021402
> automatically and routes the request onto the originating POST stream on the Streamable HTTP transport. Calling the corresponding
14031403
> `ServerSession` methods without `related_request_id:` still works but emits a deprecation warning.
14041404
1405+
**Timeouts:** every server-to-client request is bounded, so a client that never answers cannot park the handler's thread indefinitely.
1406+
`MCP::Server::Transports::StreamableHTTPTransport` waits `server_to_client_request_timeout:` seconds (600 by default), then tells
1407+
the client the request was abandoned and raises `MCP::Server::RequestTimeoutError`. Individual calls override the deadline with `timeout:`,
1408+
which is the knob to reach for when a prompt legitimately waits on a person:
1409+
1410+
```ruby
1411+
server_context.create_form_elicitation(
1412+
message: "Approve this deployment?",
1413+
requested_schema: { type: "object", properties: { approved: { type: "boolean" } } },
1414+
timeout: 3600, # This one waits up to an hour.
1415+
)
1416+
```
1417+
1418+
`StdioTransport` is not bounded and ignores `timeout:`: it owns the client process, so a client that stops answering
1419+
surfaces as end-of-file rather than as a wait that never ends.
1420+
14051421
**Using Roots in Tools:**
14061422

14071423
Tools that accept a `server_context:` parameter can call `list_roots` on it.

lib/mcp/server.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
require_relative "server/capabilities"
1313
require_relative "server/input_required_result"
1414
require_relative "server/pagination"
15+
require_relative "server/pending_response"
1516
require_relative "server/request_state_security"
1617
require_relative "server/transports"
1718

@@ -125,6 +126,27 @@ def initialize(uri, request = nil)
125126
end
126127
end
127128

129+
# Raised when a server-to-client request (sampling, elicitation, `roots/list`, `ping`) goes unanswered past its timeout.
130+
# The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust the sender's resources,
131+
# and to cancel the request on expiry; the transport sends `notifications/cancelled` before raising this.
132+
# These requests exist only on connections speaking 2025-11-25 or earlier, since the modern lifecycle forbids them.
133+
#
134+
# Left uncaught in a handler, this answers the client's originating request with `-32001` rather than a generic
135+
# internal error, so the peer that failed to answer can tell a timeout apart from a server fault. The code is not
136+
# spec-allocated: it sits in the implementation-defined server range and is the value the Python SDK reports for
137+
# this condition, so a client that already recognizes it there reads the same meaning here.
138+
#
139+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
140+
class RequestTimeoutError < RequestHandlerError
141+
attr_reader :request_id, :timeout
142+
143+
def initialize(message, request_id:, timeout:)
144+
super(message, nil, error_type: :request_timeout, error_code: -32001)
145+
@request_id = request_id
146+
@timeout = timeout
147+
end
148+
end
149+
128150
class MethodAlreadyDefinedError < StandardError
129151
attr_reader :method_name
130152

lib/mcp/server/pending_response.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
module MCP
4+
class Server
5+
# A one-shot, timeout-aware handoff between the thread awaiting a server-to-client response
6+
# and whichever thread resolves it (the client's response, a cancellation, or session teardown).
7+
#
8+
# `Queue#pop` only accepts a `timeout:` on Ruby 3.2 and later, and this gem supports 2.7,
9+
# so the wait is expressed with a `ConditionVariable`. The `push`/`pop` names mirror the `Queue`
10+
# this replaces, keeping the resolving call sites unchanged.
11+
#
12+
# First writer wins: a second `push` is ignored, so a cancellation that races a real response
13+
# cannot overwrite it. `pop` returns the pushed value, or the `on_timeout` result when
14+
# the deadline passes with nothing pushed.
15+
class PendingResponse
16+
def initialize
17+
@mutex = Mutex.new
18+
@condition = ConditionVariable.new
19+
@delivered = false
20+
@value = nil
21+
end
22+
23+
# Resolves the wait. Ignored when a value was already delivered.
24+
def push(value)
25+
@mutex.synchronize do
26+
next if @delivered
27+
28+
@delivered = true
29+
@value = value
30+
@condition.broadcast
31+
end
32+
end
33+
34+
# Blocks until a value is pushed or `timeout` seconds elapse, and yields to the caller
35+
# on expiry so it can decide what a timeout means. `ConditionVariable#wait` can return spuriously,
36+
# so the deadline is re-checked against the monotonic clock.
37+
#
38+
# The expiry block runs after the lock is released: it typically cancels the request,
39+
# which resolves this same object, and Ruby's `Mutex` is not reentrant.
40+
def pop(timeout:)
41+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
42+
expired = false
43+
44+
value = @mutex.synchronize do
45+
until @delivered
46+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
47+
if remaining <= 0
48+
expired = true
49+
break
50+
end
51+
52+
@condition.wait(@mutex, remaining)
53+
end
54+
55+
@value
56+
end
57+
58+
expired ? yield : value
59+
end
60+
end
61+
end
62+
end

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ class InvalidJsonError < StandardError; end
3939
UNSET_IDLE_TIMEOUT = Object.new.freeze
4040
private_constant :UNSET_IDLE_TIMEOUT
4141

42+
# Default deadline in seconds for a server-to-client request (sampling, elicitation, `roots/list`, `ping`).
43+
# The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust
44+
# the sender's resources; without one, a client that opens a session and simply never replies parks
45+
# a worker thread for good.
46+
#
47+
# Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds
48+
# for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default
49+
# is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation
50+
# prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds
51+
# nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler
52+
# that legitimately waits longer passes `timeout:`.
53+
#
54+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
55+
DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
56+
4257
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
4358
# body into memory and parses it, so without a cap a single unauthenticated POST
4459
# can allocate gigabytes and OOM the worker. 4 MiB comfortably
@@ -87,6 +102,9 @@ class InvalidJsonError < StandardError; end
87102
# ownership is not enforced.
88103
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
89104
# requests are rejected with HTTP 413. Defaults to 4 MiB.
105+
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
106+
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
107+
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
90108
def initialize(
91109
server,
92110
stateless: false,
@@ -97,7 +115,8 @@ def initialize(
97115
allowed_hosts: nil,
98116
dns_rebinding_protection: true,
99117
session_request_validator: nil,
100-
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
118+
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
119+
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
101120
)
102121
super(server)
103122
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
@@ -147,6 +166,12 @@ def initialize(
147166

148167
@max_request_bytes = max_request_bytes
149168

169+
unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
170+
raise ArgumentError, "server_to_client_request_timeout must be a positive number"
171+
end
172+
173+
@server_to_client_request_timeout = server_to_client_request_timeout
174+
150175
start_reaper_thread if @session_idle_timeout
151176
end
152177

@@ -373,14 +398,18 @@ def close_streams(streams)
373398
end
374399
end
375400

376-
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
377-
# blocks until the client responds.
401+
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until
402+
# the client responds.
378403
#
379-
# Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
380-
# sends the request via SSE stream, then blocks on `queue.pop`.
381-
# When the client POSTs a response, `handle_response` matches it by `request_id`
382-
# and pushes the result onto the queue, unblocking this thread.
383-
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
404+
# Uses a `PendingResponse` for cross-thread synchronization: this method registers one,
405+
# sends the request via SSE stream, then waits on it. When the client POSTs a response,
406+
# `handle_response` matches it by `request_id` and resolves the pending response,
407+
# unblocking this thread. A cancellation and session teardown resolve it the same way.
408+
#
409+
# The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`),
410+
# so a client that never answers cannot park the calling thread for good. On expiry the peer is
411+
# sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised.
412+
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
384413
if @stateless
385414
raise "Stateless mode does not support server-to-client requests."
386415
end
@@ -394,7 +423,8 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil,
394423
end
395424

396425
request_id = generate_request_id
397-
queue = Queue.new
426+
pending_response = PendingResponse.new
427+
wait_timeout = timeout || @server_to_client_request_timeout
398428
cancel_hook = nil
399429

400430
request = { jsonrpc: "2.0", id: request_id, method: method }
@@ -407,7 +437,7 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil,
407437
raise "Session not found: #{session_id}."
408438
end
409439

410-
@pending_responses[request_id] = { queue: queue, session_id: session_id }
440+
@pending_responses[request_id] = { queue: pending_response, session_id: session_id }
411441

412442
active_stream(session, related_request_id: related_request_id)
413443
end
@@ -443,7 +473,27 @@ def send_request(method, params = nil, session_id: nil, related_request_id: nil,
443473
end
444474
end
445475

446-
response = queue.pop
476+
response = pending_response.pop(timeout: wait_timeout) do
477+
# Expiry cancels as well as stops waiting, so a client that answers late does not act on
478+
# a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here:
479+
# the modern lifecycle forbids server-to-client requests outright, and its sessionless requests
480+
# never register the session this method looks up. Those revisions ask the sender to "issue
481+
# a cancellation notification for that request and stop waiting", letting either side send one.
482+
# (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown
483+
# governs the era that has no such requests to cancel.) Both reference SDKs send this same
484+
# courtesy cancel on timeout.
485+
server_session&.send_peer_cancellation(
486+
nested_request_id: request_id,
487+
related_request_id: related_request_id,
488+
reason: "Timed out after #{wait_timeout} seconds",
489+
)
490+
491+
raise RequestTimeoutError.new(
492+
"#{method} request timed out after #{wait_timeout} seconds",
493+
request_id: request_id,
494+
timeout: wait_timeout,
495+
)
496+
end
447497

448498
if response.is_a?(Hash) && response.key?(:error)
449499
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"

lib/mcp/server_context.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@ def notify_resources_updated(uri:)
138138
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
139139
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
140140
# server configuration, or environment variables instead.
141-
def list_roots
141+
def list_roots(timeout: nil)
142142
if @notification_target.respond_to?(:list_roots)
143-
@notification_target.list_roots(related_request_id: @related_request_id)
143+
@notification_target.list_roots(related_request_id: @related_request_id, **timeout_kwarg(timeout))
144144
else
145145
raise NoMethodError, "undefined method 'list_roots' for #{self}"
146146
end
@@ -160,9 +160,9 @@ def list_roots
160160
# end
161161
#
162162
# @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
163-
def ping
163+
def ping(timeout: nil)
164164
if @notification_target.respond_to?(:ping)
165-
@notification_target.ping(related_request_id: @related_request_id)
165+
@notification_target.ping(related_request_id: @related_request_id, **timeout_kwarg(timeout))
166166
else
167167
raise NoMethodError, "undefined method 'ping' for #{self}"
168168
end
@@ -245,5 +245,15 @@ def method_missing(name, *args, **kwargs, &block)
245245
def respond_to_missing?(name, include_private = false)
246246
@context.respond_to?(name) || super
247247
end
248+
249+
private
250+
251+
# An omitted `timeout:` is not forwarded at all, so the delegated call keeps the shape it had
252+
# before per-request timeouts existed. A notification target that predates the keyword
253+
# (a custom object standing in for a session) keeps working until a caller actually asks for a timeout,
254+
# and the transport applies its own default in that case.
255+
def timeout_kwarg(timeout)
256+
timeout.nil? ? {} : { timeout: timeout }
257+
end
248258
end
249259
end

lib/mcp/server_session.rb

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -131,20 +131,20 @@ def client_capabilities
131131
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
132132
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
133133
# server configuration, or environment variables instead.
134-
def list_roots(related_request_id: nil)
134+
def list_roots(related_request_id: nil, timeout: nil)
135135
@server.send(:warn_if_deprecated_protocol_feature, :roots, session: self, uplevel: 2)
136136
warn_unassociated_request(__method__, related_request_id)
137137

138138
unless client_capabilities&.dig(:roots)
139139
raise "Client does not support roots."
140140
end
141141

142-
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
142+
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id, timeout: timeout)
143143
end
144144

145145
# Sends a `ping` request scoped to this session.
146-
def ping(related_request_id: nil)
147-
result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id)
146+
def ping(related_request_id: nil, timeout: nil)
147+
result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id, timeout: timeout)
148148
raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
149149

150150
result
@@ -158,20 +158,25 @@ def ping(related_request_id: nil)
158158
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
159159
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
160160
# APIs instead.
161-
def create_sampling_message(related_request_id: nil, **kwargs)
161+
def create_sampling_message(related_request_id: nil, timeout: nil, **kwargs)
162162
@server.send(:warn_if_deprecated_protocol_feature, :sampling, session: self, uplevel: 2)
163163
warn_unassociated_request(__method__, related_request_id)
164164

165165
params = @server.build_sampling_params(client_capabilities, **kwargs)
166-
send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
166+
send_to_transport_request(
167+
Methods::SAMPLING_CREATE_MESSAGE,
168+
params,
169+
related_request_id: related_request_id,
170+
timeout: timeout,
171+
)
167172
end
168173

169174
# Sends an `elicitation/create` request (form mode) scoped to this session.
170175
#
171176
# Per SEP-2260, the request must be associated with an originating client
172177
# request; prefer `server_context.create_form_elicitation` inside a handler,
173178
# which stamps the association automatically.
174-
def create_form_elicitation(message:, requested_schema:, related_request_id: nil)
179+
def create_form_elicitation(message:, requested_schema:, related_request_id: nil, timeout: nil)
175180
warn_unassociated_request(__method__, related_request_id)
176181

177182
unless client_capabilities&.dig(:elicitation)
@@ -180,15 +185,20 @@ def create_form_elicitation(message:, requested_schema:, related_request_id: nil
180185
end
181186

182187
params = { mode: "form", message: message, requestedSchema: requested_schema }
183-
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
188+
send_to_transport_request(
189+
Methods::ELICITATION_CREATE,
190+
params,
191+
related_request_id: related_request_id,
192+
timeout: timeout,
193+
)
184194
end
185195

186196
# Sends an `elicitation/create` request (URL mode) scoped to this session.
187197
#
188198
# Per SEP-2260, the request must be associated with an originating client
189199
# request; prefer `server_context.create_url_elicitation` inside a handler,
190200
# which stamps the association automatically.
191-
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil)
201+
def create_url_elicitation(message:, url:, elicitation_id:, related_request_id: nil, timeout: nil)
192202
warn_unassociated_request(__method__, related_request_id)
193203

194204
unless client_capabilities&.dig(:elicitation, :url)
@@ -197,7 +207,12 @@ def create_url_elicitation(message:, url:, elicitation_id:, related_request_id:
197207
end
198208

199209
params = { mode: "url", message: message, url: url, elicitationId: elicitation_id }
200-
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
210+
send_to_transport_request(
211+
Methods::ELICITATION_CREATE,
212+
params,
213+
related_request_id: related_request_id,
214+
timeout: timeout,
215+
)
201216
end
202217

203218
# Sends `notifications/cancelled` to the peer for a nested server-to-client request
@@ -296,14 +311,15 @@ def send_to_transport(method, params, related_request_id: nil)
296311
# `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing.
297312
# When `related_request_id` names an in-flight request, its `Cancellation` token is looked up
298313
# so that cancelling the parent also cancels this nested server-to-client request.
299-
def send_to_transport_request(method, params, related_request_id: nil)
314+
def send_to_transport_request(method, params, related_request_id: nil, timeout: nil)
300315
parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil
301316

302317
kwargs = {
303318
session_id: @session_id,
304319
related_request_id: related_request_id,
305320
parent_cancellation: parent_cancellation,
306321
server_session: self,
322+
timeout: timeout,
307323
}.compact
308324

309325
forward_to_transport(@transport.method(:send_request), method, params, kwargs)

0 commit comments

Comments
 (0)