mcp 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,10 @@ require "securerandom"
4
4
  require_relative "../../json_rpc_handler"
5
5
  require_relative "../configuration"
6
6
  require_relative "../methods"
7
+ require_relative "../protocol_deprecations"
7
8
  require_relative "../version"
9
+ require_relative "mcp_param_headers"
10
+ require_relative "modern_envelope"
8
11
 
9
12
  module MCP
10
13
  class Client
@@ -24,6 +27,29 @@ module MCP
24
27
  DEFAULT_RECONNECTION_DELAY_MS = 1000
25
28
  MAX_RECONNECTION_ATTEMPTS = 2
26
29
 
30
+ # Floor on the effective reconnection delay. `listen_for_server_requests` treats a graceful close as success
31
+ # and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and reconnects in a tight loop.
32
+ # Waiting longer than the server asked for is explicitly allowed: the `retry` field the spec points at is
33
+ # the one defined by WHATWG HTML, whose reconnection algorithm reads "Wait a delay equal to the reconnection time
34
+ # of the event source. Optionally, wait some more." Waiting *less* is what the spec's MUST rules out,
35
+ # and nothing here ever does that.
36
+ #
37
+ # https://html.spec.whatwg.org/multipage/server-sent-events.html#reconnection-time
38
+ MIN_RECONNECTION_DELAY_MS = 100
39
+
40
+ # Budget in seconds for `await_response_after_disconnect`: it gates every wait between reconnection attempts,
41
+ # and whatever is left of it becomes the read timeout of each resumed stream. That method runs on the calling thread,
42
+ # so without a deadline a server answering with a large `retry:` parks a thread of the embedding application for
43
+ # as long as it likes; `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects,
44
+ # not how long it waits for each. A delay that would run past the deadline is not shortened - the client stops
45
+ # reconnecting instead, the same kind of decision the attempt cap already makes, so the server's `retry:` is
46
+ # always honored in full or not acted on at all.
47
+ #
48
+ # Matches `SSE_LISTENER_READ_TIMEOUT`, this client's other "how long to wait on a quiet SSE stream" value.
49
+ # `listen_for_server_requests` has no such deadline: it runs on a thread this client owns and is meant
50
+ # to poll indefinitely, so a long `retry:` there idles the SDK's own listener rather than the application.
51
+ MAX_RECONNECTION_WAIT = 300
52
+
27
53
  # How long the standalone GET listening stream may stay idle before the read times out
28
54
  # and the connection is counted as a failure and retried. Matches the Python SDK's
29
55
  # `sse_read_timeout` default of 5 minutes; without this, the adapter's default read timeout
@@ -37,6 +63,12 @@ module MCP
37
63
  # and the server transports' request cap.
38
64
  MAX_MESSAGE_BYTES = 4 * 1024 * 1024
39
65
 
66
+ # Upper bound on the tools whose `x-mcp-header` declarations are retained for
67
+ # `Mcp-Param-*` mirroring (SEP-2243). Past the cap, newly listed tools mirror nothing
68
+ # (the spec's guidance to send without custom headers), so a server rotating tool names across
69
+ # `tools/list` responses cannot grow the registry without bound.
70
+ MAX_MCP_PARAM_TOOLS = 1000
71
+
40
72
  # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
41
73
  # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
42
74
  # is trivially observed and stolen.
@@ -207,13 +239,24 @@ module MCP
207
239
 
208
240
  attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
209
241
 
210
- def initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, &block)
242
+ def initialize(
243
+ url:,
244
+ headers: {},
245
+ oauth: nil,
246
+ max_message_bytes: MAX_MESSAGE_BYTES,
247
+ max_reconnection_wait: MAX_RECONNECTION_WAIT,
248
+ &block
249
+ )
211
250
  # `nil` or a non-positive value would make the buffering unbounded and silently
212
251
  # disable the protection, so reject it up front.
213
252
  unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0
214
253
  raise ArgumentError, "max_message_bytes must be a positive Integer"
215
254
  end
216
255
 
256
+ unless max_reconnection_wait.is_a?(Numeric) && max_reconnection_wait > 0
257
+ raise ArgumentError, "max_reconnection_wait must be a positive number"
258
+ end
259
+
217
260
  if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
218
261
  # Mask credentials (userinfo) and query parameters before quoting the URL in the error message
219
262
  # so they cannot leak into logs.
@@ -228,6 +271,7 @@ module MCP
228
271
  @faraday_customizer = block
229
272
  @oauth = oauth
230
273
  @max_message_bytes = max_message_bytes
274
+ @max_reconnection_wait = max_reconnection_wait
231
275
  # Snapshot the canonical URL at construction time. This single value
232
276
  # serves two related roles, both of which need to see the query string:
233
277
  #
@@ -251,6 +295,9 @@ module MCP
251
295
  @connected = false
252
296
  @server_request_handlers = {}
253
297
  @listener_thread = nil
298
+ @modern_client_info = nil
299
+ @modern_capabilities = nil
300
+ @mcp_param_declarations = {}
254
301
  end
255
302
 
256
303
  # Registers a handler for a server-to-client request (e.g. `elicitation/create`) delivered on an SSE stream.
@@ -280,77 +327,47 @@ module MCP
280
327
  #
281
328
  # @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
282
329
  # Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
283
- # @param protocol_version [String, nil] Protocol version to offer. Defaults
284
- # to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
330
+ # @param protocol_version [String, nil] Protocol version to offer on the legacy handshake.
331
+ # Defaults to `MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION`; a modern version
332
+ # raises `ArgumentError` here (modern versions are selected via `mode: :modern`/`:auto`).
285
333
  # @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
286
334
  # @return [Hash] The server's `InitializeResult`.
287
335
  # @raise [RequestHandlerError] If the server responds with a JSON-RPC error
288
336
  # or a malformed result.
337
+ # @param mode [Symbol] Lifecycle selection (SEP-2575): `:legacy` (default) performs
338
+ # the handshake below, `:modern` skips it and probes `server/discover`,
339
+ # and `:auto` probes `server/discover` first, falling back to the legacy handshake
340
+ # when the server does not serve a mutually supported modern version.
289
341
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
290
- def connect(client_info: nil, protocol_version: nil, capabilities: {})
342
+ def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy)
291
343
  return @server_info if connected?
292
344
 
293
- client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
294
- protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION
295
-
296
- response = send_request(request: {
297
- jsonrpc: JsonRpcHandler::Version::V2_0,
298
- id: SecureRandom.uuid,
299
- method: MCP::Methods::INITIALIZE,
300
- params: {
301
- protocolVersion: protocol_version,
302
- capabilities: capabilities,
303
- clientInfo: client_info,
304
- },
305
- })
345
+ # Per the SEP-2575 era model, a modern version cannot ride the legacy `initialize` handshake.
346
+ MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy
306
347
 
307
- if response.is_a?(Hash) && response.key?("error")
308
- clear_session
309
- error = response["error"]
310
- raise RequestHandlerError.new(
311
- "Server initialization failed: #{error["message"]}",
312
- { method: MCP::Methods::INITIALIZE },
313
- error_type: :internal_error,
314
- )
315
- end
316
-
317
- unless response.is_a?(Hash) && response["result"].is_a?(Hash)
318
- clear_session
319
- raise RequestHandlerError.new(
320
- "Server initialization failed: missing result in response",
321
- { method: MCP::Methods::INITIALIZE },
322
- error_type: :internal_error,
323
- )
324
- end
325
-
326
- @server_info = response["result"]
327
- negotiated_protocol_version = @server_info["protocolVersion"]
328
- unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
329
- clear_session
330
- raise RequestHandlerError.new(
331
- "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
332
- { method: MCP::Methods::INITIALIZE },
333
- error_type: :internal_error,
334
- )
335
- end
348
+ client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
336
349
 
337
- begin
338
- send_request(request: {
339
- jsonrpc: JsonRpcHandler::Version::V2_0,
340
- method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
341
- })
342
- rescue StandardError
343
- clear_session
344
- raise
350
+ case mode
351
+ when :legacy
352
+ connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
353
+ when :modern
354
+ connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
355
+ when :auto
356
+ connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
357
+ else
358
+ raise ArgumentError, "mode must be :legacy, :modern, or :auto"
345
359
  end
360
+ end
346
361
 
347
- @connected = true
348
- start_listening if @server_request_handlers.any?
349
- @server_info
362
+ # Whether the transport operates in the stateless modern lifecycle (SEP-2575):
363
+ # no handshake was performed and every request carries the `_meta` envelope.
364
+ def modern?
365
+ !@modern_client_info.nil?
350
366
  end
351
367
 
352
368
  # Returns true once `connect` has completed the full handshake
353
- # (`initialize` response received and `notifications/initialized` sent).
369
+ # (`initialize` response received and `notifications/initialized` sent),
370
+ # or once the modern lifecycle was adopted via `server/discover`.
354
371
  # Returns false before the first handshake and after `close`.
355
372
  def connected?
356
373
  @connected
@@ -370,6 +387,18 @@ module MCP
370
387
  # a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
371
388
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
372
389
  def send_request(request:)
390
+ # Modern requests (never notifications, whose `_meta` has no envelope) carry
391
+ # the SEP-2575 triple; the `MCP-Protocol-Version` header from `session_headers`
392
+ # matches it by construction.
393
+ if modern? && (request[:id] || request["id"])
394
+ request = ModernEnvelope.stamp(
395
+ request,
396
+ protocol_version: @protocol_version,
397
+ client_info: @modern_client_info,
398
+ capabilities: @modern_capabilities,
399
+ )
400
+ end
401
+
373
402
  method = request[:method] || request["method"]
374
403
  params = request[:params] || request["params"]
375
404
  oauth_retried = false
@@ -398,6 +427,7 @@ module MCP
398
427
  body = resolve_response_body(stream, response, method, params)
399
428
 
400
429
  capture_session_info(method, response, body) if response
430
+ capture_mcp_param_declarations(method, params, body)
401
431
 
402
432
  body
403
433
  rescue MessageTooLargeError => e
@@ -532,6 +562,163 @@ module MCP
532
562
 
533
563
  attr_reader :headers
534
564
 
565
+ def connect_legacy(client_info:, protocol_version:, capabilities:)
566
+ protocol_version ||= MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION
567
+
568
+ response = send_request(request: {
569
+ jsonrpc: JsonRpcHandler::Version::V2_0,
570
+ id: SecureRandom.uuid,
571
+ method: MCP::Methods::INITIALIZE,
572
+ params: {
573
+ protocolVersion: protocol_version,
574
+ capabilities: capabilities,
575
+ clientInfo: client_info,
576
+ },
577
+ })
578
+
579
+ if response.is_a?(Hash) && response.key?("error")
580
+ clear_session
581
+ error = response["error"]
582
+ raise RequestHandlerError.new(
583
+ "Server initialization failed: #{error["message"]}",
584
+ { method: MCP::Methods::INITIALIZE },
585
+ error_type: :internal_error,
586
+ )
587
+ end
588
+
589
+ unless response.is_a?(Hash) && response["result"].is_a?(Hash)
590
+ clear_session
591
+ raise RequestHandlerError.new(
592
+ "Server initialization failed: missing result in response",
593
+ { method: MCP::Methods::INITIALIZE },
594
+ error_type: :internal_error,
595
+ )
596
+ end
597
+
598
+ @server_info = response["result"]
599
+ negotiated_protocol_version = @server_info["protocolVersion"]
600
+ # A modern version in an `InitializeResult` is rejected along with unknown ones: the handshake
601
+ # settles on a legacy version by definition, and the TypeScript and Python clients refuse
602
+ # a modern counter-offer the same way.
603
+ unless MCP::Configuration::SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
604
+ clear_session
605
+ raise RequestHandlerError.new(
606
+ "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
607
+ { method: MCP::Methods::INITIALIZE },
608
+ error_type: :internal_error,
609
+ )
610
+ end
611
+
612
+ begin
613
+ send_request(request: {
614
+ jsonrpc: JsonRpcHandler::Version::V2_0,
615
+ method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
616
+ })
617
+ rescue StandardError
618
+ clear_session
619
+ raise
620
+ end
621
+
622
+ @connected = true
623
+ start_listening if @server_request_handlers.any?
624
+ @server_info
625
+ end
626
+
627
+ # Enters the modern lifecycle by probing `server/discover` at the requested (or latest) modern version.
628
+ # No `initialize` or `notifications/initialized` is sent; the probe response becomes `server_info`.
629
+ def connect_modern(client_info:, protocol_version:, capabilities:)
630
+ version = protocol_version || MCP::Configuration::LATEST_MODERN_PROTOCOL_VERSION
631
+ unless MCP::Configuration.modern_protocol_version?(version)
632
+ raise ArgumentError, "protocol_version #{version.inspect} is not a supported modern protocol version"
633
+ end
634
+
635
+ enter_modern_mode(protocol_version: version, client_info: client_info, capabilities: capabilities)
636
+
637
+ begin
638
+ result = probe_discover
639
+ rescue StandardError
640
+ leave_modern_mode
641
+ raise
642
+ end
643
+
644
+ supported = result["supportedVersions"]
645
+ unless supported.is_a?(Array) && supported.include?(version)
646
+ leave_modern_mode
647
+ raise RequestHandlerError.new(
648
+ "Server discovery failed: no mutually supported modern protocol version " \
649
+ "(server supports #{supported.inspect})",
650
+ { method: MCP::Methods::SERVER_DISCOVER },
651
+ error_type: :internal_error,
652
+ )
653
+ end
654
+
655
+ # SEP-2577 deprecates roots and sampling at 2026-07-28, the revision every modern connection speaks,
656
+ # so the warning lives here now that the handshake cannot land on one.
657
+ MCP::ProtocolDeprecations.warn_for_client_capabilities(capabilities, protocol_version: version, uplevel: 1)
658
+
659
+ @server_info = result
660
+ @connected = true
661
+ @server_info
662
+ end
663
+
664
+ # Probes `server/discover` and adopts the modern lifecycle when the server serves
665
+ # a mutually supported modern version; otherwise falls back to the legacy handshake.
666
+ # The fallback intentionally covers a successful discovery without a mutual modern
667
+ # version as well: during the 2026-07-28 rollout a server may answer discovery while
668
+ # only serving legacy versions.
669
+ def connect_auto(client_info:, protocol_version:, capabilities:)
670
+ modern_pin = protocol_version if protocol_version && MCP::Configuration.modern_protocol_version?(protocol_version)
671
+ connect_modern(client_info: client_info, protocol_version: modern_pin, capabilities: capabilities)
672
+ rescue RequestHandlerError
673
+ # An explicitly requested modern version is never downgraded by the fallback: the legacy handshake cannot negotiate it,
674
+ # so the probe's failure is the real answer and propagates.
675
+ raise if modern_pin
676
+
677
+ connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities)
678
+ end
679
+
680
+ def enter_modern_mode(protocol_version:, client_info:, capabilities:)
681
+ @modern_client_info = client_info
682
+ @modern_capabilities = capabilities || {}
683
+ # `session_headers` sends `@protocol_version` as the `MCP-Protocol-Version` header,
684
+ # which the modern lifecycle requires to match the `_meta`-carried version.
685
+ @protocol_version = protocol_version
686
+ end
687
+
688
+ def leave_modern_mode
689
+ @modern_client_info = nil
690
+ @modern_capabilities = nil
691
+ @protocol_version = nil
692
+ end
693
+
694
+ def probe_discover
695
+ response = send_request(request: {
696
+ jsonrpc: JsonRpcHandler::Version::V2_0,
697
+ id: SecureRandom.uuid,
698
+ method: MCP::Methods::SERVER_DISCOVER,
699
+ })
700
+
701
+ if response.is_a?(Hash) && response.key?("error")
702
+ error = response["error"]
703
+ raise RequestHandlerError.new(
704
+ "Server discovery failed: #{error["message"]}",
705
+ { method: MCP::Methods::SERVER_DISCOVER },
706
+ error_type: :internal_error,
707
+ )
708
+ end
709
+
710
+ result = response.is_a?(Hash) ? response["result"] : nil
711
+ unless result.is_a?(Hash)
712
+ raise RequestHandlerError.new(
713
+ "Server discovery failed: missing result in response",
714
+ { method: MCP::Methods::SERVER_DISCOVER },
715
+ error_type: :internal_error,
716
+ )
717
+ end
718
+
719
+ result
720
+ end
721
+
535
722
  def client
536
723
  require_faraday!
537
724
  @client ||= Faraday.new(url) do |faraday|
@@ -583,11 +770,56 @@ module MCP
583
770
  name = params[:name] || params["name"]
584
771
  name = params[:uri] || params["uri"] unless name.is_a?(String)
585
772
  metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
773
+
774
+ if method == MCP::Methods::TOOLS_CALL && (declarations = @mcp_param_declarations[params[:name] || params["name"]])
775
+ arguments = params[:arguments] || params["arguments"]
776
+
777
+ metadata_headers.merge!(McpParamHeaders.build(declarations, arguments))
778
+ end
586
779
  end
587
780
 
588
781
  metadata_headers
589
782
  end
590
783
 
784
+ # Learns the `x-mcp-header` declarations of the tools a `tools/list` response advertises,
785
+ # so later `tools/call` requests can mirror the annotated arguments into `Mcp-Param-*` headers (SEP-2243).
786
+ # The custom headers exist on the modern lifecycle only, matching the TypeScript and Python SDKs,
787
+ # so legacy connections learn nothing. Only a valid, non-empty declaration set is kept:
788
+ # an invalid tool definition mirrors nothing,
789
+ # following the spec's guidance to send without custom headers when no reliable declarations are available.
790
+ def capture_mcp_param_declarations(method, params, body)
791
+ return unless modern?
792
+ return unless method.to_s == MCP::Methods::TOOLS_LIST && body.is_a?(Hash)
793
+
794
+ tools = body.dig("result", "tools")
795
+ return unless tools.is_a?(Array)
796
+
797
+ # An uncursored request answered without `nextCursor` is the complete tool universe,
798
+ # so knowledge about unlisted tools is stale; the registry is rebuilt from this listing,
799
+ # the same pruning the Python SDK applies to complete listings.
800
+ cursor = params.is_a?(Hash) && (params[:cursor] || params["cursor"])
801
+ complete = !cursor && body.dig("result", "nextCursor").nil?
802
+ registry = complete ? {} : @mcp_param_declarations
803
+
804
+ tools.each do |tool|
805
+ next unless tool.is_a?(Hash)
806
+
807
+ name = tool["name"]
808
+ next unless name.is_a?(String)
809
+
810
+ scan = McpParamHeaders.scan(tool["inputSchema"])
811
+ if scan[:valid] && !scan[:declarations].empty?
812
+ next if !registry.key?(name) && registry.size >= MAX_MCP_PARAM_TOOLS
813
+
814
+ registry[name] = scan[:declarations]
815
+ else
816
+ registry.delete(name)
817
+ end
818
+ end
819
+
820
+ @mcp_param_declarations = registry if complete
821
+ end
822
+
591
823
  # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
592
824
  # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
593
825
  # `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
@@ -727,6 +959,8 @@ module MCP
727
959
  @protocol_version = nil
728
960
  @server_info = nil
729
961
  @connected = false
962
+ @modern_client_info = nil
963
+ @modern_capabilities = nil
730
964
  end
731
965
 
732
966
  # Opens the standalone GET SSE listening stream on a background thread so server-to-client requests
@@ -780,10 +1014,26 @@ module MCP
780
1014
 
781
1015
  stream.reset_parser!
782
1016
 
783
- sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
1017
+ sleep(reconnection_delay_seconds(stream))
784
1018
  end
785
1019
  end
786
1020
 
1021
+ # The reconnection delay in seconds: the server's `retry:` value when it sent one and
1022
+ # the default otherwise, never shortened, raised to `MIN_RECONNECTION_DELAY_MS` when the server
1023
+ # asked for less than that. A `retry:` that is negative or not a run of digits is not a value at all;
1024
+ # the SSE parser drops it, so those arrive here as the default rather than as something to guard.
1025
+ def reconnection_delay_seconds(stream)
1026
+ delay_ms = stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS
1027
+
1028
+ [delay_ms, MIN_RECONNECTION_DELAY_MS].max / 1000.0
1029
+ end
1030
+
1031
+ # Seconds left before `deadline`, floored just above zero so a budget consumed down to the last instant
1032
+ # still asks the adapter for a timeout rather than for "no timeout".
1033
+ def remaining_reconnection_budget(deadline)
1034
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
1035
+ end
1036
+
787
1037
  def require_faraday!
788
1038
  require "faraday"
789
1039
  rescue LoadError
@@ -900,23 +1150,43 @@ module MCP
900
1150
 
901
1151
  # SEP-1699 resumability: the server closed the SSE stream after a priming event
902
1152
  # without delivering the response. Treat the graceful close like a network failure:
903
- # wait the server-specified `retry:` interval (default 1000ms), then reconnect with
1153
+ # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
904
1154
  # a GET carrying `Last-Event-ID` so the server can replay the pending response on
905
1155
  # the standalone stream. Mirrors the TypeScript SDK's `StreamableHTTPClientTransport`
906
1156
  # reconnection and the Python SDK's `_handle_reconnection` (including its 2-attempt cap).
1157
+ #
1158
+ # This runs on the caller's thread, so the attempts are bounded by `max_reconnection_wait` as well as
1159
+ # by their count: the deadline gates each wait, and what is left of it becomes the read timeout of
1160
+ # the resumed stream. A delay that would run past the deadline is never shortened: the client stops
1161
+ # reconnecting instead, so the server's `retry:` is honored in full or not acted on at all.
907
1162
  # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699
908
1163
  def await_response_after_disconnect(stream, method, params)
909
1164
  stream.abortable = true
1165
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @max_reconnection_wait
1166
+ gave_up_waiting = false
910
1167
 
911
1168
  MAX_RECONNECTION_ATTEMPTS.times do
912
- sleep((stream.retry_ms || DEFAULT_RECONNECTION_DELAY_MS) / 1000.0)
1169
+ delay = reconnection_delay_seconds(stream)
1170
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay > deadline
1171
+ gave_up_waiting = true
1172
+ break
1173
+ end
1174
+
1175
+ sleep(delay)
913
1176
  stream.reset_parser!
914
1177
 
1178
+ # Bound the resumed stream's idle time by what is left of the budget, rather than leaving it to
1179
+ # whatever the Faraday adapter defaults to. `listen_for_server_requests` guards its own GET the same way;
1180
+ # without this, a caller-supplied adapter with no default read timeout would let a server hold
1181
+ # the connection open past the budget by simply sending nothing.
1182
+ read_timeout = remaining_reconnection_budget(deadline)
1183
+
915
1184
  reconnect_response = begin
916
1185
  client.get("") do |req|
917
1186
  req.headers.update(session_headers)
918
1187
  req.headers["Accept"] = SSE_ACCEPT_HEADER
919
1188
  req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1189
+ req.options.read_timeout = read_timeout
920
1190
  req.options.on_data = stream.on_data
921
1191
  end
922
1192
  rescue StreamAbort
@@ -932,9 +1202,14 @@ module MCP
932
1202
  return stream.response if stream.response
933
1203
  end
934
1204
 
1205
+ reason = if gave_up_waiting
1206
+ "the reconnection delay it asked for would exceed the #{@max_reconnection_wait} second reconnection budget"
1207
+ else
1208
+ "#{MAX_RECONNECTION_ATTEMPTS} reconnection attempts"
1209
+ end
1210
+
935
1211
  raise RequestHandlerError.new(
936
- "Server closed the SSE stream without a response for #{method} " \
937
- "after #{MAX_RECONNECTION_ATTEMPTS} reconnection attempts",
1212
+ "Server closed the SSE stream without a response for #{method} after #{reason}",
938
1213
  { method: method, params: params },
939
1214
  error_type: :internal_error,
940
1215
  )