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,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# The tool definition a `tools/call` request went out under (MCP 2026-07-28
|
|
5
|
+
# "Custom Headers from Tool Parameters"): the transport derives that
|
|
6
|
+
# request's `Mcp-Param-*` headers from its tool list, so the definitions in
|
|
7
|
+
# that list are the ones the call is made -- and answered -- under.
|
|
8
|
+
#
|
|
9
|
+
# A host that re-resolves the tool afterwards, to validate the result
|
|
10
|
+
# against the definition the call actually carried, reads it from here
|
|
11
|
+
# instead of listing again: on a list the server bounds with `ttlMs: 0` (or
|
|
12
|
+
# one whose TTL expired during the call) another access re-fetches, and the
|
|
13
|
+
# definition that comes back may not be the one the request went out with.
|
|
14
|
+
#
|
|
15
|
+
# Every call gets a slot of its own, so a nested exchange cannot overwrite
|
|
16
|
+
# an outer call's definition: a Streamable HTTP response dispatches the
|
|
17
|
+
# notifications it carries synchronously, and a listener may call another
|
|
18
|
+
# tool on this very transport and thread before the outer call returns.
|
|
19
|
+
#
|
|
20
|
+
# The slots are kept per thread and per transport, in a stack named after
|
|
21
|
+
# the transport's `object_id` so that
|
|
22
|
+
# {MCPClient::ResultCaching#forget_transport_thread_state} drops it with
|
|
23
|
+
# everything else a discarded transport left behind.
|
|
24
|
+
module CalledToolDefinition
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Run one tools/call with a slot of its own for the definition its
|
|
28
|
+
# request goes out under. What the call recorded is handed, when it
|
|
29
|
+
# returns, to the caller still waiting for it -- never to an outer call
|
|
30
|
+
# that already recorded its own -- and nothing is left on this thread
|
|
31
|
+
# once the outermost call is over.
|
|
32
|
+
# @yield the call
|
|
33
|
+
# @return [Object] the block's value
|
|
34
|
+
def recording_called_tool_definition
|
|
35
|
+
stack = (Thread.current[called_tool_definition_key] ||= [])
|
|
36
|
+
stack.push(nil)
|
|
37
|
+
begin
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
finished = stack.pop
|
|
41
|
+
stack[-1] = finished if finished && !stack.empty? && stack.last.nil?
|
|
42
|
+
Thread.current[called_tool_definition_key] = nil if stack.empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The boundary a transport crosses when it hands control to host code: a
|
|
47
|
+
# notification listener, a handler for a server-initiated request. A
|
|
48
|
+
# `tools/call` that code issues -- the public `call_tool`, or a raw
|
|
49
|
+
# `rpc_request('tools/call', ...)` naming the very tool the open call is
|
|
50
|
+
# waiting on -- records into a slot of its own and nothing of it is handed
|
|
51
|
+
# back out, so the call whose response is still being parsed keeps the
|
|
52
|
+
# definition its own request went out under.
|
|
53
|
+
# @yield the host code
|
|
54
|
+
# @return [Object] the block's value
|
|
55
|
+
def outside_called_tool_definition
|
|
56
|
+
stack = (Thread.current[called_tool_definition_key] ||= [])
|
|
57
|
+
stack.push(nil)
|
|
58
|
+
begin
|
|
59
|
+
yield
|
|
60
|
+
ensure
|
|
61
|
+
stack.pop
|
|
62
|
+
Thread.current[called_tool_definition_key] = nil if stack.empty?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Remember the definition a tools/call request is going out under.
|
|
67
|
+
# @param name [String] the tool named in the request
|
|
68
|
+
# @param tool [MCPClient::Tool, nil] its definition in the list the
|
|
69
|
+
# request's headers were derived from (nil when that list no longer
|
|
70
|
+
# carries the tool at all)
|
|
71
|
+
# @return [void]
|
|
72
|
+
def note_called_tool_definition(name, tool)
|
|
73
|
+
stack = (Thread.current[called_tool_definition_key] ||= [nil])
|
|
74
|
+
stack[-1] = [name.to_s, tool]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The definition the tools/call this caller is waiting on went out under.
|
|
78
|
+
# Taken rather than read: it describes that one request, and leaving it
|
|
79
|
+
# behind would keep a tool definition on this thread for as long as the
|
|
80
|
+
# transport lives.
|
|
81
|
+
# @param name [String] the tool being re-resolved
|
|
82
|
+
# @return [Array(MCPClient::Tool, nil), nil] a one-element array holding
|
|
83
|
+
# the definition -- its element is nil when the list the request went
|
|
84
|
+
# out under no longer listed the tool -- or nil when no call of this
|
|
85
|
+
# caller's recorded a definition for that tool
|
|
86
|
+
def take_called_tool_definition(name)
|
|
87
|
+
stack = Thread.current[called_tool_definition_key]
|
|
88
|
+
recorded = stack.is_a?(Array) ? stack.last : nil
|
|
89
|
+
return nil unless recorded.is_a?(Array) && recorded.first == name.to_s
|
|
90
|
+
|
|
91
|
+
stack[-1] = nil
|
|
92
|
+
Thread.current[called_tool_definition_key] = nil if stack.size == 1
|
|
93
|
+
[recorded.last]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Symbol] this transport's thread-local key for it
|
|
97
|
+
def called_tool_definition_key
|
|
98
|
+
:"mcp_client_called_tool_definition_#{object_id}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Pin the definition the HeaderMismatch retry was checked against, so
|
|
102
|
+
# the retry's headers are derived from that very definition. Between
|
|
103
|
+
# the check and the send the list would otherwise be looked up again,
|
|
104
|
+
# and on a list the server bounds with `ttlMs: 0` another lookup is
|
|
105
|
+
# another fetch — possibly of a definition the check never read.
|
|
106
|
+
# @param name [String] the tool named in the retried request
|
|
107
|
+
# @param tool [MCPClient::Tool, nil] the definition the check read (nil
|
|
108
|
+
# when the refreshed list no longer carries the tool)
|
|
109
|
+
# @return [void]
|
|
110
|
+
def pin_retry_definition(name, tool)
|
|
111
|
+
Thread.current[pinned_retry_definition_key] = [name.to_s, tool]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Take the pinned definition for a request, if the retry of that very
|
|
115
|
+
# tool pinned one. Taken rather than read: it describes one send.
|
|
116
|
+
# @param name [String] the tool named in the request being sent
|
|
117
|
+
# @return [Array(MCPClient::Tool, nil), nil] a one-element array holding
|
|
118
|
+
# the definition, or nil when nothing is pinned for the tool
|
|
119
|
+
def take_pinned_retry_definition(name)
|
|
120
|
+
pinned = Thread.current[pinned_retry_definition_key]
|
|
121
|
+
return nil unless pinned.is_a?(Array) && pinned.first == name.to_s
|
|
122
|
+
|
|
123
|
+
Thread.current[pinned_retry_definition_key] = nil
|
|
124
|
+
[pinned.last]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Drop a pin the retry never consumed (it raised before sending).
|
|
128
|
+
# @return [void]
|
|
129
|
+
def clear_pinned_retry_definition
|
|
130
|
+
Thread.current[pinned_retry_definition_key] = nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Symbol] this transport's thread-local key for the pin
|
|
134
|
+
def pinned_retry_definition_key
|
|
135
|
+
:"mcp_client_pinned_retry_definition_#{object_id}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
class Client
|
|
5
|
+
# The client's own slices of a list cache under the MCP 2026-07-28
|
|
6
|
+
# caching rules: whether every server's slice is still fresh and current
|
|
7
|
+
# for the parameters a listing would send, the snapshot a fresh cache
|
|
8
|
+
# yields, and the replacement of one server's slice by a fresh fetch
|
|
9
|
+
# without disturbing the others. Mixed into {MCPClient::Client}; every
|
|
10
|
+
# method is private there.
|
|
11
|
+
module CacheSlices
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Whether every server's cached list of a kind is still fresh (MCP
|
|
15
|
+
# 2026-07-28 caching: a stale list is re-fetched on access).
|
|
16
|
+
# The parameters go first: reading them evaluates the host's request_meta
|
|
17
|
+
# and the transport holds that evaluation, which the freshness check then
|
|
18
|
+
# reuses instead of reading the callable a second time. Asking the other
|
|
19
|
+
# way round released the held value in between, so a hit spent two
|
|
20
|
+
# trace ids (or nonces) on a decision that sends nothing.
|
|
21
|
+
# @param kind [Symbol] :tools, :prompts or :resources
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def caches_fresh?(kind)
|
|
24
|
+
servers.all? do |server|
|
|
25
|
+
cache_params_current?(kind, server) && (!server.respond_to?(:cache_fresh?) || server.cache_fresh?(kind))
|
|
26
|
+
end
|
|
27
|
+
rescue StandardError
|
|
28
|
+
# One server's check aborted -- an OAuth refresh that failed, a host
|
|
29
|
+
# `request_meta` callable that raised -- so nothing is fetched from any
|
|
30
|
+
# of them. Every server the loop had already passed is still holding
|
|
31
|
+
# the evaluation this decision read for the fetch it would have made:
|
|
32
|
+
# dropped here, all of them, or an unrelated request on this worker
|
|
33
|
+
# thread would go out carrying that decision's tenant, baggage or nonce.
|
|
34
|
+
release_held_request_meta
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The cache's items as one snapshot taken under the lock, when the cache
|
|
39
|
+
# holds something and every server's slice is fresh for the parameters
|
|
40
|
+
# its next request would carry.
|
|
41
|
+
# @param kind [Symbol]
|
|
42
|
+
# @param cache [Hash]
|
|
43
|
+
# @return [Array, nil]
|
|
44
|
+
def cached_snapshot(kind, cache)
|
|
45
|
+
# Freshness consults the servers (a request_meta callable, host
|
|
46
|
+
# middleware), which may clear this cache in turn: it runs outside the
|
|
47
|
+
# lock, and the copy is served only when nothing changed meanwhile.
|
|
48
|
+
version = @cache_mutex.synchronize { @cache_version }
|
|
49
|
+
# An empty snapshot is a hit too: a server may list nothing, and its
|
|
50
|
+
# entry says so for as long as it is fresh. What makes a hit is that
|
|
51
|
+
# every server has filled its slice, not that the hash holds items.
|
|
52
|
+
return nil unless @cache_mutex.synchronize { snapshot_complete?(kind, cache) }
|
|
53
|
+
return nil unless caches_fresh?(kind)
|
|
54
|
+
|
|
55
|
+
@cache_mutex.synchronize do
|
|
56
|
+
# A cleanup that landed after the verdict replaced the transport
|
|
57
|
+
# entries the slices came from; that check touches only the
|
|
58
|
+
# transport's cache lock, so it can run under this one.
|
|
59
|
+
next unless version == @cache_version && snapshot_complete?(kind, cache) && slices_still_current?(kind)
|
|
60
|
+
|
|
61
|
+
cached_copies(cache)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether every server this client talks to has filled its slice of a
|
|
66
|
+
# list cache (an empty list fills a slice as much as a long one does).
|
|
67
|
+
# Called under {@cache_mutex}.
|
|
68
|
+
# @param kind [Symbol]
|
|
69
|
+
# @param cache [Hash] the kind's cache, to tell an empty snapshot apart
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def snapshot_complete?(kind, cache)
|
|
72
|
+
filled = @cache_filled[kind]
|
|
73
|
+
return false if filled.nil?
|
|
74
|
+
|
|
75
|
+
list = servers
|
|
76
|
+
return false if list.empty? || !list.all? { |server| filled.key?(server) }
|
|
77
|
+
# A snapshot with nothing in it is only a hit while a server says so
|
|
78
|
+
# itself: a 2026-07-28 server bounds its empty list with a ttlMs, an
|
|
79
|
+
# older one records no hint at all and keeps the client's previous
|
|
80
|
+
# heuristic — ask again until something is listed.
|
|
81
|
+
return true unless cache.empty?
|
|
82
|
+
|
|
83
|
+
list.all? { |server| hinted_slice?(kind, server) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param kind [Symbol]
|
|
87
|
+
# @param server [MCPClient::ServerBase]
|
|
88
|
+
# @return [Boolean] whether the entry behind this server's slice bounds
|
|
89
|
+
# its own freshness (the server sent a hint)
|
|
90
|
+
def hinted_slice?(kind, server)
|
|
91
|
+
server.respond_to?(:cache_entry_hinted?, true) && server.send(:cache_entry_hinted?, kind)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Whether every server's slice of a kind still comes from the transport
|
|
95
|
+
# entry it was recorded against (a cleanup or a replaced entry ends it).
|
|
96
|
+
# @param kind [Symbol]
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def slices_still_current?(kind)
|
|
99
|
+
servers.all? do |server|
|
|
100
|
+
next true unless server.respond_to?(:cache_entry_token, true)
|
|
101
|
+
|
|
102
|
+
_fingerprint, token = @cache_params[kind][server]
|
|
103
|
+
current = server.send(:cache_entry_token, kind)
|
|
104
|
+
next current.nil? if token == MCPClient::ResultCaching::LEGACY_ENTRY
|
|
105
|
+
next false unless !token.nil? && !current.nil? && current.equal?(token)
|
|
106
|
+
|
|
107
|
+
# The verdict may be old by the time the copy is made: the entry's
|
|
108
|
+
# own hint is re-read here (transport cache lock only).
|
|
109
|
+
!server.respond_to?(:cache_entry_fresh?, true) || server.send(:cache_entry_fresh?, kind)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Replace one server's slice of a list cache under the lock: its previous
|
|
114
|
+
# entries go, the fingerprint the fetch was made under is recorded, and
|
|
115
|
+
# the block inserts the new entries.
|
|
116
|
+
# @param kind [Symbol]
|
|
117
|
+
# @param cache [Hash]
|
|
118
|
+
# @param server [MCPClient::ServerBase]
|
|
119
|
+
# @param fingerprint [String, nil]
|
|
120
|
+
# @return [void]
|
|
121
|
+
def replace_cached_slice(kind, cache, server, fingerprint, generation: nil)
|
|
122
|
+
@cache_mutex.synchronize do
|
|
123
|
+
# An invalidation that landed while the fetch ran already replaced
|
|
124
|
+
# these definitions; writing them back would undo it.
|
|
125
|
+
next if generation && @tool_cache_generation != generation
|
|
126
|
+
|
|
127
|
+
@cache_version += 1
|
|
128
|
+
drop_cached_entries(cache, server)
|
|
129
|
+
# This server's slice now stands for its whole list, empty or not.
|
|
130
|
+
(@cache_filled[kind] ||= {}.compare_by_identity)[server] = true
|
|
131
|
+
forget_schema_checks(server) if kind == :tools
|
|
132
|
+
if server.respond_to?(:current_params_fingerprint, true)
|
|
133
|
+
# The slice is tied to the very transport entry its list came
|
|
134
|
+
# from — its identity and the parameters that entry is bound to
|
|
135
|
+
# (the request that produced it, which a first fetch may have made
|
|
136
|
+
# with more than was known before connecting): a transport list
|
|
137
|
+
# refreshed on its own (rotated credentials, a concurrent fetch)
|
|
138
|
+
# replaces that entry, and the slice with it.
|
|
139
|
+
token, bound = served_entry_for(kind, server)
|
|
140
|
+
@cache_params[kind][server] = [bound || fingerprint, token]
|
|
141
|
+
end
|
|
142
|
+
yield
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Array(Object, String), nil] the identity of the transport
|
|
147
|
+
# entry the list this thread just obtained from the server came from,
|
|
148
|
+
# and the parameters fingerprint it is bound to
|
|
149
|
+
def served_entry_for(kind, server)
|
|
150
|
+
return nil unless server.respond_to?(:take_served_entry, true)
|
|
151
|
+
|
|
152
|
+
# Taken rather than read: the note exists for this one tagging, and
|
|
153
|
+
# leaving it behind would keep a slot on this thread for every
|
|
154
|
+
# transport a long-lived worker has ever listed through.
|
|
155
|
+
server.send(:take_served_entry, kind)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [Boolean] whether the server's next request would carry the
|
|
159
|
+
# parameters its slice of the cache was filled under, and the transport
|
|
160
|
+
# still holds the entry that slice came from
|
|
161
|
+
def cache_params_current?(kind, server)
|
|
162
|
+
return true unless server.respond_to?(:current_params_fingerprint, true)
|
|
163
|
+
|
|
164
|
+
fingerprint, token = @cache_params[kind][server]
|
|
165
|
+
return false unless fingerprint == server.send(:current_params_fingerprint)
|
|
166
|
+
return true unless server.respond_to?(:cache_entry_token, true)
|
|
167
|
+
|
|
168
|
+
# A slice is identified by the very entry it came from; a legacy list
|
|
169
|
+
# (no hint recorded) stays a hit only while the transport still holds
|
|
170
|
+
# no entry, and a fetch that recorded no entry identifies nothing.
|
|
171
|
+
current = server.send(:cache_entry_token, kind)
|
|
172
|
+
return current.nil? if token == MCPClient::ResultCaching::LEGACY_ENTRY
|
|
173
|
+
|
|
174
|
+
!token.nil? && !current.nil? && current.equal?(token)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The cache's items as copies: a caller can neither change the cache nor
|
|
178
|
+
# what later callers (and the x-mcp-header derivation) see.
|
|
179
|
+
# @param cache [Hash]
|
|
180
|
+
# @return [Array]
|
|
181
|
+
def cached_copies(cache)
|
|
182
|
+
cache.values.map { |item| MCPClient::DeepCopy.copy(item) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Remove one server's entries from a client-level cache.
|
|
186
|
+
# @param cache [Hash] the cache keyed by #cache_key_for
|
|
187
|
+
# @param server [MCPClient::ServerBase]
|
|
188
|
+
# @return [void]
|
|
189
|
+
def drop_cached_entries(cache, server)
|
|
190
|
+
prefix = "#{server.object_id}:"
|
|
191
|
+
cache.delete_if { |key, _| key.start_with?(prefix) }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
class Client
|
|
5
|
+
# Listing across the client's servers: the per-server fetch loops that
|
|
6
|
+
# fill the client's slices of a list cache, and the bookkeeping the
|
|
7
|
+
# MCP 2026-07-28 caching rules put around them.
|
|
8
|
+
#
|
|
9
|
+
# A listing weighs its cache decision on the effective parameters the
|
|
10
|
+
# fetch would carry, which evaluates the host's `request_meta` callable;
|
|
11
|
+
# the transports hold that evaluation for the fetch the decision leads
|
|
12
|
+
# to, so the request goes out with exactly what was weighed. That
|
|
13
|
+
# reservation is opened here, for every server, and closed here from an
|
|
14
|
+
# `ensure`: a fetch that raises during a reconnect, an authorization
|
|
15
|
+
# error a caller catches, a server the loop never reaches -- none of them
|
|
16
|
+
# can leave an evaluation on this worker thread for some later, unrelated
|
|
17
|
+
# request to send.
|
|
18
|
+
module ListAggregation
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Reserve, on every server, the evaluation of the host `request_meta`
|
|
22
|
+
# for the request this listing leads to -- for the listing and no
|
|
23
|
+
# longer. Each reservation is kept by name: it is handed to that
|
|
24
|
+
# server's own fetch when the loop reaches it ({#fetching_from}) and to
|
|
25
|
+
# nothing else, so a list a notification listener runs meanwhile on a
|
|
26
|
+
# server the loop has not reached cannot spend an evaluation this
|
|
27
|
+
# decision weighed for that server's fetch.
|
|
28
|
+
# @param method [String] the JSON-RPC method the listing's fetch sends
|
|
29
|
+
# @yield the listing
|
|
30
|
+
# @return [Object] the block's value
|
|
31
|
+
def holding_request_meta(method)
|
|
32
|
+
holders = servers.select { |server| server.respond_to?(:open_request_meta_hold, true) }
|
|
33
|
+
reservations = holders.to_h { |server| [server, server.send(:open_request_meta_hold, method)] }
|
|
34
|
+
request_meta_reservations.push(reservations)
|
|
35
|
+
begin
|
|
36
|
+
yield
|
|
37
|
+
ensure
|
|
38
|
+
pop_request_meta_reservations
|
|
39
|
+
holders.each { |server| server.send(:close_request_meta_hold) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Run this server's fetch under the reservation this listing opened for
|
|
44
|
+
# it: the transport's own listing operation adopts it, and only it --
|
|
45
|
+
# an operation that begins meanwhile finds nothing to adopt and
|
|
46
|
+
# reserves its own.
|
|
47
|
+
# @param server [MCPClient::ServerBase]
|
|
48
|
+
# @yield the fetch
|
|
49
|
+
# @return [Object] the block's value
|
|
50
|
+
def fetching_from(server)
|
|
51
|
+
reservation = request_meta_reservations.last&.[](server)
|
|
52
|
+
return yield unless reservation && server.respond_to?(:offer_request_meta_hold, true)
|
|
53
|
+
|
|
54
|
+
server.send(:offer_request_meta_hold, reservation)
|
|
55
|
+
begin
|
|
56
|
+
yield
|
|
57
|
+
ensure
|
|
58
|
+
server.send(:withdraw_request_meta_hold)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The reservations of the listings open on this thread, innermost last:
|
|
63
|
+
# a listing a notification listener nests inside this one has its own,
|
|
64
|
+
# and neither reaches the other's.
|
|
65
|
+
# @return [Array<Hash>]
|
|
66
|
+
def request_meta_reservations
|
|
67
|
+
Thread.current[request_meta_reservations_key] ||= []
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [void]
|
|
71
|
+
def pop_request_meta_reservations
|
|
72
|
+
stack = Thread.current[request_meta_reservations_key]
|
|
73
|
+
return nil unless stack.is_a?(Array)
|
|
74
|
+
|
|
75
|
+
stack.pop
|
|
76
|
+
Thread.current[request_meta_reservations_key] = nil if stack.empty?
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Symbol] this client's thread-local key for the reservations
|
|
81
|
+
# its open listings hold
|
|
82
|
+
def request_meta_reservations_key
|
|
83
|
+
:"mcp_client_list_reservations_#{object_id}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A freshness check reads the parameters each server's next request
|
|
87
|
+
# would carry, which evaluates a host `request_meta` callable; the
|
|
88
|
+
# transports hold that evaluation for the fetch the check decides on.
|
|
89
|
+
# Nothing is fetched after a snapshot is served, so the held metadata is
|
|
90
|
+
# dropped instead of being sent by some later request.
|
|
91
|
+
# @return [void]
|
|
92
|
+
def release_held_request_meta
|
|
93
|
+
servers.each do |server|
|
|
94
|
+
server.send(:release_held_request_meta) if server.respond_to?(:release_held_request_meta, true)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The effective-parameter fingerprint a server's next request would
|
|
99
|
+
# carry, read before a fetch so its slice of the cache is tagged with
|
|
100
|
+
# the parameters of the list it holds (never with a leftover of whatever
|
|
101
|
+
# request ran last on this thread).
|
|
102
|
+
# @param server [MCPClient::ServerBase]
|
|
103
|
+
# @return [String, nil]
|
|
104
|
+
def params_fingerprint_for(server)
|
|
105
|
+
return nil unless server.respond_to?(:current_params_fingerprint, true)
|
|
106
|
+
|
|
107
|
+
server.send(:current_params_fingerprint)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A forced refresh (`cache: false`) must really re-list. A transport
|
|
111
|
+
# keeps a list the server bounded with a positive `ttlMs` and answers
|
|
112
|
+
# from it without sending anything at all (MCP 2026-07-28
|
|
113
|
+
# server/utilities/caching), so the entry that bounds it is dropped
|
|
114
|
+
# first and the fetch reaches the server.
|
|
115
|
+
# @param server [MCPClient::ServerBase]
|
|
116
|
+
# @param kind [Symbol] :tools, :prompts or :resources
|
|
117
|
+
# @return [void]
|
|
118
|
+
def refresh_server_cache(server, kind)
|
|
119
|
+
server.send(:invalidate_cache, kind) if server.respond_to?(:invalidate_cache, true)
|
|
120
|
+
server.send(:invalidate_list_cache, kind) if server.respond_to?(:invalidate_list_cache, true)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A call and the re-resolve that follows it share one slot for the
|
|
124
|
+
# definition the request went out under, so a nested call (a listener
|
|
125
|
+
# the response's notification dispatch runs) records into its own and
|
|
126
|
+
# leaves this one alone.
|
|
127
|
+
# @param server [MCPClient::ServerBase]
|
|
128
|
+
# @yield the call and its validation
|
|
129
|
+
# @return [Object] the block's value
|
|
130
|
+
def with_called_tool_definition(server, &block)
|
|
131
|
+
return block.call unless server.respond_to?(:recording_called_tool_definition, true)
|
|
132
|
+
|
|
133
|
+
server.send(:recording_called_tool_definition, &block)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @param cache [Boolean] whether a cached list may answer
|
|
137
|
+
# @return [Array<MCPClient::Tool>]
|
|
138
|
+
def collect_tools_from_servers(cache)
|
|
139
|
+
tools = []
|
|
140
|
+
connection_errors = []
|
|
141
|
+
# Read before the fetch so an invalidation that lands while it runs is
|
|
142
|
+
# noticed. The mutex is never held across a request: a response may
|
|
143
|
+
# dispatch a notification, on this very thread, that empties the cache.
|
|
144
|
+
generation = @cache_mutex.synchronize { @tool_cache_generation }
|
|
145
|
+
|
|
146
|
+
servers.each do |server|
|
|
147
|
+
refresh_server_cache(server, :tools) unless cache
|
|
148
|
+
# The parameters this fetch will carry are read before it goes out:
|
|
149
|
+
# whatever request ran last on this thread says nothing about it.
|
|
150
|
+
fingerprint = params_fingerprint_for(server)
|
|
151
|
+
server_tools = fetching_from(server) { server.list_tools }
|
|
152
|
+
# What was fetched answers this caller either way.
|
|
153
|
+
tools.concat(server_tools)
|
|
154
|
+
# Replace this server's slice: an item the refreshed list no longer
|
|
155
|
+
# carries must not linger from the previous fetch. A
|
|
156
|
+
# tools/list_changed that landed while the fetch ran -- the one a
|
|
157
|
+
# HeaderMismatch refresh announces included -- already replaced these
|
|
158
|
+
# definitions, so caching them would hand the superseded ones on.
|
|
159
|
+
replace_cached_slice(:tools, @tool_cache, server, fingerprint,
|
|
160
|
+
generation: generation) do
|
|
161
|
+
server_tools.each do |tool|
|
|
162
|
+
@tool_cache[cache_key_for(server, tool.name)] = MCPClient::DeepCopy.copy(tool)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
166
|
+
# Fast-fail on authorization errors for better user experience
|
|
167
|
+
# If this is the first server or we haven't collected any tools yet,
|
|
168
|
+
# raise the auth error directly to avoid cascading error messages
|
|
169
|
+
raise e if e.message.include?('Authorization failed') && tools.empty?
|
|
170
|
+
|
|
171
|
+
# Store the error and try other servers
|
|
172
|
+
connection_errors << e
|
|
173
|
+
@logger.error("Server error: #{e.message}")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# If we didn't get any tools from any server but have servers configured, report failure
|
|
177
|
+
if tools.empty? && !servers.empty?
|
|
178
|
+
raise connection_errors.first if connection_errors.any?
|
|
179
|
+
|
|
180
|
+
@logger.warn('No tools found from any server.')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
tools
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @param cache [Boolean] whether a cached list may answer
|
|
187
|
+
# @return [Array<MCPClient::Prompt>]
|
|
188
|
+
def collect_prompts_from_servers(cache)
|
|
189
|
+
prompts = []
|
|
190
|
+
connection_errors = []
|
|
191
|
+
|
|
192
|
+
servers.each do |server|
|
|
193
|
+
refresh_server_cache(server, :prompts) unless cache
|
|
194
|
+
fingerprint = params_fingerprint_for(server)
|
|
195
|
+
server_prompts = fetching_from(server) { server.list_prompts }
|
|
196
|
+
replace_cached_slice(:prompts, @prompt_cache, server, fingerprint) do
|
|
197
|
+
server_prompts.each do |prompt|
|
|
198
|
+
@prompt_cache[cache_key_for(server, prompt.name)] = MCPClient::DeepCopy.copy(prompt)
|
|
199
|
+
prompts << prompt
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
203
|
+
# Fast-fail on authorization errors for better user experience
|
|
204
|
+
raise e if e.message.include?('Authorization failed') && prompts.empty?
|
|
205
|
+
|
|
206
|
+
connection_errors << e
|
|
207
|
+
@logger.error("Server error: #{e.message}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
prompts
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @param cache [Boolean] whether a cached list may answer
|
|
214
|
+
# @return [Hash] the aggregated resources result
|
|
215
|
+
def collect_resources_from_servers(cache)
|
|
216
|
+
resources = []
|
|
217
|
+
connection_errors = []
|
|
218
|
+
|
|
219
|
+
servers.each do |server|
|
|
220
|
+
refresh_server_cache(server, :resources) unless cache
|
|
221
|
+
fingerprint = params_fingerprint_for(server)
|
|
222
|
+
result = fetching_from(server) { server.list_resources }
|
|
223
|
+
resource_list = result['resources'] || []
|
|
224
|
+
replace_cached_slice(:resources, @resource_cache, server, fingerprint) do
|
|
225
|
+
resource_list.each do |resource|
|
|
226
|
+
@resource_cache[cache_key_for(server, resource.uri)] = MCPClient::DeepCopy.copy(resource)
|
|
227
|
+
resources << resource
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
231
|
+
# Fast-fail on authorization errors for better user experience
|
|
232
|
+
raise e if e.message.include?('Authorization failed') && resources.empty?
|
|
233
|
+
|
|
234
|
+
connection_errors << e
|
|
235
|
+
@logger.error("Server error: #{e.message}")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Return hash format consistent with server methods
|
|
239
|
+
{ 'resources' => resources, 'nextCursor' => nil }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|