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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'deprecations'
|
|
4
|
+
|
|
3
5
|
module MCPClient
|
|
4
6
|
# Base class for MCP servers - serves as the interface for different server implementations
|
|
5
7
|
class ServerBase
|
|
@@ -7,8 +9,10 @@ module MCPClient
|
|
|
7
9
|
# @return [String] the name of the server
|
|
8
10
|
attr_reader :name
|
|
9
11
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
+
# @!attribute [r] read_timeout
|
|
13
|
+
# @return [Numeric, nil] the configured per-request timeout in seconds, when the transport has one
|
|
14
|
+
attr_reader :read_timeout
|
|
15
|
+
|
|
12
16
|
# Server-declared instructions from the initialize result, if any
|
|
13
17
|
# @return [String, nil]
|
|
14
18
|
attr_reader :instructions
|
|
@@ -25,6 +29,8 @@ module MCPClient
|
|
|
25
29
|
@client_info = info.transform_keys(&:to_s)
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
# Initialize the server with a name
|
|
33
|
+
# @param name [String, nil] server name
|
|
28
34
|
def initialize(name: nil)
|
|
29
35
|
@name = name
|
|
30
36
|
end
|
|
@@ -147,6 +153,15 @@ module MCPClient
|
|
|
147
153
|
# @param method [String] the JSON-RPC method the caller wants to send
|
|
148
154
|
# @raise [MCPClient::Errors::CapabilityError]
|
|
149
155
|
def require_capability!(*path, method:)
|
|
156
|
+
# A DiscoverResult that may no longer be reused is re-fetched on the
|
|
157
|
+
# next use of what it declared (MCP 2026-07-28 caching: a stale result
|
|
158
|
+
# is re-fetched on access) BEFORE the capability is judged at all: the
|
|
159
|
+
# server may have enabled a capability the old result lacked, or
|
|
160
|
+
# withdrawn one it still lists.
|
|
161
|
+
if respond_to?(:modern?, true) && modern? && discovery_refresh_needed?
|
|
162
|
+
@logger&.debug("The server/discover result may not be reused; refreshing it before #{method}")
|
|
163
|
+
rpc_request('server/discover')
|
|
164
|
+
end
|
|
150
165
|
return if capability?(*path)
|
|
151
166
|
|
|
152
167
|
raise MCPClient::Errors::CapabilityError,
|
|
@@ -154,11 +169,57 @@ module MCPClient
|
|
|
154
169
|
"required for #{method}"
|
|
155
170
|
end
|
|
156
171
|
|
|
172
|
+
# Whether the DiscoverResult behind the negotiated capabilities may still
|
|
173
|
+
# answer for the request about to go out.
|
|
174
|
+
#
|
|
175
|
+
# It is a cacheable result like any other, so it is bound by both of the
|
|
176
|
+
# caching rules the lists and reads obey: its ttlMs, counted from receipt
|
|
177
|
+
# (a result with no hint, or a zero, negative or malformed one, is stale
|
|
178
|
+
# at once), and — for a privately scoped result, which is what a server
|
|
179
|
+
# that declares no scope gets — the authorization context and effective
|
|
180
|
+
# parameters of the request that produced it. "Private responses MUST NOT
|
|
181
|
+
# be shared across authorization contexts (e.g. a different access token
|
|
182
|
+
# requires a different cache)", and the capabilities a server declares
|
|
183
|
+
# are exactly the kind of answer that differs between two tokens.
|
|
184
|
+
# @return [Boolean]
|
|
185
|
+
def discovery_refresh_needed?
|
|
186
|
+
return false unless respond_to?(:discovery_fresh?, true)
|
|
187
|
+
return true unless discovery_fresh?
|
|
188
|
+
return false unless respond_to?(:cache_fresh?, true)
|
|
189
|
+
|
|
190
|
+
reusable = cache_fresh?(:discover)
|
|
191
|
+
# The lookup holds the evaluation of the host's request_meta for the
|
|
192
|
+
# request it expected to follow. Nothing is sent when the result stands,
|
|
193
|
+
# so it is dropped rather than left on this thread for whichever request
|
|
194
|
+
# goes out next (see ResultCaching#release_serving_request_meta).
|
|
195
|
+
release_serving_request_meta if reusable && respond_to?(:release_serving_request_meta, true)
|
|
196
|
+
!reusable
|
|
197
|
+
end
|
|
198
|
+
|
|
157
199
|
# Clean up the server connection
|
|
158
200
|
def cleanup
|
|
159
201
|
raise NotImplementedError, 'Subclasses must implement cleanup'
|
|
160
202
|
end
|
|
161
203
|
|
|
204
|
+
# How many times this transport's session ended (cleanup, a restarted
|
|
205
|
+
# stdio process, a reconnect). Anything scoped to a session — task ids
|
|
206
|
+
# and their bookkeeping — is keyed by it, so state from a previous
|
|
207
|
+
# session never colours the next one.
|
|
208
|
+
# @return [Integer]
|
|
209
|
+
def session_epoch
|
|
210
|
+
@session_epoch || 0
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
protected
|
|
214
|
+
|
|
215
|
+
# Mark the current session as ended.
|
|
216
|
+
# @return [void]
|
|
217
|
+
def bump_session_epoch
|
|
218
|
+
@session_epoch = session_epoch + 1
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
public
|
|
222
|
+
|
|
162
223
|
# Send a JSON-RPC request and return the result
|
|
163
224
|
# @param method [String] JSON-RPC method name
|
|
164
225
|
# @param params [Hash] parameters for the request
|
|
@@ -186,6 +247,31 @@ module MCPClient
|
|
|
186
247
|
end
|
|
187
248
|
end
|
|
188
249
|
|
|
250
|
+
# Open a long-lived notification stream (MCP 2026-07-28 subscriptions/listen).
|
|
251
|
+
# @param notifications [Hash] the SubscriptionFilter (tools_list_changed,
|
|
252
|
+
# prompts_list_changed, resources_list_changed, resource_subscriptions,
|
|
253
|
+
# snake_case or camelCase; an extension's own field, such as the tasks
|
|
254
|
+
# extension's task_ids, once it has registered it — see
|
|
255
|
+
# {MCPClient::Subscription.register_filter_field})
|
|
256
|
+
# @param ack_timeout [Numeric, false, nil] seconds to wait for the
|
|
257
|
+
# server's acknowledgment before giving the listen up; nil takes the
|
|
258
|
+
# transport's own read timeout, false waits for ever
|
|
259
|
+
# @yield [method, params] notifications delivered on the subscription
|
|
260
|
+
# @return [MCPClient::Subscription]
|
|
261
|
+
def listen(notifications:, ack_timeout: nil, &listener)
|
|
262
|
+
raise NotImplementedError, 'Subclasses must implement listen'
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Cancel a subscription opened with {#listen}.
|
|
266
|
+
# @param subscription [MCPClient::Subscription]
|
|
267
|
+
# @return [void]
|
|
268
|
+
def cancel_subscription(subscription)
|
|
269
|
+
raise NotImplementedError, 'Subclasses must implement cancel_subscription'
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @return [Logger] the transport logger
|
|
273
|
+
attr_reader :logger
|
|
274
|
+
|
|
189
275
|
# Ping the MCP server to check connectivity (zero-parameter heartbeat call)
|
|
190
276
|
# @return [Object] result from the ping request
|
|
191
277
|
def ping
|
|
@@ -199,6 +285,43 @@ module MCPClient
|
|
|
199
285
|
@notification_callback = block
|
|
200
286
|
end
|
|
201
287
|
|
|
288
|
+
# Register a callback for the caches a notification invalidates, run
|
|
289
|
+
# *before* the notification is delivered to a subscription's listeners.
|
|
290
|
+
#
|
|
291
|
+
# A host layered above the transport (MCPClient::Client) keeps caches of
|
|
292
|
+
# its own, and they have to be gone by the time a listener reacting to a
|
|
293
|
+
# `list_changed` notification calls the cached list method. `on_notification`
|
|
294
|
+
# cannot serve for that: it is the last routing step, deliberately after
|
|
295
|
+
# the delivery, because it is host code that may block on the very reader
|
|
296
|
+
# the delivery came from. So the invalidation gets a hook of its own, ahead
|
|
297
|
+
# of the delivery, and only the invalidation goes on it.
|
|
298
|
+
# @yield [method, params] invoked before the notification is delivered
|
|
299
|
+
# @return [void]
|
|
300
|
+
# @see MCPClient::JsonRpcCommon#notify_cache_invalidation for where it runs
|
|
301
|
+
def on_cache_invalidation(&block)
|
|
302
|
+
@cache_invalidation_callback = block
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Map a resources/read error response to ResourceNotFound. MCP 2026-07-28
|
|
306
|
+
# (server/resources.mdx "Error Handling"): a missing resource is reported
|
|
307
|
+
# with -32602 (Invalid params); "for backwards compatibility, clients
|
|
308
|
+
# SHOULD also accept -32002 as a resource not found error".
|
|
309
|
+
# @param uri [String] the requested resource URI
|
|
310
|
+
# @param error [MCPClient::Errors::ServerError] the server's error response
|
|
311
|
+
# @return [MCPClient::Errors::ResourceNotFound]
|
|
312
|
+
def resource_not_found_error(uri, error)
|
|
313
|
+
MCPClient::Errors::ResourceNotFound.new("Resource '#{uri}' not found: #{error.message}")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Whether a resources/read error response means the resource does not
|
|
317
|
+
# exist, given this session's protocol era.
|
|
318
|
+
# @param error [MCPClient::Errors::ServerError] the server's error response
|
|
319
|
+
# @return [Boolean]
|
|
320
|
+
def resource_not_found_response?(error)
|
|
321
|
+
modern = respond_to?(:modern?) && modern?
|
|
322
|
+
MCPClient::Errors::Codes.resource_not_found_code?(error.code, modern: modern)
|
|
323
|
+
end
|
|
324
|
+
|
|
202
325
|
# Safety bound on the number of pages followed when auto-paginating a
|
|
203
326
|
# cursor-based list operation, to protect against a server that returns
|
|
204
327
|
# a nextCursor indefinitely.
|
|
@@ -228,7 +351,10 @@ module MCPClient
|
|
|
228
351
|
items.concat(Array(page_items))
|
|
229
352
|
pages += 1
|
|
230
353
|
|
|
231
|
-
|
|
354
|
+
# Only a missing (null) nextCursor ends the list: a cursor is opaque,
|
|
355
|
+
# and the empty string is one a server may hand out. A cursor handed
|
|
356
|
+
# out twice stops the walk below.
|
|
357
|
+
break if next_cursor.nil?
|
|
232
358
|
|
|
233
359
|
if seen_cursors[next_cursor]
|
|
234
360
|
@logger.warn("Pagination for #{kind} stopped: server returned a repeated cursor #{next_cursor.inspect}")
|
|
@@ -259,9 +385,95 @@ module MCPClient
|
|
|
259
385
|
# @return [Array<Hash>] all raw item hashes collected across pages
|
|
260
386
|
# @raise [MCPClient::Errors::TransportError] if a page result is not a Hash or Array
|
|
261
387
|
def request_paginated_list(method, key)
|
|
262
|
-
|
|
388
|
+
# The cursor the page request now in flight carries, so a rejection can
|
|
389
|
+
# be told apart from an -32602 the first page's request earned.
|
|
390
|
+
page = { cursor: nil }
|
|
391
|
+
restarting_rejected_cursor(key, page) { collect_list_pages(method, key, page) }
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Collect a paginated list once more from its first page when the server
|
|
395
|
+
# rejects a cursor it had issued.
|
|
396
|
+
#
|
|
397
|
+
# MCP pagination: a cursor the server no longer accepts (-32602) ends the
|
|
398
|
+
# sequence the pages collected so far belong to, so the list is collected
|
|
399
|
+
# again from the beginning rather than failing a caller who only asked for
|
|
400
|
+
# a list. A second rejection is the server's answer and is raised, as is a
|
|
401
|
+
# rejection of the first page's own request — it carries no cursor.
|
|
402
|
+
# @param key [String] the result array key (for the log line)
|
|
403
|
+
# @param page [Hash] holds the cursor of the request in flight
|
|
404
|
+
# @yield collects every page of the list
|
|
405
|
+
# @return [Object] the block's value
|
|
406
|
+
def restarting_rejected_cursor(key, page)
|
|
407
|
+
restarted = false
|
|
408
|
+
begin
|
|
409
|
+
page[:cursor] = nil
|
|
410
|
+
yield
|
|
411
|
+
rescue MCPClient::Errors::ServerError => e
|
|
412
|
+
raise if restarted || !page[:cursor] || !cursor_rejected?(e)
|
|
413
|
+
|
|
414
|
+
@logger.warn("Pagination for #{key} restarted: the server rejected a cursor it had issued")
|
|
415
|
+
restarted = true
|
|
416
|
+
retry
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# @param error [MCPClient::Errors::ServerError] a page request's failure
|
|
421
|
+
# @return [Boolean] whether it rejects the cursor the request carried
|
|
422
|
+
def cursor_rejected?(error)
|
|
423
|
+
respond_to?(:invalid_cursor_error?, true) && invalid_cursor_error?(error)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Send one page request of an auto-paginated list, dropping the entry the
|
|
427
|
+
# list is cached under when the server rejects the cursor it carried.
|
|
428
|
+
#
|
|
429
|
+
# A cursor names a position in one sequence of pages: once the server has
|
|
430
|
+
# forgotten it, the pages cached from that sequence are gone with it, and
|
|
431
|
+
# a restart that then fails transiently must not serve them back. Only the
|
|
432
|
+
# entry goes — the fetch already collecting this list replaces the
|
|
433
|
+
# transport's own copy, and an invalidation of a fetch's own making is not
|
|
434
|
+
# a change the host has to hear about (which would restart that fetch).
|
|
435
|
+
# A rejection of the first page's request carries no cursor and says
|
|
436
|
+
# nothing about the cache, so it leaves it alone.
|
|
437
|
+
# @param kind [Symbol, nil] the list kind
|
|
438
|
+
# @param cursor [String, nil] the cursor this page request carries
|
|
439
|
+
# @yield sends the page request
|
|
440
|
+
# @return [Object] the block's value
|
|
441
|
+
def fetch_list_page(kind, cursor)
|
|
442
|
+
yield
|
|
443
|
+
rescue MCPClient::Errors::ServerError => e
|
|
444
|
+
raise unless kind && cursor && cursor_rejected?(e) && respond_to?(:invalidate_cache, true)
|
|
445
|
+
|
|
446
|
+
invalidate_cache(kind)
|
|
447
|
+
raise
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Collect every page of a list, recording what each page was answered
|
|
451
|
+
# under so the cache can bind the combined list to it.
|
|
452
|
+
# @param method [String] the list method
|
|
453
|
+
# @param key [String] the result array key
|
|
454
|
+
# @param page [Hash] holds the cursor of the request in flight
|
|
455
|
+
# @return [Array<Hash>] all raw item hashes collected across pages
|
|
456
|
+
def collect_list_pages(method, key, page)
|
|
457
|
+
pages = []
|
|
458
|
+
received_ats = []
|
|
459
|
+
contexts = []
|
|
460
|
+
fingerprints = []
|
|
461
|
+
page[:cursor] = nil
|
|
462
|
+
epoch = list_cache_epoch(method) if respond_to?(:list_cache_epoch, true)
|
|
463
|
+
kind = respond_to?(:list_kind_for, true) ? list_kind_for(method) : nil
|
|
464
|
+
items = collect_paginated(key) do |cursor|
|
|
263
465
|
params = cursor ? { cursor: cursor } : {}
|
|
264
|
-
|
|
466
|
+
started = respond_to?(:monotonic_now, true) ? monotonic_now : nil
|
|
467
|
+
page[:cursor] = cursor
|
|
468
|
+
# A cursor the server no longer accepts ends the sequence its pages
|
|
469
|
+
# belong to: what was cached under that sequence goes with it, so a
|
|
470
|
+
# restart that then fails cannot serve it back (MCP pagination).
|
|
471
|
+
answer = fetch_list_page(kind, cursor) { rpc_request(method, params) }
|
|
472
|
+
result = require_complete_result!(answer, method)
|
|
473
|
+
pages << result
|
|
474
|
+
received_ats << response_received_at(since: started) if respond_to?(:response_received_at, true)
|
|
475
|
+
contexts << (respond_to?(:request_authorization_context, true) ? request_authorization_context : nil)
|
|
476
|
+
fingerprints << (respond_to?(:request_params_fingerprint, true) ? request_params_fingerprint : nil)
|
|
265
477
|
case result
|
|
266
478
|
when Hash
|
|
267
479
|
[result[key] || [], result['nextCursor']]
|
|
@@ -272,6 +484,22 @@ module MCPClient
|
|
|
272
484
|
"Invalid #{method} response: expected an object or array, got #{result.class}"
|
|
273
485
|
end
|
|
274
486
|
end
|
|
487
|
+
# MCP 2026-07-28 caching: every page carries its own ttlMs; the list is
|
|
488
|
+
# fresh only as long as its shortest-lived page, and pages fetched
|
|
489
|
+
# under differing credentials or parameters are never served combined.
|
|
490
|
+
if respond_to?(:record_list_cache_hint, true)
|
|
491
|
+
record_list_cache_hint(method, pages, received_ats, contexts: contexts, params: fingerprints, epoch: epoch)
|
|
492
|
+
end
|
|
493
|
+
items
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Whether a cached list of the given kind may still be served (MCP
|
|
497
|
+
# 2026-07-28 caching); transports without freshness hints keep caching
|
|
498
|
+
# until a change notification.
|
|
499
|
+
# @param _kind [Symbol]
|
|
500
|
+
# @return [Boolean]
|
|
501
|
+
def cache_fresh?(_kind)
|
|
502
|
+
true
|
|
275
503
|
end
|
|
276
504
|
|
|
277
505
|
# Initialize logger with proper formatter handling
|
|
@@ -38,7 +38,9 @@ module MCPClient
|
|
|
38
38
|
read_timeout: config[:read_timeout] || MCPClient::ServerStdio::READ_TIMEOUT,
|
|
39
39
|
name: config[:name],
|
|
40
40
|
logger: logger,
|
|
41
|
-
env: config[:env] || {}
|
|
41
|
+
env: config[:env] || {},
|
|
42
|
+
protocol: config[:protocol] || :auto,
|
|
43
|
+
discover_timeout: config[:discover_timeout]
|
|
42
44
|
)
|
|
43
45
|
end
|
|
44
46
|
|
|
@@ -78,7 +80,9 @@ module MCPClient
|
|
|
78
80
|
name: config[:name],
|
|
79
81
|
logger: logger,
|
|
80
82
|
oauth_provider: config[:oauth_provider],
|
|
81
|
-
faraday_config: config[:faraday_config]
|
|
83
|
+
faraday_config: config[:faraday_config],
|
|
84
|
+
protocol: config[:protocol] || :auto,
|
|
85
|
+
discover_timeout: config[:discover_timeout]
|
|
82
86
|
)
|
|
83
87
|
end
|
|
84
88
|
|
|
@@ -102,7 +106,9 @@ module MCPClient
|
|
|
102
106
|
faraday_config: config[:faraday_config],
|
|
103
107
|
max_decompressed_body_bytes:
|
|
104
108
|
config[:max_decompressed_body_bytes] ||
|
|
105
|
-
MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES
|
|
109
|
+
MCPClient::ServerStreamableHTTP::JsonRpcTransport::MAX_DECOMPRESSED_BODY_BYTES,
|
|
110
|
+
protocol: config[:protocol] || :auto,
|
|
111
|
+
discover_timeout: config[:discover_timeout]
|
|
106
112
|
)
|
|
107
113
|
end
|
|
108
114
|
|
|
@@ -12,17 +12,232 @@ module MCPClient
|
|
|
12
12
|
|
|
13
13
|
# Parse an HTTP JSON-RPC response
|
|
14
14
|
# @param response [Faraday::Response] the HTTP response
|
|
15
|
-
# @param
|
|
15
|
+
# @param request [Hash, nil] the originating JSON-RPC request
|
|
16
16
|
# @return [Hash] the parsed result
|
|
17
17
|
# @raise [MCPClient::Errors::TransportError] if parsing fails
|
|
18
18
|
# @raise [MCPClient::Errors::ServerError] if the response contains an error
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
# A host's `conn.response :json` middleware decodes the body before it
|
|
20
|
+
# reaches here — the README offers that middleware for the error path,
|
|
21
|
+
# and it applies to every response — so an already-decoded object is
|
|
22
|
+
# taken as it is rather than parsed a second time. An event-stream body
|
|
23
|
+
# is never decoded by that middleware, so it is still the raw text.
|
|
24
|
+
def parse_response(response, request = nil)
|
|
25
|
+
# Host code a stream listener reached raised while the body was still
|
|
26
|
+
# arriving: that is this exchange's failure (already marked as a
|
|
27
|
+
# nested exchange's, so no recovery acts on it), raised in place of
|
|
28
|
+
# the response it was interleaved with — as it is when the completed
|
|
29
|
+
# body is parsed.
|
|
30
|
+
failure = stream_listener_error(response)
|
|
31
|
+
raise failure if failure
|
|
32
|
+
|
|
33
|
+
body = response.body
|
|
34
|
+
headers = response.respond_to?(:headers) ? response.headers || {} : {}
|
|
35
|
+
content_type = headers['content-type'] || headers['Content-Type'] || ''
|
|
36
|
+
# MCP 2026-07-28 Streamable HTTP: the server answers with either a
|
|
37
|
+
# single JSON object or an SSE stream scoped to the request; the
|
|
38
|
+
# client MUST support both.
|
|
39
|
+
data = if content_type.include?('text/event-stream')
|
|
40
|
+
# Not stripped: the blank line that terminates the final
|
|
41
|
+
# event is what makes it a delivered event at all.
|
|
42
|
+
response_from_sse(body.to_s, request && request['id'], live_event_count(response))
|
|
43
|
+
elsif body.is_a?(String)
|
|
44
|
+
JSON.parse(body.strip)
|
|
45
|
+
else
|
|
46
|
+
body
|
|
47
|
+
end
|
|
22
48
|
process_jsonrpc_response(data)
|
|
23
49
|
rescue JSON::ParserError => e
|
|
24
50
|
raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{describe_parse_error(e)}"
|
|
25
51
|
end
|
|
52
|
+
|
|
53
|
+
# Every complete event of a response stream is handed over while the
|
|
54
|
+
# body is still arriving, so a legacy server's request on the stream
|
|
55
|
+
# is answered — and a progress notification delivered — before the
|
|
56
|
+
# server has to end the response. A notification has no response
|
|
57
|
+
# stream worth reading incrementally.
|
|
58
|
+
# @param request [Hash] the JSON-RPC message being sent
|
|
59
|
+
# @return [Proc, nil]
|
|
60
|
+
def response_stream_listener(request)
|
|
61
|
+
return nil unless request.is_a?(Hash) && request.key?('id')
|
|
62
|
+
|
|
63
|
+
->(event) { dispatch_live_sse_event(event) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Act on one event as it arrives: requests and notifications are
|
|
67
|
+
# routed now, responses wait for the completed body. A failing
|
|
68
|
+
# callback is not allowed to abort the read of the response it was
|
|
69
|
+
# interleaved with: the capture middleware holds it and parse_response
|
|
70
|
+
# raises it once the body is in.
|
|
71
|
+
# @param event [String] one complete, LF-normalized SSE event
|
|
72
|
+
# @return [void]
|
|
73
|
+
def dispatch_live_sse_event(event)
|
|
74
|
+
message = sse_event_message(event)
|
|
75
|
+
dispatch_sse_message(message) if message && message['method']
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Pick the JSON-RPC response to the request out of an SSE-framed body,
|
|
79
|
+
# forwarding request-scoped notifications (progress, log messages) to
|
|
80
|
+
# the notification callback — except the `live` leading events, which
|
|
81
|
+
# were already routed as they arrived. Server-initiated requests are
|
|
82
|
+
# not permitted on a 2026-07-28 response stream and are dropped.
|
|
83
|
+
# @param sse_body [String] the text/event-stream body
|
|
84
|
+
# @param request_id [Integer, String, nil] id of the originating request
|
|
85
|
+
# @param live [Integer] events already dispatched while the body arrived
|
|
86
|
+
# @return [Hash] the JSON-RPC response
|
|
87
|
+
# @raise [MCPClient::Errors::TransportError] when the stream carries no response
|
|
88
|
+
def response_from_sse(sse_body, request_id, live = 0)
|
|
89
|
+
responses = []
|
|
90
|
+
saw_invalid_json = false
|
|
91
|
+
sse_events(sse_body).each_with_index do |event, index|
|
|
92
|
+
message = sse_event_message(event)
|
|
93
|
+
saw_invalid_json = true if message.nil? && event.lines.any? { |l| l.start_with?('data:') }
|
|
94
|
+
next unless message
|
|
95
|
+
|
|
96
|
+
if message['method']
|
|
97
|
+
dispatch_sse_message(message) if index >= live
|
|
98
|
+
else
|
|
99
|
+
responses << message
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
matched = responses.find { |m| request_id.nil? || m['id'] == request_id || m['id'].to_s == request_id.to_s }
|
|
103
|
+
matched ||= tolerated_id_mismatch(responses, request_id)
|
|
104
|
+
return matched if matched
|
|
105
|
+
|
|
106
|
+
# Every event that reaches here is terminated, so a data line that did
|
|
107
|
+
# not parse is a delivered (malformed) answer rather than a break
|
|
108
|
+
# inside one: the server ran the request, and re-issuing would run it
|
|
109
|
+
# again.
|
|
110
|
+
if saw_invalid_json
|
|
111
|
+
raise MCPClient::Errors::TransportError,
|
|
112
|
+
'Invalid JSON response from server: SSE event carried no valid JSON-RPC message'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The stream closed without the response: on a modern server the
|
|
116
|
+
# request is lost and must be re-issued (see rpc_request).
|
|
117
|
+
if modern?
|
|
118
|
+
raise MCPClient::Errors::ResponseStreamClosedError, 'SSE stream closed before delivering the response'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
raise MCPClient::Errors::TransportError, 'No JSON-RPC response found in SSE response'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Split a response stream into events, in the order and count the
|
|
125
|
+
# stream listener saw them.
|
|
126
|
+
#
|
|
127
|
+
# SSE line terminators are CRLF, CR or LF; a server framing its events
|
|
128
|
+
# with bare CR still delimits them, so normalize before splitting. An
|
|
129
|
+
# event is dispatched at its terminating blank line, so a body that
|
|
130
|
+
# ends inside an event delivered nothing for it: on a modern server
|
|
131
|
+
# that event is dropped (and a missing response re-issued). A legacy
|
|
132
|
+
# server keeps the benefit of the doubt this transport always gave it.
|
|
133
|
+
# @param sse_body [String] the text/event-stream body
|
|
134
|
+
# @return [Array<String>] the events, without their terminators
|
|
135
|
+
def sse_events(sse_body)
|
|
136
|
+
normalized = modern? ? complete_sse_events(sse_body) : normalize_sse_newlines(sse_body)
|
|
137
|
+
events = normalized.split("\n\n", -1)
|
|
138
|
+
events.pop if events.last.to_s.empty?
|
|
139
|
+
events
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param event [String] one LF-normalized SSE event
|
|
143
|
+
# @return [Hash, nil] the JSON-RPC message its data lines carry, if any
|
|
144
|
+
def sse_event_message(event)
|
|
145
|
+
data_lines = event.lines.map(&:chomp).select { |l| l.start_with?('data:') }
|
|
146
|
+
return nil if data_lines.empty?
|
|
147
|
+
|
|
148
|
+
parse_sse_message(data_lines.map { |l| l.sub(/\Adata:\s*/, '') }.join("\n"))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The only response on a stream, when its id is not the one asked for.
|
|
152
|
+
#
|
|
153
|
+
# A legacy server that echoes ids loosely — a string where an integer
|
|
154
|
+
# went out, or an id an intermediary rewrote — still gets the benefit of
|
|
155
|
+
# the doubt. A modern one does not: no response to THIS request arrived,
|
|
156
|
+
# so the request was lost and MCP 2026-07-28 says to re-issue it rather
|
|
157
|
+
# than complete it with the answer to something else.
|
|
158
|
+
# @param responses [Array<Hash>] the responses the stream carried
|
|
159
|
+
# @param request_id [Integer, String, nil] id of the originating request
|
|
160
|
+
# @return [Hash, nil] the response to accept, or nil to treat as lost
|
|
161
|
+
def tolerated_id_mismatch(responses, request_id)
|
|
162
|
+
return nil if modern? || responses.size != 1
|
|
163
|
+
|
|
164
|
+
@logger.warn("SSE response id #{responses.first['id'].inspect} does not match request id " \
|
|
165
|
+
"#{request_id.inspect}; accepting the only response on the stream")
|
|
166
|
+
responses.first
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Route a non-response message: notifications go to the callback, and a
|
|
170
|
+
# server-initiated request is answered on a legacy stream and dropped on
|
|
171
|
+
# a modern one.
|
|
172
|
+
# @param message [Hash] a JSON-RPC request or notification
|
|
173
|
+
# @return [void]
|
|
174
|
+
def dispatch_sse_message(message)
|
|
175
|
+
# Host code reached from here -- a notification listener -- may issue a
|
|
176
|
+
# request of its own while the response that carried this message is
|
|
177
|
+
# still being parsed. That request is an exchange of its own, and the
|
|
178
|
+
# call still waiting for this response must keep both its recorded
|
|
179
|
+
# definition and its own failures (HttpTransportBase::RequestRecovery#dispatching_to_host).
|
|
180
|
+
dispatching_to_host { dispatch_sse_message_now(message) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @param message [Hash] a JSON-RPC request or notification
|
|
184
|
+
# @return [void]
|
|
185
|
+
def dispatch_sse_message_now(message)
|
|
186
|
+
unless message.key?('id')
|
|
187
|
+
route_notification(message['method'], message['params'])
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# MCP 2026-07-28: "The server MUST NOT send independent JSON-RPC
|
|
192
|
+
# requests on this stream" and clients MUST NOT POST responses to it,
|
|
193
|
+
# so there is nothing to answer with. Judged by the ESTABLISHED era:
|
|
194
|
+
# while the probe is in flight the version is only a proposal, and a
|
|
195
|
+
# legacy server may be waiting for its ping on the probe's stream.
|
|
196
|
+
if protocol_era == :modern
|
|
197
|
+
@logger.warn("Ignoring server-initiated request #{message['method']} on a response stream")
|
|
198
|
+
return
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
answer_server_request(message)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Answer a server-initiated request on a legacy (2025-11-25 and earlier)
|
|
205
|
+
# response stream, where the server may send one and a receiver "MUST
|
|
206
|
+
# respond promptly" to ping. This transport serves no other
|
|
207
|
+
# server-initiated method — it has no elicitation, roots or sampling
|
|
208
|
+
# callbacks — so those get the JSON-RPC method-not-found answer rather
|
|
209
|
+
# than silence, which would leave the server waiting.
|
|
210
|
+
# @param message [Hash] the server's JSON-RPC request
|
|
211
|
+
# @return [void]
|
|
212
|
+
def answer_server_request(message)
|
|
213
|
+
send_http_request(server_request_answer(message))
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
@logger.error("Failed to answer server request #{message['method']}: #{e.message}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @param message [Hash] the server's JSON-RPC request
|
|
219
|
+
# @return [Hash] the JSON-RPC response to POST back
|
|
220
|
+
def server_request_answer(message)
|
|
221
|
+
answer = { 'jsonrpc' => '2.0', 'id' => message['id'] }
|
|
222
|
+
return answer.merge('result' => {}) if message['method'] == 'ping'
|
|
223
|
+
|
|
224
|
+
@logger.warn("Answering unsupported server request #{message['method']} with method not found")
|
|
225
|
+
answer.merge('error' => { 'code' => MCPClient::Errors::Codes::METHOD_NOT_FOUND,
|
|
226
|
+
'message' => "Method not found: #{message['method']}" })
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @param json [String] one SSE event's data
|
|
230
|
+
# @return [Hash, nil] the parsed JSON-RPC message, nil when unusable
|
|
231
|
+
def parse_sse_message(json)
|
|
232
|
+
message = JSON.parse(json)
|
|
233
|
+
return message if message.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
@logger.warn("Skipping non-object JSON-RPC message in SSE event (#{message.class})")
|
|
236
|
+
nil
|
|
237
|
+
rescue JSON::ParserError => e
|
|
238
|
+
@logger.warn("Skipping invalid JSON in SSE event: #{describe_parse_error(e, json)}")
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
26
241
|
end
|
|
27
242
|
end
|
|
28
243
|
end
|