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
|
@@ -60,25 +60,135 @@ module MCPClient
|
|
|
60
60
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
61
61
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
62
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
|
+
|
|
63
70
|
body = response.body
|
|
64
71
|
content_type = response.headers['content-type'] || response.headers['Content-Type'] || ''
|
|
65
72
|
content_encoding = response.headers['content-encoding'] || response.headers['Content-Encoding'] || ''
|
|
66
73
|
|
|
67
74
|
body = decompress_gzip(body) if content_encoding.include?('gzip')
|
|
68
|
-
body = body&.strip
|
|
69
75
|
|
|
70
76
|
# Determine response format based on Content-Type header per MCP 2025 spec
|
|
71
77
|
data = if content_type.include?('text/event-stream')
|
|
72
|
-
# Parse SSE-formatted response for streaming
|
|
73
|
-
|
|
74
|
-
|
|
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)
|
|
75
83
|
# Parse regular JSON response (default for Streamable HTTP)
|
|
76
|
-
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
|
|
77
93
|
end
|
|
78
94
|
|
|
79
95
|
process_jsonrpc_response(data)
|
|
80
96
|
rescue JSON::ParserError => e
|
|
81
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)
|
|
82
192
|
end
|
|
83
193
|
|
|
84
194
|
# Incrementally decompress a gzip response body, aborting once the
|
|
@@ -123,20 +233,35 @@ module MCPClient
|
|
|
123
233
|
# @param request_id [Integer, String, nil] id of the originating request
|
|
124
234
|
# @return [Hash] the parsed JSON-RPC response
|
|
125
235
|
# @raise [MCPClient::Errors::TransportError] if no response is found
|
|
126
|
-
def parse_sse_response(sse_body, request_id = nil)
|
|
236
|
+
def parse_sse_response(sse_body, request_id = nil, live = 0)
|
|
127
237
|
events, retry_ms = extract_sse_events(sse_body)
|
|
128
238
|
|
|
129
|
-
|
|
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
|
|
130
248
|
|
|
131
|
-
responses, saw_invalid_json = route_sse_events(events)
|
|
249
|
+
responses, saw_invalid_json = route_sse_events(events, live_message_events(sse_body, live))
|
|
132
250
|
matched = select_sse_response(responses, request_id)
|
|
133
251
|
return matched if matched
|
|
134
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.
|
|
135
257
|
if saw_invalid_json
|
|
136
258
|
raise MCPClient::Errors::TransportError,
|
|
137
259
|
'Invalid JSON response from server: SSE stream contained no valid JSON-RPC response'
|
|
138
260
|
end
|
|
139
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).
|
|
140
265
|
resume_or_fail(events, request_id, retry_ms)
|
|
141
266
|
end
|
|
142
267
|
|
|
@@ -151,6 +276,13 @@ module MCPClient
|
|
|
151
276
|
# @raise [MCPClient::Errors::ServerError] when resumption fails
|
|
152
277
|
# @raise [MCPClient::Errors::TransportError] when no cursor was received
|
|
153
278
|
def resume_or_fail(events, request_id, retry_ms = nil)
|
|
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
|
+
|
|
154
286
|
# Only a validated id may become a cursor: it is sent back as a
|
|
155
287
|
# Last-Event-ID header on the resumption GET.
|
|
156
288
|
cursor = events.reverse.find { |e| retainable_event_id?(e[:id]) }&.dig(:id)
|
|
@@ -181,7 +313,9 @@ module MCPClient
|
|
|
181
313
|
retry_ms = nil
|
|
182
314
|
current_event = { type: 'message', data_lines: [], id: nil }
|
|
183
315
|
|
|
184
|
-
|
|
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|
|
|
185
319
|
line = line.strip
|
|
186
320
|
|
|
187
321
|
if line.empty?
|
|
@@ -206,8 +340,18 @@ module MCPClient
|
|
|
206
340
|
end
|
|
207
341
|
end
|
|
208
342
|
|
|
209
|
-
#
|
|
210
|
-
|
|
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
|
|
211
355
|
[events, retry_ms]
|
|
212
356
|
end
|
|
213
357
|
|
|
@@ -220,27 +364,20 @@ module MCPClient
|
|
|
220
364
|
# Track event ids for resumability, dispatch interleaved server messages
|
|
221
365
|
# (requests, notifications, pings) and collect response candidates.
|
|
222
366
|
# @param events [Array<Hash>] parsed SSE events
|
|
367
|
+
# @param live [Integer] leading events already routed as they arrived
|
|
223
368
|
# @return [Array(Array<Hash>, Boolean)] response candidates and whether invalid JSON was seen
|
|
224
|
-
def route_sse_events(events)
|
|
369
|
+
def route_sse_events(events, live = 0)
|
|
225
370
|
responses = []
|
|
226
371
|
saw_invalid_json = false
|
|
227
372
|
|
|
228
|
-
events.
|
|
229
|
-
if
|
|
230
|
-
|
|
231
|
-
# ids get the same bound/charset check before being retained or
|
|
232
|
-
# echoed in a Last-Event-ID header.
|
|
233
|
-
@mutex.synchronize { @last_event_id = event[:id] } if retainable_event_id?(event[:id])
|
|
234
|
-
@logger.debug("Tracking event ID for resumability: #{event[:id]}")
|
|
235
|
-
end
|
|
236
|
-
next unless event[:type] == 'message'
|
|
237
|
-
|
|
238
|
-
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)
|
|
239
376
|
saw_invalid_json = true if message == :invalid
|
|
240
377
|
next unless message.is_a?(Hash)
|
|
241
378
|
|
|
242
379
|
if message['method']
|
|
243
|
-
dispatch_server_message(message)
|
|
380
|
+
dispatch_server_message(message) if index >= live
|
|
244
381
|
else
|
|
245
382
|
responses << message
|
|
246
383
|
end
|
|
@@ -249,6 +386,27 @@ module MCPClient
|
|
|
249
386
|
[responses, saw_invalid_json]
|
|
250
387
|
end
|
|
251
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
|
+
|
|
252
410
|
# Parse the data payload of a single SSE event.
|
|
253
411
|
# @param json_data [String] the joined data lines
|
|
254
412
|
# @return [Hash, Symbol, nil] the parsed message, :invalid, or nil for empty/non-object data
|
|
@@ -278,7 +436,13 @@ module MCPClient
|
|
|
278
436
|
responses.find { |msg| msg['id'] == request_id || msg['id'].to_s == request_id.to_s }
|
|
279
437
|
end
|
|
280
438
|
|
|
281
|
-
|
|
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?
|
|
282
446
|
matched = responses.first
|
|
283
447
|
@logger.warn(
|
|
284
448
|
"SSE response id #{matched['id'].inspect} does not match request id #{request_id.inspect}; " \
|