ruby-mcp-client 2.0.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 +914 -49
- 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 +1367 -311
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -36
- 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 +851 -478
- 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/elicitation_validator.rb +56 -13
- data/lib/mcp_client/errors.rb +481 -6
- 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 +669 -100
- 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 +967 -11
- 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 -2
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_link.rb +0 -2
- data/lib/mcp_client/resource_template.rb +4 -2
- 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 +893 -175
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +12 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +220 -5
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +146 -30
- data/lib/mcp_client/server_sse/origin_policy.rb +57 -0
- data/lib/mcp_client/server_sse/reconnect_monitor.rb +7 -1
- data/lib/mcp_client/server_sse/sse_parser.rb +77 -13
- data/lib/mcp_client/server_sse.rb +310 -76
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1004 -29
- data/lib/mcp_client/server_stdio.rb +778 -186
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +262 -28
- data/lib/mcp_client/server_streamable_http.rb +547 -157
- 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 -5
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +115 -19
- metadata +71 -7
|
@@ -12,12 +12,45 @@ module MCPClient
|
|
|
12
12
|
module JsonRpcTransport
|
|
13
13
|
include HttpTransportBase
|
|
14
14
|
|
|
15
|
+
# Default ceiling on the expanded size of a gzip-encoded response body.
|
|
16
|
+
# The peer controls the compression ratio, so without a bound a tiny
|
|
17
|
+
# compressed response ("gzip bomb") could expand to an arbitrarily large
|
|
18
|
+
# string and exhaust host memory before JSON parsing.
|
|
19
|
+
#
|
|
20
|
+
# Hosts that legitimately exchange very large payloads (e.g. base64
|
|
21
|
+
# resource blobs or audio) can raise it per server with the
|
|
22
|
+
# max_decompressed_body_bytes option, so that whether a response is
|
|
23
|
+
# accepted does not depend on the server's choice to gzip it.
|
|
24
|
+
MAX_DECOMPRESSED_BODY_BYTES = 64 * 1024 * 1024
|
|
25
|
+
DECOMPRESS_CHUNK_BYTES = 64 * 1024
|
|
26
|
+
|
|
15
27
|
private
|
|
16
28
|
|
|
29
|
+
# Whether a server-supplied SSE event id may be retained as the
|
|
30
|
+
# resumption cursor: non-empty, bounded, and safe to place in an HTTP
|
|
31
|
+
# header. Shared by every SSE parsing path (GET events stream, POST
|
|
32
|
+
# response stream, and resumed GET), since all three feed the same
|
|
33
|
+
# Last-Event-ID header.
|
|
34
|
+
# @param id [String, nil] the raw id field
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def retainable_event_id?(id)
|
|
37
|
+
return false if id.nil? || id.empty?
|
|
38
|
+
|
|
39
|
+
if id.length > MAX_EVENT_ID_LENGTH
|
|
40
|
+
@logger.warn("Ignoring oversized SSE event id (#{id.length} chars)")
|
|
41
|
+
return false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
return true if id.match?(EVENT_ID_PATTERN)
|
|
45
|
+
|
|
46
|
+
@logger.warn('Ignoring SSE event id with characters illegal in a header value')
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
17
50
|
# Log HTTP response for Streamable HTTP
|
|
18
51
|
# @param response [Faraday::Response] the HTTP response
|
|
19
52
|
def log_response(response)
|
|
20
|
-
@logger.debug("Received Streamable HTTP response: #{response.status} #{response.body}")
|
|
53
|
+
@logger.debug("Received Streamable HTTP response: #{response.status} (#{describe_body_size(response.body)})")
|
|
21
54
|
end
|
|
22
55
|
|
|
23
56
|
# Parse a Streamable HTTP JSON-RPC response (JSON or SSE format)
|
|
@@ -27,25 +60,165 @@ module MCPClient
|
|
|
27
60
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
28
61
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
29
62
|
def parse_response(response, request = nil)
|
|
63
|
+
# Host code a stream listener reached raised while the body was still
|
|
64
|
+
# arriving: that is this exchange's failure (already marked as a
|
|
65
|
+
# nested exchange's, so no recovery acts on it), raised in place of
|
|
66
|
+
# the response it was interleaved with.
|
|
67
|
+
failure = stream_listener_error(response)
|
|
68
|
+
raise failure if failure
|
|
69
|
+
|
|
30
70
|
body = response.body
|
|
31
71
|
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
|
32
72
|
content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
|
|
33
73
|
|
|
34
|
-
body =
|
|
35
|
-
body = body&.strip
|
|
74
|
+
body = decompress_gzip(body) if content_encoding.include?('gzip')
|
|
36
75
|
|
|
37
76
|
# Determine response format based on Content-Type header per MCP 2025 spec
|
|
38
77
|
data = if content_type.include?('text/event-stream')
|
|
39
|
-
# Parse SSE-formatted response for streaming
|
|
40
|
-
|
|
41
|
-
|
|
78
|
+
# Parse SSE-formatted response for streaming. The body is
|
|
79
|
+
# not stripped: its final blank line is the last event's
|
|
80
|
+
# terminator, and without it that event was never dispatched.
|
|
81
|
+
parse_sse_response(body.to_s, request && request['id'], live_event_count(response))
|
|
82
|
+
elsif body.is_a?(String)
|
|
42
83
|
# Parse regular JSON response (default for Streamable HTTP)
|
|
43
|
-
JSON.parse(body)
|
|
84
|
+
JSON.parse(body.strip)
|
|
85
|
+
else
|
|
86
|
+
# A host's `conn.response :json` middleware decodes the body
|
|
87
|
+
# before it reaches here — the README offers that middleware
|
|
88
|
+
# for the error path, and it applies to every response — so an
|
|
89
|
+
# already-decoded object is taken as it is rather than parsed
|
|
90
|
+
# a second time. An event-stream body is not JSON and reaches
|
|
91
|
+
# the branch above as the text it was sent as.
|
|
92
|
+
body
|
|
44
93
|
end
|
|
45
94
|
|
|
46
95
|
process_jsonrpc_response(data)
|
|
47
96
|
rescue JSON::ParserError => e
|
|
48
|
-
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e
|
|
97
|
+
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
98
|
+
rescue Zlib::Error => e
|
|
99
|
+
# Streamable HTTP always offers gzip, so a stream that stops before the
|
|
100
|
+
# gzip footer arrives here rather than as a socket failure. No response
|
|
101
|
+
# was delivered, which on a modern server means the in-flight request
|
|
102
|
+
# is lost and MUST be re-issued with a new id. A body that is complete
|
|
103
|
+
# but corrupt (bad CRC, bad deflate data) was not cut short: the
|
|
104
|
+
# server answered, badly, and running the request again would not
|
|
105
|
+
# make it answer better.
|
|
106
|
+
unless modern? && truncated_gzip?(e)
|
|
107
|
+
raise MCPClient::Errors::TransportError, "Invalid gzip response from server: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A stream cut after the deflate data — footer only — still delivered
|
|
111
|
+
# its answer; a delivered answer settles the request, as on a socket
|
|
112
|
+
# that died after the final event (re-issuing would run it again).
|
|
113
|
+
delivered = delivered_before_truncation(response, request, e)
|
|
114
|
+
return delivered unless delivered.nil?
|
|
115
|
+
|
|
116
|
+
raise MCPClient::Errors::ResponseStreamClosedError,
|
|
117
|
+
"Response stream closed before delivering the response: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The answer a gzip body that lost its footer delivered, parsed the
|
|
121
|
+
# ordinary way; nil when the deflate data itself stopped short of it.
|
|
122
|
+
# @param response [Faraday::Response] the response whose body failed to decode
|
|
123
|
+
# @param request [Hash, nil] the originating JSON-RPC request
|
|
124
|
+
# @param error [Zlib::Error] the decode failure
|
|
125
|
+
# @return [Object, nil] the parsed result
|
|
126
|
+
def delivered_before_truncation(response, request, error)
|
|
127
|
+
return nil unless request.is_a?(Hash) && request.key?('id')
|
|
128
|
+
|
|
129
|
+
body = inflate_delivered_gzip(response.body.to_s)
|
|
130
|
+
return nil if body.nil? || body.empty?
|
|
131
|
+
|
|
132
|
+
sse = sse_framed_body?(body)
|
|
133
|
+
body = complete_sse_events(body) if sse
|
|
134
|
+
return nil if body.empty? || !body_carries_response?(body, sse, request['id'])
|
|
135
|
+
|
|
136
|
+
@logger.warn("Response stream ended after the response arrived (#{error.message}); " \
|
|
137
|
+
"keeping the delivered #{request['method']} response instead of re-issuing it")
|
|
138
|
+
data = sse ? parse_sse_response(body, request['id'], live_event_count(response)) : JSON.parse(body.strip)
|
|
139
|
+
process_jsonrpc_response(data)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Every complete event of a response stream is handed over while the
|
|
143
|
+
# body is still arriving, so a progress notification reaches the host
|
|
144
|
+
# before the tool finishes — and before a timeout ends the stream —
|
|
145
|
+
# and a legacy server's request on the stream is answered before the
|
|
146
|
+
# server has to end the response. A notification has no response
|
|
147
|
+
# stream worth reading incrementally.
|
|
148
|
+
# @param request [Hash] the JSON-RPC message being sent
|
|
149
|
+
# @return [Proc, nil]
|
|
150
|
+
def response_stream_listener(request)
|
|
151
|
+
return nil unless request.is_a?(Hash) && request.key?('id')
|
|
152
|
+
|
|
153
|
+
->(event) { dispatch_live_sse_event(event) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Act on one event as it arrives: requests and notifications are
|
|
157
|
+
# routed now, responses wait for the completed body. A failing
|
|
158
|
+
# callback is logged rather than allowed to abort the read of the
|
|
159
|
+
# response it was interleaved with.
|
|
160
|
+
# @param event [String] one complete, LF-normalized SSE event
|
|
161
|
+
# @return [void]
|
|
162
|
+
def dispatch_live_sse_event(event)
|
|
163
|
+
events, = extract_sse_events("#{event}\n\n")
|
|
164
|
+
events.each do |parsed|
|
|
165
|
+
track_sse_event_id(parsed)
|
|
166
|
+
message = sse_event_json_rpc_message(parsed)
|
|
167
|
+
dispatch_server_message(message) if message.is_a?(Hash) && message['method']
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# How many of the events the completed body splits into were handed to
|
|
172
|
+
# the stream listener while it arrived: the scanner counts every
|
|
173
|
+
# terminated block, blank or comment-only ones included, so the same
|
|
174
|
+
# split maps its count onto the events the body parser keeps.
|
|
175
|
+
# @param sse_body [String] the text/event-stream body
|
|
176
|
+
# @param live [Integer] blocks the scanner dispatched
|
|
177
|
+
# @return [Integer] events among them the body parser would keep
|
|
178
|
+
def live_message_events(sse_body, live)
|
|
179
|
+
return 0 if live.zero?
|
|
180
|
+
|
|
181
|
+
blocks = normalize_sse_newlines(sse_body).split("\n\n", -1)
|
|
182
|
+
blocks.first(live).count { |block| block.lines.any? { |line| line.strip.start_with?('data:', 'id:') } }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Whether a gzip failure means the body stopped before its end rather
|
|
186
|
+
# than carrying bad data.
|
|
187
|
+
# @param error [Zlib::Error] the decompression failure
|
|
188
|
+
# @return [Boolean]
|
|
189
|
+
def truncated_gzip?(error)
|
|
190
|
+
error.is_a?(Zlib::GzipFile::NoFooter) || error.is_a?(Zlib::BufError) ||
|
|
191
|
+
error.message.to_s.match?(/unexpected end|footer/i)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Incrementally decompress a gzip response body, aborting once the
|
|
195
|
+
# expanded output exceeds the configured ceiling.
|
|
196
|
+
# @param body [String] the gzip-compressed response body
|
|
197
|
+
# @return [String] the decompressed body
|
|
198
|
+
# @raise [MCPClient::Errors::ResponseTooLargeError] if the expansion limit is exceeded
|
|
199
|
+
def decompress_gzip(body)
|
|
200
|
+
limit = max_decompressed_body_bytes
|
|
201
|
+
reader = Zlib::GzipReader.new(StringIO.new(body))
|
|
202
|
+
decompressed = +''
|
|
203
|
+
while (chunk = reader.read(DECOMPRESS_CHUNK_BYTES))
|
|
204
|
+
decompressed << chunk
|
|
205
|
+
next unless decompressed.bytesize > limit
|
|
206
|
+
|
|
207
|
+
# ResponseTooLargeError (not a plain TransportError) so with_retry
|
|
208
|
+
# does not re-POST a request the server has already executed.
|
|
209
|
+
raise MCPClient::Errors::ResponseTooLargeError,
|
|
210
|
+
"Gzip response expanded beyond #{limit} bytes"
|
|
211
|
+
end
|
|
212
|
+
decompressed
|
|
213
|
+
ensure
|
|
214
|
+
reader&.close
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Configured ceiling for decompressed response bodies.
|
|
218
|
+
# @return [Integer] positive byte limit
|
|
219
|
+
def max_decompressed_body_bytes
|
|
220
|
+
configured = defined?(@max_decompressed_body_bytes) ? @max_decompressed_body_bytes : nil
|
|
221
|
+
configured || MAX_DECOMPRESSED_BODY_BYTES
|
|
49
222
|
end
|
|
50
223
|
|
|
51
224
|
# Parse a Server-Sent Event formatted response body.
|
|
@@ -60,20 +233,35 @@ module MCPClient
|
|
|
60
233
|
# @param request_id [Integer, String, nil] id of the originating request
|
|
61
234
|
# @return [Hash] the parsed JSON-RPC response
|
|
62
235
|
# @raise [MCPClient::Errors::TransportError] if no response is found
|
|
63
|
-
def parse_sse_response(sse_body, request_id = nil)
|
|
236
|
+
def parse_sse_response(sse_body, request_id = nil, live = 0)
|
|
64
237
|
events, retry_ms = extract_sse_events(sse_body)
|
|
65
238
|
|
|
66
|
-
|
|
239
|
+
if events.empty?
|
|
240
|
+
# An empty stream is a stream that closed before delivering the
|
|
241
|
+
# response; on a modern server that means re-issue, not resume.
|
|
242
|
+
if modern?
|
|
243
|
+
raise MCPClient::Errors::ResponseStreamClosedError, 'SSE stream closed before delivering the response'
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
raise MCPClient::Errors::TransportError, 'No data found in SSE response'
|
|
247
|
+
end
|
|
67
248
|
|
|
68
|
-
responses, saw_invalid_json = route_sse_events(events)
|
|
249
|
+
responses, saw_invalid_json = route_sse_events(events, live_message_events(sse_body, live))
|
|
69
250
|
matched = select_sse_response(responses, request_id)
|
|
70
251
|
return matched if matched
|
|
71
252
|
|
|
253
|
+
# Every event that reaches here is terminated: an unterminated final
|
|
254
|
+
# event is dropped before parsing. So invalid JSON is not a break that
|
|
255
|
+
# landed inside an event — the server processed the request and
|
|
256
|
+
# answered, however badly, and re-issuing would run it a second time.
|
|
72
257
|
if saw_invalid_json
|
|
73
258
|
raise MCPClient::Errors::TransportError,
|
|
74
259
|
'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
|
|
75
260
|
end
|
|
76
261
|
|
|
262
|
+
# A stream that ended between events carries no answer at all: the
|
|
263
|
+
# in-flight request was lost and takes the re-issue path (2026-07-28
|
|
264
|
+
# changelog, major change 9).
|
|
77
265
|
resume_or_fail(events, request_id, retry_ms)
|
|
78
266
|
end
|
|
79
267
|
|
|
@@ -88,7 +276,16 @@ module MCPClient
|
|
|
88
276
|
# @raise [MCPClient::Errors::ServerError] when resumption fails
|
|
89
277
|
# @raise [MCPClient::Errors::TransportError] when no cursor was received
|
|
90
278
|
def resume_or_fail(events, request_id, retry_ms = nil)
|
|
91
|
-
|
|
279
|
+
# MCP 2026-07-28 removed SSE resumability: the in-flight request is
|
|
280
|
+
# lost and must be re-issued as a new request (see rpc_request).
|
|
281
|
+
if modern?
|
|
282
|
+
raise MCPClient::Errors::ResponseStreamClosedError,
|
|
283
|
+
'SSE stream closed before delivering the response'
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Only a validated id may become a cursor: it is sent back as a
|
|
287
|
+
# Last-Event-ID header on the resumption GET.
|
|
288
|
+
cursor = events.reverse.find { |e| retainable_event_id?(e[:id]) }&.dig(:id)
|
|
92
289
|
if request_id && cursor
|
|
93
290
|
# Resume with THIS stream's cursor and retry directive (both are
|
|
94
291
|
# per-stream), not the shared @last_event_id / @sse_retry_ms which a
|
|
@@ -116,7 +313,9 @@ module MCPClient
|
|
|
116
313
|
retry_ms = nil
|
|
117
314
|
current_event = { type: 'message', data_lines: [], id: nil }
|
|
118
315
|
|
|
119
|
-
|
|
316
|
+
# SSE line terminators are CRLF, CR or LF; a server framing its events
|
|
317
|
+
# with bare CR still delimits them, so normalize before splitting.
|
|
318
|
+
normalize_sse_newlines(sse_body).lines.each do |line|
|
|
120
319
|
line = line.strip
|
|
121
320
|
|
|
122
321
|
if line.empty?
|
|
@@ -141,8 +340,18 @@ module MCPClient
|
|
|
141
340
|
end
|
|
142
341
|
end
|
|
143
342
|
|
|
144
|
-
#
|
|
145
|
-
|
|
343
|
+
# An event is dispatched at its terminating blank line, so a body that
|
|
344
|
+
# ends inside an event delivered nothing for it. On a modern server
|
|
345
|
+
# the event is dropped — and a missing response re-issued — exactly
|
|
346
|
+
# as when the socket cut it; a legacy server keeps the benefit of the
|
|
347
|
+
# doubt this transport always gave it.
|
|
348
|
+
if sse_event_present?(current_event)
|
|
349
|
+
if modern?
|
|
350
|
+
@logger.warn('Dropping an SSE event the response stream ended without terminating')
|
|
351
|
+
else
|
|
352
|
+
events << current_event
|
|
353
|
+
end
|
|
354
|
+
end
|
|
146
355
|
[events, retry_ms]
|
|
147
356
|
end
|
|
148
357
|
|
|
@@ -155,24 +364,20 @@ module MCPClient
|
|
|
155
364
|
# Track event ids for resumability, dispatch interleaved server messages
|
|
156
365
|
# (requests, notifications, pings) and collect response candidates.
|
|
157
366
|
# @param events [Array<Hash>] parsed SSE events
|
|
367
|
+
# @param live [Integer] leading events already routed as they arrived
|
|
158
368
|
# @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
|
|
159
|
-
def route_sse_events(events)
|
|
369
|
+
def route_sse_events(events, live = 0)
|
|
160
370
|
responses = []
|
|
161
371
|
saw_invalid_json = false
|
|
162
372
|
|
|
163
|
-
events.
|
|
164
|
-
if
|
|
165
|
-
|
|
166
|
-
@logger.debug("Tracking event ID for resumability: #{event[:id]}")
|
|
167
|
-
end
|
|
168
|
-
next unless event[:type] == 'message'
|
|
169
|
-
|
|
170
|
-
message = parse_sse_event_data(event[:data_lines].join("\n"))
|
|
373
|
+
events.each_with_index do |event, index|
|
|
374
|
+
track_sse_event_id(event) if index >= live
|
|
375
|
+
message = sse_event_json_rpc_message(event)
|
|
171
376
|
saw_invalid_json = true if message == :invalid
|
|
172
377
|
next unless message.is_a?(Hash)
|
|
173
378
|
|
|
174
379
|
if message['method']
|
|
175
|
-
dispatch_server_message(message)
|
|
380
|
+
dispatch_server_message(message) if index >= live
|
|
176
381
|
else
|
|
177
382
|
responses << message
|
|
178
383
|
end
|
|
@@ -181,6 +386,27 @@ module MCPClient
|
|
|
181
386
|
[responses, saw_invalid_json]
|
|
182
387
|
end
|
|
183
388
|
|
|
389
|
+
# The POST SSE stream is peer-controlled like the GET one, so its ids
|
|
390
|
+
# get the same bound/charset check before being retained or echoed in
|
|
391
|
+
# a Last-Event-ID header.
|
|
392
|
+
# @param event [Hash] a parsed SSE event
|
|
393
|
+
# @return [void]
|
|
394
|
+
def track_sse_event_id(event)
|
|
395
|
+
return unless event[:id] && !event[:id].empty? && !modern?
|
|
396
|
+
|
|
397
|
+
@mutex.synchronize { @last_event_id = event[:id] } if retainable_event_id?(event[:id])
|
|
398
|
+
@logger.debug("Tracking event ID for resumability: #{event[:id]}")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# @param event [Hash] a parsed SSE event
|
|
402
|
+
# @return [Hash, Symbol, nil] its JSON-RPC message, :invalid, or nil for
|
|
403
|
+
# an event of another type or without an object payload
|
|
404
|
+
def sse_event_json_rpc_message(event)
|
|
405
|
+
return nil unless event[:type] == 'message'
|
|
406
|
+
|
|
407
|
+
parse_sse_event_data(event[:data_lines].join("\n"))
|
|
408
|
+
end
|
|
409
|
+
|
|
184
410
|
# Parse the data payload of a single SSE event.
|
|
185
411
|
# @param json_data [String] the joined data lines
|
|
186
412
|
# @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
|
|
@@ -190,10 +416,12 @@ module MCPClient
|
|
|
190
416
|
message = JSON.parse(json_data)
|
|
191
417
|
return message if message.is_a?(Hash)
|
|
192
418
|
|
|
193
|
-
|
|
419
|
+
# Type only: the value is peer-controlled payload and may carry tool
|
|
420
|
+
# arguments, results or elicitation content.
|
|
421
|
+
@logger.warn("Skipping non-object JSON-RPC message in SSE event (#{message.class})")
|
|
194
422
|
nil
|
|
195
423
|
rescue JSON::ParserError => e
|
|
196
|
-
@logger.warn("Skipping invalid JSON in SSE event: #{e
|
|
424
|
+
@logger.warn("Skipping invalid JSON in SSE event: #{describe_parse_error(e, json_data)}")
|
|
197
425
|
:invalid
|
|
198
426
|
end
|
|
199
427
|
|
|
@@ -208,7 +436,13 @@ module MCPClient
|
|
|
208
436
|
responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
|
|
209
437
|
end
|
|
210
438
|
|
|
211
|
-
|
|
439
|
+
# A legacy server that echoes ids loosely — a string where an integer
|
|
440
|
+
# went out, or an id an intermediary rewrote — gets the benefit of the
|
|
441
|
+
# doubt when its stream carried exactly one response. A modern one
|
|
442
|
+
# does not: no response to THIS request arrived, so the request was
|
|
443
|
+
# lost and MCP 2026-07-28 says to re-issue it rather than complete it
|
|
444
|
+
# with the answer to something else.
|
|
445
|
+
if matched.nil? && responses.length == 1 && !modern?
|
|
212
446
|
matched = responses.first
|
|
213
447
|
@logger.warn(
|
|
214
448
|
"SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \
|