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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|