ruby-mcp-client 2.1.0 → 3.0.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module HttpTransportBase
5
+ # MCP 2026-07-28 "Custom Headers from Tool Parameters" (SEP-2243): the
6
+ # Mcp-Param-* headers a tools/call derives from its annotated arguments,
7
+ # the clearing of that reserved namespace so a configured header cannot
8
+ # stand in for an argument that was not sent, and the
9
+ # refresh-tools-and-retry-once recovery a HeaderMismatch asks for.
10
+ module ParamHeaders
11
+ private
12
+
13
+ # Attach the computed `Mcp-Param-*` headers (MCP 2026-07-28 "Custom
14
+ # Headers from Tool Parameters"). On a modern session that namespace is
15
+ # derived from the call's arguments and from nothing else -- the client
16
+ # MUST omit the header for an argument that is absent or null -- so a
17
+ # configured header of that name is cleared first: leaving it would let
18
+ # it stand for an argument the extraction omitted, which no tools/list
19
+ # refresh can correct. The clearing matches HTTP's case-insensitive field
20
+ # names, whatever spelling the host configured.
21
+ # @param req [Faraday::Request] the outgoing request
22
+ # @param param_headers [Hash{String => String}] the computed headers
23
+ # @return [void]
24
+ def apply_param_headers(req, param_headers)
25
+ if modern?
26
+ # The names are collected before any is dropped: the header set is
27
+ # being mutated.
28
+ configured = req.headers.keys.select { |name| MCPClient::HeaderParams.mirrored_header?(name) }
29
+ configured.each { |name| req.headers.delete(name) }
30
+ end
31
+ param_headers.each { |k, v| req.headers[k] = v }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module HttpTransportBase
5
+ # The recoveries one JSON-RPC exchange may need before it is handed to
6
+ # with_retry: the MCP 2026-07-28 re-issue of a request whose response
7
+ # stream broke, the HeaderMismatch refresh-and-retry-once, and the
8
+ # protocol-version renegotiation. It also owns the boundary the transport
9
+ # crosses when it hands control to host code, since an error from the far
10
+ # side of that boundary is what these recoveries must not act on.
11
+ module RequestRecovery
12
+ # Marks an error that escaped another exchange run from inside this one:
13
+ # host code this transport handed control to while a response was still
14
+ # being parsed, or the tools/list a tools/call reads first to derive its
15
+ # headers. It belongs to that exchange -- which had its own recovery --
16
+ # and never to the one it surfaced in.
17
+ module NestedExchange; end
18
+
19
+ private
20
+
21
+ # One attempt of a request with the transport-level recoveries that
22
+ # re-send the same params: version renegotiation, HeaderMismatch refresh
23
+ # and a response stream that closed without the response.
24
+ #
25
+ # Both re-sends `retry` the same guarded block rather than running inside
26
+ # their own rescue clause, so either recovery's re-send is still covered
27
+ # by the other — a HeaderMismatch retry whose stream closes is re-issued,
28
+ # and a re-issue that is rejected for its headers still refreshes
29
+ # tools/list. Each recovery fires at most once, so the pair is bounded at
30
+ # three sends.
31
+ #
32
+ # The refresh is spent once for the whole logical request: the caller's
33
+ # flag rides in, and the block marks it. A re-issue is scoped to the
34
+ # attempt, which is all a tools/call ever gets — with_retry refuses to
35
+ # re-attempt a NON_IDEMPOTENT_METHODS request.
36
+ #
37
+ # The deadline is this attempt's, shared by every send it makes: a
38
+ # recovery replaces the request, it does not buy it more time.
39
+ # @param method [String] JSON-RPC method name
40
+ # @param params [Hash] parameters for this attempt (may carry inputResponses)
41
+ # @param timeout [Numeric, nil] per-request timeout override
42
+ # @param header_refresh_done [Boolean] whether the one HeaderMismatch refresh was spent
43
+ # @yield marks the HeaderMismatch refresh as spent
44
+ # @return [Object] the attempt's result
45
+ def attempt_request(method, params, timeout, header_refresh_done)
46
+ with_retry(method) do
47
+ # One budget for this request and every replacement it may need: the
48
+ # maximum timeout the spec asks for holds "regardless of progress",
49
+ # and neither a lost stream nor a rejected header set is progress.
50
+ # A continuation is a request of its own and gets its own budget --
51
+ # the wait before it is bounded separately (see InputWaits).
52
+ budget = timeout || @read_timeout
53
+ # The real monotonic clock, never the stubbable #monotonic_now the
54
+ # caching layer exposes: a test that freezes cache time must not
55
+ # make every request's deadline expire on arrival.
56
+ deadline = budget && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + budget)
57
+ stream_reissued = false
58
+ header_refreshed = header_refresh_done
59
+ begin
60
+ send_request_with_version_retry(method, params, timeout, deadline)
61
+ rescue MCPClient::Errors::HeaderMismatchError => e
62
+ # A rejection that escaped host code reached from this response --
63
+ # a listener's own tools/call -- rejects that request, not this
64
+ # one. This one the server has already executed, and re-sending it
65
+ # on someone else's error would execute it twice.
66
+ raise if e.is_a?(NestedExchange)
67
+ raise unless modern? && method == 'tools/call' && !header_refreshed
68
+
69
+ header_refreshed = true
70
+ yield
71
+ refreshed = refresh_tools_after_header_mismatch(e)
72
+ # The rejected attempt did not run the tool; this retry is the
73
+ # send that would. A refreshed definition whose inputSchema
74
+ # declares an unreadable dialect must therefore stop the call
75
+ # here, not after it has been executed. The list this refresh
76
+ # read is carried into the check so that the definition pinned
77
+ # for the retry is this caller's own, not whichever concurrent
78
+ # refresh happened to write the cache last.
79
+ reject_unreadable_refreshed_schema!(params, refreshed)
80
+ retry
81
+ rescue MCPClient::Errors::ResponseStreamClosedError => e
82
+ # Modern Streamable HTTP has no resumption: "a broken response
83
+ # stream loses the in-flight request; clients MUST re-issue it as a
84
+ # new request with a new request ID" (2026-07-28 changelog, major
85
+ # change 9). The rule has no exception for tools/call, and this
86
+ # revision makes closing the response stream itself the
87
+ # cancellation signal — the server MUST treat the broken stream as
88
+ # a cancellation and stop work — so the re-issue is the behaviour
89
+ # the protocol expects rather than a blind replay.
90
+ #
91
+ # A stream that closed between (or inside) SSE events reaches here
92
+ # from the parser; one that died at the socket reaches here from
93
+ # connection_failure_error. Both are the same loss. A stream a
94
+ # listener's own request lost is that request's to re-issue, and it
95
+ # already did: this exchange still has its response.
96
+ raise if stream_reissued || e.is_a?(NestedExchange)
97
+
98
+ stream_reissued = true
99
+ @logger.warn("#{e.message}; re-issuing #{method} as a new request")
100
+ retry
101
+ end
102
+ end
103
+ ensure
104
+ # A pin the retry never consumed (it raised before sending) must not
105
+ # outlive the request it was for.
106
+ clear_pinned_retry_definition
107
+ end
108
+
109
+ # Send the request, renegotiating the protocol version once if the server
110
+ # rejects the one it went out with.
111
+ # @param method [String] JSON-RPC method name
112
+ # @param params [Hash] parameters for the request
113
+ # @param timeout [Numeric, nil] per-request timeout override
114
+ # @param deadline [Float, nil] monotonic instant the exchange and its
115
+ # renegotiated replacement must finish by
116
+ # @return [Object] result from the JSON-RPC response
117
+ def send_request_with_version_retry(method, params, timeout, deadline = nil)
118
+ sent_version = protocol_version
119
+ begin
120
+ send_request_and_parse(method, params, timeout, deadline)
121
+ rescue MCPClient::Errors::UnsupportedProtocolVersionError => e
122
+ # MCP 2026-07-28 basic/versioning: select a mutually supported
123
+ # version from the error's list and retry. The server rejected the
124
+ # request before processing it, so a re-send cannot duplicate a side
125
+ # effect. Compared against the version THIS request went out with:
126
+ # a concurrent request may already have moved the transport on.
127
+ version = select_protocol_version(e.supported)
128
+ raise unless modern? && version && version != sent_version
129
+
130
+ @logger.info("Server does not support protocol version #{sent_version}; " \
131
+ "retrying #{method} with #{version}")
132
+ @protocol_version = version
133
+ send_request_and_parse(method, params, timeout, deadline)
134
+ end
135
+ end
136
+
137
+ # Hand control to host code -- a notification listener, a handler for a
138
+ # server-initiated request -- reached while a response is still being
139
+ # parsed.
140
+ #
141
+ # A request that code issues is an exchange of its own: it gets a slot of
142
+ # its own for the definition it goes out under
143
+ # (MCPClient::CalledToolDefinition), and an error escaping it is marked
144
+ # NestedExchange so the exchange whose response parsing reached here does
145
+ # not mistake it for its own rejection and recover from it.
146
+ # @yield the host code
147
+ # @return [Object] the block's value
148
+ def dispatching_to_host(&)
149
+ outside_called_tool_definition(&)
150
+ rescue StandardError => e
151
+ e.extend(NestedExchange) unless e.frozen?
152
+ raise
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module HttpTransportBase
5
+ # MCP 2025-11-25 session management for the HTTP transports: an HTTP 404
6
+ # answering a request that carried an Mcp-Session-Id means the session
7
+ # expired, and "the client MUST start a new session by sending a new
8
+ # InitializeRequest without a session ID attached". That new session is a
9
+ # new session in every sense — the session epoch moves with it, so
10
+ # everything scoped to the old one (the tasks extension's task ids and
11
+ # input keys, which the replacement session may reuse for entirely
12
+ # different requests) dies with it.
13
+ module SessionRecovery
14
+ # Resend a request against the freshly restarted session — unless doing
15
+ # so could execute a side effect twice.
16
+ #
17
+ # A 404 usually means the server rejected the request outright, but it
18
+ # does not prove that: a session can expire after the tool ran.
19
+ # Automatic session recovery is worth having for idempotent methods, and
20
+ # would otherwise be a hole straight through the no-replay guarantee
21
+ # that with_retry enforces for NON_IDEMPOTENT_METHODS.
22
+ #
23
+ # Raises ConnectionError (which with_retry never retries) so no other
24
+ # path can turn this into a second attempt.
25
+ # @param request [Hash] the JSON-RPC request that hit the expired session
26
+ # @return [Faraday::Response] the response to the resent request
27
+ # @raise [MCPClient::Errors::ConnectionError] for a non-idempotent method
28
+ # @raise [MCPClient::Errors::SessionChangedError] for a request of the session that ended
29
+ def resend_after_session_restart(request)
30
+ method = request['method']
31
+ # A request pinned to the session the 404 ended is not resent into the
32
+ # session that replaced it: its payload (task ids, input request keys)
33
+ # names something else there. Nothing was written, so the caller may
34
+ # drop it — see {MCPClient::SessionPin}.
35
+ check_session_pin!
36
+ return send_http_request(request) unless MCPClient::JsonRpcCommon::NON_IDEMPOTENT_METHODS.include?(method)
37
+
38
+ raise MCPClient::Errors::ConnectionError,
39
+ "Session expired during #{method}; a new session was started but the request was NOT resent " \
40
+ 'because it may already have executed. Retry it explicitly if that is safe.'
41
+ end
42
+
43
+ private
44
+
45
+ # Start a new session after the server invalidated the current one, then
46
+ # resend the original request once. The @restarting_session flag prevents
47
+ # a second restart if the fresh session also answers 404.
48
+ #
49
+ # The 404 ended a session as surely as a cleanup or a restarted stdio
50
+ # process does, so the session epoch moves with it: a wait notices the
51
+ # move and the bookkeeping keyed by the old session dies rather than
52
+ # colouring a task id the new session may reuse.
53
+ # @param request [Hash] the JSON-RPC request that hit the expired session
54
+ # @param expired_session_id [String] the session id the 404'd request was sent with
55
+ # @return [Faraday::Response] the response to the resent request
56
+ def restart_session_and_resend(request, expired_session_id)
57
+ # Serialized on the transport monitor so concurrent 404s trigger a
58
+ # single restart; the monitor is reentrant, so the nested
59
+ # perform_initialize/id generation inside is safe.
60
+ @mutex.synchronize do
61
+ # Recheck now that the monitor is held: another caller may already
62
+ # have restarted the session while this one waited. If so, skip the
63
+ # extra initialize and just resend against the fresh session.
64
+ return resend_after_session_restart(request) if @session_id != expired_session_id
65
+
66
+ @logger.warn("Session #{@session_id} no longer valid (HTTP 404); starting a new session")
67
+ @restarting_session = true
68
+ @session_id = nil
69
+ @last_event_id = nil if instance_variable_defined?(:@last_event_id)
70
+ # The 404 ended the session, so the epoch moves here — before the
71
+ # replacement handshake, not after it. A handshake that fails (or
72
+ # that is still running) must never leave a request, or a task
73
+ # handle, treating the session the server has already dropped as
74
+ # the current one. No other request can slip in meanwhile: the
75
+ # monitor is held for the whole restart.
76
+ bump_session_epoch
77
+ # The handshake is not part of the session that ended: it is what
78
+ # establishes the one that follows, so this thread's pin — which
79
+ # the bump above has just invalidated — is lifted for it.
80
+ establish_replacement_session
81
+ resend_after_session_restart(request)
82
+ ensure
83
+ @restarting_session = false
84
+ end
85
+ end
86
+
87
+ # Send the InitializeRequest that replaces the session the 404 ended,
88
+ # with this thread's session pin lifted. A handshake that fails leaves
89
+ # no session behind: the transport is marked uninitialized so the next
90
+ # request rebuilds one through ensure_connected (whose cleanup ends
91
+ # this epoch too) instead of talking into a session that never came up.
92
+ # @return [void]
93
+ def establish_replacement_session
94
+ unpinned_session { perform_initialize }
95
+ rescue StandardError
96
+ @connection_established = false
97
+ @initialized = false
98
+ raise
99
+ end
100
+
101
+ # Whether a 404 should trigger a session restart: only when the 404'd
102
+ # request was actually sent with a session id and no restart is already
103
+ # in flight (a restart's own resend answering 404 must not loop).
104
+ # @param sent_session_id [String, nil] session id captured when the request was sent
105
+ # @return [Boolean] true if session restart recovery applies
106
+ def session_restart_applicable?(sent_session_id)
107
+ return false if sent_session_id.nil?
108
+
109
+ @mutex.synchronize { !@restarting_session }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require_relative 'bounded_inflate'
5
+
6
+ module MCPClient
7
+ module HttpTransportBase
8
+ # Splits an SSE body into complete events as its bytes arrive. Line
9
+ # terminators are CRLF, CR or LF (SSE "Parsing an event stream"): a CR
10
+ # ends a line on its own, so an event it terminates is dispatched at
11
+ # once, and the LF of a CRLF that arrives in the next chunk is skipped.
12
+ # A gzip body (Streamable HTTP offers gzip on every request) is inflated
13
+ # as it arrives. Only a body that starts like an event stream is scanned;
14
+ # a JSON body never yields anything. Events are counted, terminated or
15
+ # not dispatched, in the same order the completed body splits into them.
16
+ class SseEventScanner
17
+ # An event stream opens with a comment or a field — any field: one
18
+ # the client does not know is ignored, not a reason to stop reading
19
+ # (SSE "Parsing an event stream"). A body that opens a JSON value is
20
+ # never an event stream.
21
+ SSE_START = /\A(?::|[^\n:{\[]+:)/n
22
+ BOM = "\xEF\xBB\xBF".b
23
+ GZIP_MAGIC = "\x1F\x8B".b
24
+
25
+ # @return [Integer] complete events seen so far
26
+ attr_reader :count
27
+
28
+ # @param max_inflated_bytes [Integer, nil] bound on a gzip body's expansion,
29
+ # beyond which the stream is no longer scanned
30
+ def initialize(max_inflated_bytes: nil)
31
+ @normalized = +''.b
32
+ @head = +''.b
33
+ @scanned = 0
34
+ @after_cr = false
35
+ @count = 0
36
+ @sse = nil
37
+ @inflater = nil
38
+ @inflated = 0
39
+ @max_inflated_bytes = max_inflated_bytes
40
+ end
41
+
42
+ # @param chunk [String] the bytes that just arrived
43
+ # @yieldparam event [String] one complete event, LF-normalized, without its terminator
44
+ # @return [void]
45
+ def feed(chunk)
46
+ return if @sse == false
47
+
48
+ # Bytes, not characters: the body is peer-controlled and may not be
49
+ # text at all.
50
+ text = decoded(chunk.b)
51
+ return if text.nil? || @sse == false
52
+
53
+ text = text[1..] if @after_cr && text.start_with?("\n")
54
+ @after_cr = text.end_with?("\r")
55
+ @normalized << text.gsub(/\r\n|\r/, "\n")
56
+ return unless scanning?
57
+
58
+ while (index = @normalized.index("\n\n", @scanned))
59
+ event = @normalized[@scanned...index]
60
+ @scanned = index + 2
61
+ @count += 1
62
+ # Blank lines before an event's first field dispatch nothing (SSE
63
+ # "Parsing an event stream"), so they are not part of the event.
64
+ yield event.sub(/\A\n+/, '').force_encoding(Encoding::UTF_8)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # The chunk as text: inflated when the body turned out to be gzip,
71
+ # which its first two bytes tell. Until they have arrived nothing can be
72
+ # scanned.
73
+ # @param chunk [String] the raw bytes
74
+ # @return [String, nil] nil while the body's encoding is not known yet
75
+ def decoded(chunk)
76
+ return inflate(chunk) if @inflater
77
+ return chunk if @head.frozen?
78
+
79
+ @head << chunk
80
+ return nil if @head.bytesize < GZIP_MAGIC.bytesize
81
+
82
+ head = @head
83
+ @head = ''.b.freeze
84
+ return head unless head.start_with?(GZIP_MAGIC)
85
+
86
+ @inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
87
+ inflate(head)
88
+ end
89
+
90
+ # @param bytes [String] gzip bytes as they arrived
91
+ # @return [String, nil] the text they expand to; nil once the stream is unusable
92
+ def inflate(bytes)
93
+ text = BoundedInflate.inflate(@inflater, bytes, @max_inflated_bytes, @inflated)
94
+ return stop_scanning if text.nil?
95
+
96
+ @inflated += text.bytesize
97
+ text
98
+ rescue Zlib::Error
99
+ stop_scanning
100
+ end
101
+
102
+ # @return [nil]
103
+ def stop_scanning
104
+ @sse = false
105
+ @normalized.clear
106
+ nil
107
+ end
108
+
109
+ # Whether the body is an event stream worth scanning, settled from its
110
+ # first line after any leading blank lines: one that does not start like
111
+ # an event stream (JSON, anything else) is never scanned and never
112
+ # buffered here.
113
+ #
114
+ # The verdict waits for that first line to be decidable. A field name is
115
+ # what says "event stream", and a name split across chunks ("x-igno" +
116
+ # "re: 1\n") is not one yet: settling on its first bytes would answer
117
+ # "not an event stream" for a stream that is one, and nothing on it —
118
+ # a server's request awaiting its answer, a progress notification —
119
+ # would ever be delivered. A JSON body is refused on its first byte,
120
+ # since no field name may open with one.
121
+ # @return [Boolean] false while too little has arrived to tell
122
+ def scanning?
123
+ return @sse unless @sse.nil?
124
+
125
+ # The UTF-8 decode step of the SSE algorithm drops one leading BOM.
126
+ @normalized.delete_prefix!(BOM) if @scanned.zero?
127
+ content = @normalized.sub(/\A\n+/, '')
128
+ return settle(false) if content.match?(/\A[{\[]/n)
129
+ # A colon ends a field name; so does the line itself, since a line
130
+ # with no colon is a field whose value is empty.
131
+ return false unless content.match?(/[:\n]/n)
132
+
133
+ settle(content.match?(SSE_START) || !content.match?(/\A[^\n]*:/n))
134
+ end
135
+
136
+ # @param verdict [Boolean] whether this body is an event stream
137
+ # @return [Boolean] the verdict
138
+ def settle(verdict)
139
+ @sse = verdict
140
+ @normalized.clear unless verdict
141
+ verdict
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require_relative 'bounded_inflate'
5
+
6
+ module MCPClient
7
+ module HttpTransportBase
8
+ # How one HTTP exchange is bounded and read as it arrives: the socket
9
+ # timeout and overall deadline of a request, the listener that gets the
10
+ # response stream's events while the body is still open, and the count
11
+ # of events it already handled once the completed body is parsed.
12
+ module StreamCapture
13
+ private
14
+
15
+ # The socket timeout and the overall deadline of one HTTP exchange.
16
+ #
17
+ # MCP 2026-07-28 cancellation/timeouts: implementations "SHOULD always
18
+ # enforce a maximum timeout regardless of progress". Faraday's socket
19
+ # timeout only bounds the gap between reads, which a stream of keep-alive
20
+ # comments resets forever, so every request gets a deadline the capture
21
+ # middleware checks as the body arrives. A caller-supplied deadline (the
22
+ # probe and its re-issue share one) is honoured as the time left on it:
23
+ # the socket timeout is clamped to it, so a silent server cannot stretch
24
+ # the replacement out to a full timeout of its own.
25
+ # @param timeout [Numeric, nil] per-request timeout override
26
+ # @param deadline [Float, nil] monotonic instant the exchange must finish by
27
+ # @return [Array(Numeric, Float)] the socket timeout and the deadline
28
+ # @raise [MCPClient::Errors::RequestTimeoutError] when the deadline already passed
29
+ def request_bounds(timeout, deadline)
30
+ budget = timeout || @read_timeout
31
+ return [budget, budget && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + budget)] unless deadline
32
+
33
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ raise MCPClient::Errors::RequestTimeoutError, 'Request timed out: its deadline has passed' if remaining <= 0
35
+
36
+ [budget ? [budget, remaining].min : remaining, deadline]
37
+ end
38
+
39
+ # Run one HTTP exchange under its deadline, whatever the socket does.
40
+ #
41
+ # Faraday's socket timeout bounds the gap between reads, and every read
42
+ # restarts it: a server that sends an event late in the budget, or head
43
+ # bytes forever, outlives the bound the caller asked for — the second
44
+ # never even reaches the body callback that checks the deadline. MCP
45
+ # 2026-07-28 cancellation/timeouts asks for a maximum timeout "regardless
46
+ # of progress", so a watchdog ends the exchange at the deadline whatever
47
+ # the socket is doing.
48
+ #
49
+ # Raising into the requesting thread is the only way to break its
50
+ # blocking read from outside. The watchdog fires at most once, never
51
+ # after the request settled, and is always torn down; if it loses the
52
+ # race by the microseconds between the answer arriving and the request
53
+ # being marked settled, the answer stands rather than the timeout.
54
+ # @param deadline [Float, nil] monotonic instant the exchange must finish by
55
+ # @return [Object] whatever the block returns
56
+ # @raise [Faraday::TimeoutError] when the deadline passes first
57
+ def with_request_watchdog(deadline)
58
+ return yield unless deadline
59
+
60
+ target = Thread.current
61
+ lock = Mutex.new
62
+ settled = false
63
+ watchdog = Thread.new do
64
+ remaining = deadline - monotonic_now
65
+ sleep(remaining) if remaining.positive?
66
+ lock.synchronize do
67
+ target.raise(Faraday::TimeoutError, 'Request exceeded its deadline') unless settled
68
+ end
69
+ end
70
+
71
+ begin
72
+ answered = yield
73
+ lock.synchronize { settled = true }
74
+ answered
75
+ rescue Faraday::TimeoutError
76
+ raise if answered.nil?
77
+
78
+ lock.synchronize { settled = true }
79
+ answered
80
+ ensure
81
+ lock.synchronize { settled = true }
82
+ watchdog.kill
83
+ end
84
+ end
85
+
86
+ # A callback handed every complete SSE event of the response stream as
87
+ # it arrives, or nil to read the stream only once it has ended. The base
88
+ # transport parses completed bodies; ServerHTTP overrides this.
89
+ # @param _request [Hash] the JSON-RPC message being sent
90
+ # @return [Proc, nil]
91
+ def response_stream_listener(_request)
92
+ nil
93
+ end
94
+
95
+ # The bound on a gzip body's expansion, for the stream scanner and the
96
+ # salvage of a delivered compressed answer (Streamable HTTP configures
97
+ # one; plain HTTP never asks for gzip).
98
+ # @return [Integer, nil]
99
+ def inflate_limit
100
+ respond_to?(:max_decompressed_body_bytes, true) ? max_decompressed_body_bytes : nil
101
+ end
102
+
103
+ # A response body that arrived gzip-encoded, inflated so the salvage
104
+ # can tell whether the answer is in it. Streamable HTTP offers gzip on
105
+ # every request, so a delivered answer is usually a delivered
106
+ # *compressed* answer; treating those bytes as a lost stream would
107
+ # re-issue a tools/call the server already ran.
108
+ # An expansion the bound refuses is not a lost answer either: the
109
+ # server ran the request and sent its result, and only this client's
110
+ # ceiling stands in the way. Re-issuing there would run the request a
111
+ # second time, so the caller is told the response was too large — the
112
+ # same answer the ordinary (unbroken) path gives.
113
+ # @param body [String] the captured bytes
114
+ # @return [String, nil] the expanded body; nil when the deflate stream
115
+ # itself stopped short of what it needs to be read
116
+ # @raise [MCPClient::Errors::ResponseTooLargeError] when the body expands
117
+ # past the configured bound
118
+ def inflate_delivered_gzip(body)
119
+ inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
120
+ text = BoundedInflate.inflate(inflater, body, inflate_limit)
121
+ return text unless text.nil?
122
+
123
+ raise MCPClient::Errors::ResponseTooLargeError,
124
+ "Gzip response expanded beyond #{inflate_limit} bytes"
125
+ rescue Zlib::Error
126
+ nil
127
+ ensure
128
+ inflater&.close
129
+ end
130
+
131
+ # How many events of the response stream were already handed to the
132
+ # stream listener while the body arrived (see ResponseBodyCapture).
133
+ # @param response [Faraday::Response, NormalizedResponse] the completed response
134
+ # @return [Integer]
135
+ def live_event_count(response)
136
+ capture_state(response)[:mcp_live_events].to_i
137
+ end
138
+
139
+ # The failure the stream listener raised while the body arrived, if
140
+ # any: it could not abort the read (see ResponseBodyCapture), so the
141
+ # transport raises it in place of the response it was interleaved with.
142
+ # @param response [Faraday::Response, NormalizedResponse] the completed response
143
+ # @return [StandardError, nil]
144
+ def stream_listener_error(response)
145
+ capture_state(response)[:mcp_stream_error]
146
+ end
147
+
148
+ # @param response [Faraday::Response, NormalizedResponse] a completed response
149
+ # @return [Hash] the ResponseBodyCapture state of its exchange (empty when none)
150
+ def capture_state(response)
151
+ context = if response.respond_to?(:env) && response.env.respond_to?(:request)
152
+ response.env.request&.context
153
+ elsif response.respond_to?(:context)
154
+ response.context
155
+ end
156
+ context.is_a?(Hash) ? context : {}
157
+ end
158
+ end
159
+ end
160
+ end