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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. 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