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
|
@@ -22,6 +22,8 @@ module MCPClient
|
|
|
22
22
|
# @option options [Object, nil] :storage Storage backend for OAuth tokens and client info
|
|
23
23
|
# @option options [String, nil] :client_id_metadata_url HTTPS URL of this client's
|
|
24
24
|
# Client ID Metadata Document (SEP-991)
|
|
25
|
+
# @option options [String, nil] :application_type 'native' or 'web' for Dynamic Client
|
|
26
|
+
# Registration (MCP 2026-07-28; derived from the redirect URI when omitted)
|
|
25
27
|
# @return [ServerHTTP] OAuth-enabled HTTP server
|
|
26
28
|
def self.create_http_server(server_url:, **options)
|
|
27
29
|
opts = default_server_options.merge(options)
|
|
@@ -32,7 +34,8 @@ module MCPClient
|
|
|
32
34
|
scope: opts[:scope],
|
|
33
35
|
logger: opts[:logger],
|
|
34
36
|
storage: opts[:storage],
|
|
35
|
-
client_id_metadata_url: opts[:client_id_metadata_url]
|
|
37
|
+
client_id_metadata_url: opts[:client_id_metadata_url],
|
|
38
|
+
application_type: opts[:application_type]
|
|
36
39
|
)
|
|
37
40
|
|
|
38
41
|
ServerHTTP.new(
|
|
@@ -61,7 +64,8 @@ module MCPClient
|
|
|
61
64
|
scope: opts[:scope],
|
|
62
65
|
logger: opts[:logger],
|
|
63
66
|
storage: opts[:storage],
|
|
64
|
-
client_id_metadata_url: opts[:client_id_metadata_url]
|
|
67
|
+
client_id_metadata_url: opts[:client_id_metadata_url],
|
|
68
|
+
application_type: opts[:application_type]
|
|
65
69
|
)
|
|
66
70
|
|
|
67
71
|
ServerStreamableHTTP.new(
|
|
@@ -93,13 +97,17 @@ module MCPClient
|
|
|
93
97
|
# @param server [ServerHTTP, ServerStreamableHTTP] The OAuth-enabled server
|
|
94
98
|
# @param code [String] Authorization code from callback
|
|
95
99
|
# @param state [String] State parameter from callback
|
|
100
|
+
# @param iss [String, nil] the `iss` parameter of the authorization response (RFC 9207,
|
|
101
|
+
# MCP 2026-07-28), validated against the recorded issuer before the token exchange
|
|
96
102
|
# @return [Auth::Token] Access token
|
|
97
103
|
# @raise [ArgumentError] if server doesn't have OAuth provider
|
|
98
|
-
def self.complete_oauth_flow(server, code, state)
|
|
104
|
+
def self.complete_oauth_flow(server, code, state, iss: nil)
|
|
99
105
|
oauth_provider = server.instance_variable_get(:@oauth_provider)
|
|
100
106
|
raise ArgumentError, 'Server does not have OAuth provider configured' unless oauth_provider
|
|
101
107
|
|
|
102
|
-
oauth_provider.complete_authorization_flow(code, state)
|
|
108
|
+
return oauth_provider.complete_authorization_flow(code, state) if iss.nil?
|
|
109
|
+
|
|
110
|
+
oauth_provider.complete_authorization_flow(code, state, iss: iss)
|
|
103
111
|
end
|
|
104
112
|
|
|
105
113
|
# Check if server has a valid OAuth access token
|
|
@@ -125,7 +133,8 @@ module MCPClient
|
|
|
125
133
|
name: nil,
|
|
126
134
|
logger: nil,
|
|
127
135
|
storage: nil,
|
|
128
|
-
client_id_metadata_url: nil
|
|
136
|
+
client_id_metadata_url: nil,
|
|
137
|
+
application_type: nil
|
|
129
138
|
}
|
|
130
139
|
end
|
|
131
140
|
end
|
data/lib/mcp_client/prompt.rb
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'deep_copy'
|
|
4
|
+
|
|
3
5
|
module MCPClient
|
|
4
6
|
# Representation of an MCP prompt
|
|
5
7
|
class Prompt
|
|
8
|
+
include MCPClient::DeepCopy
|
|
9
|
+
|
|
6
10
|
# @!attribute [r] name
|
|
7
11
|
# @return [String] the name of the prompt
|
|
8
12
|
# @!attribute [r] title
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# The Authorization one request went out with, kept per thread and per
|
|
5
|
+
# transport (MCP 2026-07-28 caching, cacheScope "private"): a result is
|
|
6
|
+
# bound to the credentials of its own request rather than to whatever the
|
|
7
|
+
# transport is configured with at the moment it is recorded.
|
|
8
|
+
#
|
|
9
|
+
# Every transport that can send an Authorization header keeps it the same
|
|
10
|
+
# way, in a slot named after the transport's `object_id` so that
|
|
11
|
+
# {MCPClient::ResultCaching#forget_transport_thread_state} finds it: a
|
|
12
|
+
# worker thread that builds and discards transports must not keep one
|
|
13
|
+
# entry per transport for its whole life.
|
|
14
|
+
module RequestAuthorization
|
|
15
|
+
# Thread-local marker meaning "this attempt has not applied its headers
|
|
16
|
+
# yet": a failure before that point leaves the credentials of the
|
|
17
|
+
# attempt unknown, so no private stale copy may be served for it.
|
|
18
|
+
UNRECORDED_AUTHORIZATION = :unrecorded
|
|
19
|
+
|
|
20
|
+
# Thread-local marker for "this attempt went out with no Authorization
|
|
21
|
+
# at all". The anonymous context is `nil` everywhere else, and an empty
|
|
22
|
+
# thread-local slot is `nil` too: a request that really was anonymous is
|
|
23
|
+
# noted with this marker so that a slot a cleanup dropped reads as
|
|
24
|
+
# unrecorded rather than as an anonymous request that never happened.
|
|
25
|
+
ANONYMOUS_AUTHORIZATION = :anonymous
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Remember the Authorization header a request goes out with, on the
|
|
30
|
+
# thread that sends it, so the result it brings back can be bound to
|
|
31
|
+
# that context.
|
|
32
|
+
# @param authorization [String, nil] the Authorization header of the request
|
|
33
|
+
# @return [void]
|
|
34
|
+
def note_request_authorization(authorization)
|
|
35
|
+
file_request_authorization(authorization_fingerprint(authorization) || ANONYMOUS_AUTHORIZATION)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Forget the header recorded before middleware ran: until the request is
|
|
39
|
+
# sent (or its error reports the headers) the attempt's context is
|
|
40
|
+
# unknown, so no private stale copy can be served for it.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def note_request_authorization_pending
|
|
43
|
+
file_request_authorization(UNRECORDED_AUTHORIZATION)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Run one exchange with a record of its own.
|
|
47
|
+
#
|
|
48
|
+
# A record is filed against the innermost exchange open on this thread,
|
|
49
|
+
# and when that exchange ends the record it made stands again — over
|
|
50
|
+
# anything a request nested inside it left behind. A host `on_complete`
|
|
51
|
+
# may send a request of its own, under credentials of its own, before the
|
|
52
|
+
# exchange it is nested in has bound its result or failed; the failing
|
|
53
|
+
# request must still be judged by what it carried itself (MCP 2026-07-28
|
|
54
|
+
# caching, cacheScope "private").
|
|
55
|
+
#
|
|
56
|
+
# A resend the exchange makes for itself — the one after a session
|
|
57
|
+
# restart — is not nested: it opens no exchange of its own, so its
|
|
58
|
+
# credentials are this exchange's, exactly as they were before.
|
|
59
|
+
# @yield the exchange
|
|
60
|
+
# @return [Object] the block's value
|
|
61
|
+
def recording_one_exchange
|
|
62
|
+
stack = (Thread.current[exchange_records_key] ||= [])
|
|
63
|
+
own = []
|
|
64
|
+
stack.push(own)
|
|
65
|
+
begin
|
|
66
|
+
yield
|
|
67
|
+
ensure
|
|
68
|
+
stack.pop
|
|
69
|
+
Thread.current[exchange_records_key] = nil if stack.empty?
|
|
70
|
+
# Written straight to the slot, never filed: this record is this
|
|
71
|
+
# exchange's, and filing it would make it the enclosing exchange's
|
|
72
|
+
# too — which is the very confusion the frame exists to prevent.
|
|
73
|
+
Thread.current[request_authorization_key] = own.first unless own.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param record [String, Symbol, nil] the record to file for the request being sent
|
|
78
|
+
# @return [void]
|
|
79
|
+
def file_request_authorization(record)
|
|
80
|
+
Thread.current[request_authorization_key] = record
|
|
81
|
+
own = Thread.current[exchange_records_key]&.last
|
|
82
|
+
own&.replace([record])
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The record this thread holds for the request it is sending, exactly as
|
|
87
|
+
# it stands. Taken before a response is parsed and put back afterwards
|
|
88
|
+
# ({MCPClient::HttpTransportBase::CacheSupport#exchange_jsonrpc}): the
|
|
89
|
+
# parse dispatches the notifications the response carried, and a request
|
|
90
|
+
# host code nests inside it would otherwise leave its own record here.
|
|
91
|
+
# @return [String, Symbol, nil]
|
|
92
|
+
def recorded_request_authorization
|
|
93
|
+
Thread.current[request_authorization_key]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param record [String, Symbol, nil] a record {#recorded_request_authorization} handed out
|
|
97
|
+
# @return [void]
|
|
98
|
+
def restore_request_authorization(record)
|
|
99
|
+
file_request_authorization(record)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [String, nil] the Authorization header of the request this thread last sent
|
|
103
|
+
def request_authorization_context
|
|
104
|
+
context = Thread.current[request_authorization_key]
|
|
105
|
+
context.is_a?(String) ? context : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Boolean] whether the current attempt on this thread applied
|
|
109
|
+
# its headers. An empty slot — nothing sent yet on this thread, or a
|
|
110
|
+
# cleanup that dropped what this transport left on it — is as
|
|
111
|
+
# unrecorded as the pending marker.
|
|
112
|
+
def request_authorization_recorded?
|
|
113
|
+
context = Thread.current[request_authorization_key]
|
|
114
|
+
!context.nil? && context != UNRECORDED_AUTHORIZATION
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Symbol] the thread-local key of this transport's request authorization
|
|
118
|
+
def request_authorization_key
|
|
119
|
+
:"mcp_client_request_authorization_#{object_id}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Symbol] the thread-local key of the exchanges open on this
|
|
123
|
+
# thread, innermost last
|
|
124
|
+
def exchange_records_key
|
|
125
|
+
:"mcp_client_exchange_records_#{object_id}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# The operations a transport may make a cache decision for, each wrapped in
|
|
5
|
+
# a scope that reserves the evaluation of the host's `request_meta` for the
|
|
6
|
+
# request the operation leads to (MCP 2026-07-28 server/utilities/caching:
|
|
7
|
+
# a decision is weighed on the parameters the request will carry, and a
|
|
8
|
+
# host callable that vends a one-time value is read once for the two).
|
|
9
|
+
#
|
|
10
|
+
# Prepended to the transports, so the reservation is a property of the
|
|
11
|
+
# operation rather than of any path through it:
|
|
12
|
+
#
|
|
13
|
+
# * it is adopted only by the operation it was opened for, which the
|
|
14
|
+
# opener hands it to by name ({MCPClient::RequestMetadata#offer_request_meta_hold});
|
|
15
|
+
# an operation that merely uses the same method reserves its own;
|
|
16
|
+
# * it is spent by the request the operation sends and by nothing else --
|
|
17
|
+
# a reconnect's handshake, the `subscriptions/listen` a reconnect
|
|
18
|
+
# re-opens, a `notifications/cancelled` for an abandoned request, and
|
|
19
|
+
# every request host code issues from behind the boundary the transport
|
|
20
|
+
# crosses to reach it -- a nested list, a raw `rpc_request` or
|
|
21
|
+
# `fetch_prompts_list` of the very method the operation holds -- all read
|
|
22
|
+
# the host afresh ({MCPClient::JsonRpcCommon#request_meta_claim});
|
|
23
|
+
# * it never outlives the operation, whichever way that ends -- a value
|
|
24
|
+
# returned, a reconnect or an initialization that raised, an error a
|
|
25
|
+
# caller swallowed -- because the scope drops it from an `ensure`.
|
|
26
|
+
module RequestMetaScope
|
|
27
|
+
# Each operation and the JSON-RPC method of the request it leads to.
|
|
28
|
+
SCOPED_OPERATIONS = {
|
|
29
|
+
list_tools: 'tools/list',
|
|
30
|
+
list_prompts: 'prompts/list',
|
|
31
|
+
list_resources: 'resources/list',
|
|
32
|
+
list_resource_templates: 'resources/templates/list',
|
|
33
|
+
read_resource: 'resources/read',
|
|
34
|
+
get_prompt: 'prompts/get',
|
|
35
|
+
call_tool: 'tools/call'
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
SCOPED_OPERATIONS.each do |operation, request_method|
|
|
39
|
+
next if operation == :call_tool
|
|
40
|
+
|
|
41
|
+
define_method(operation) do |*args, **kwargs, &block|
|
|
42
|
+
holding_request_meta(request_method) { super(*args, **kwargs, &block) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A call also gets a slot of its own for the tool definition its request
|
|
47
|
+
# goes out under, so a nested exchange records into its own
|
|
48
|
+
# ({MCPClient::CalledToolDefinition}).
|
|
49
|
+
define_method(:call_tool) do |*args, **kwargs, &block|
|
|
50
|
+
holding_request_meta(SCOPED_OPERATIONS[:call_tool]) do
|
|
51
|
+
recording_called_tool_definition { super(*args, **kwargs, &block) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Where a transport hands control to host code: a notification listener
|
|
56
|
+
# (and, through it, a subscription's listeners and the client's own
|
|
57
|
+
# dispatch) and a handler for a server-initiated request. Whatever that
|
|
58
|
+
# code asks of the transport is an operation of its own -- a raw
|
|
59
|
+
# `rpc_request`, a `send_rpc`, a public `fetch_prompts_list`, a nested
|
|
60
|
+
# list -- so it never reaches the reservation the operation it
|
|
61
|
+
# interrupted is holding, whatever method it names. A `tools/call` it
|
|
62
|
+
# issues records the definition it goes out under into a slot of its own
|
|
63
|
+
# for the same reason ({MCPClient::CalledToolDefinition}): the call whose
|
|
64
|
+
# response is still being parsed keeps its own.
|
|
65
|
+
HOST_CALLBACKS = %i[route_notification handle_server_request].freeze
|
|
66
|
+
|
|
67
|
+
HOST_CALLBACKS.each do |callback|
|
|
68
|
+
define_method(callback) do |*args, **kwargs, &block|
|
|
69
|
+
raise NoMethodError, "undefined method '#{callback}' for #{self.class}" unless defined?(super)
|
|
70
|
+
|
|
71
|
+
outside_request_meta_hold do
|
|
72
|
+
outside_called_tool_definition { super(*args, **kwargs, &block) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module MCPClient
|
|
7
|
+
# The metadata a request carries and the fingerprint a cached result is
|
|
8
|
+
# bound to (MCP 2026-07-28 basic/index "_meta", server/utilities/caching):
|
|
9
|
+
# the reserved protocol keys, the effective parameters of the request this
|
|
10
|
+
# thread last built, and the evaluation of the host's `request_meta` that a
|
|
11
|
+
# cache decision holds for the request it leads to.
|
|
12
|
+
#
|
|
13
|
+
# Each slot is named after the transport's `object_id`, so a worker thread
|
|
14
|
+
# that builds and discards transports keeps nothing of theirs for its life.
|
|
15
|
+
module RequestMetadata
|
|
16
|
+
# `_meta` keys that identify one request rather than what it asks for
|
|
17
|
+
# (the progress token and the W3C trace identifiers): a cached result
|
|
18
|
+
# does not depend on them. `baggage` is deliberately not among them --
|
|
19
|
+
# it carries application-defined context (a tenant, a locale), which a
|
|
20
|
+
# server may well vary its result by, so a result cached under one
|
|
21
|
+
# baggage is never served under another.
|
|
22
|
+
CACHE_NEUTRAL_META_KEYS = %w[progressToken traceparent tracestate].freeze
|
|
23
|
+
|
|
24
|
+
# Remember the effective parameters a request goes out with, so a result
|
|
25
|
+
# cached from it is bound to them (MCP 2026-07-28 caching: a server may
|
|
26
|
+
# vary a result by host metadata such as a vendor tenant key).
|
|
27
|
+
# @param params [Hash, nil] the effective (wire) parameters
|
|
28
|
+
# @return [void]
|
|
29
|
+
def note_request_params(params)
|
|
30
|
+
Thread.current[request_params_key] = params_fingerprint_of(params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String, nil] the fingerprint of the effective parameters of
|
|
34
|
+
# the request this thread last built
|
|
35
|
+
def request_params_fingerprint
|
|
36
|
+
Thread.current[request_params_key]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The fingerprint this thread holds, exactly as it stands — never the
|
|
40
|
+
# transport's reading of it. Taken when an exchange starts and put back
|
|
41
|
+
# when it ends ({MCPClient::HttpTransportBase::CacheSupport#exchange_jsonrpc}):
|
|
42
|
+
# a request nested inside it notes parameters of its own, and the host
|
|
43
|
+
# may go on rewriting the metadata it handed the transport while the
|
|
44
|
+
# response is on its way back. A fingerprint is taken when the request is
|
|
45
|
+
# built, so what it describes is the request as sent.
|
|
46
|
+
# @return [String, Symbol, nil]
|
|
47
|
+
def recorded_request_params
|
|
48
|
+
Thread.current[request_params_key]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param record [String, Symbol, nil] a record {#recorded_request_params} handed out
|
|
52
|
+
# @return [void]
|
|
53
|
+
def restore_request_params(record)
|
|
54
|
+
Thread.current[request_params_key] = record
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Marks an attempt that has not built its request yet: the parameters
|
|
58
|
+
# of the previous request on this thread say nothing about it.
|
|
59
|
+
UNRECORDED_PARAMS = :unrecorded
|
|
60
|
+
|
|
61
|
+
# Marks a request whose effective parameters the transport cannot read:
|
|
62
|
+
# host middleware may rewrite the body after the transport built it, so
|
|
63
|
+
# the parameters the server answers are not the ones a fingerprint of the
|
|
64
|
+
# request would describe. It is not a fingerprint and matches none, so no
|
|
65
|
+
# result is served across it whatever its `cacheScope` — "public" permits
|
|
66
|
+
# sharing across callers, not across result-affecting parameters.
|
|
67
|
+
OPAQUE_PARAMS = :opaque
|
|
68
|
+
|
|
69
|
+
# @return [void]
|
|
70
|
+
def note_request_params_pending
|
|
71
|
+
Thread.current[request_params_key] = UNRECORDED_PARAMS
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The evaluation of the host's `request_meta` that one operation reserves
|
|
75
|
+
# for the request it leads to: the JSON-RPC method of that request, the
|
|
76
|
+
# evaluation once it has been made, and whether the request it was held
|
|
77
|
+
# for has already spent it.
|
|
78
|
+
#
|
|
79
|
+
# A reservation is claimed by that one request and by nothing else.
|
|
80
|
+
# Everything else a transport sends while it is open -- a reconnect's
|
|
81
|
+
# handshake, the `subscriptions/listen` a reconnect re-opens, the
|
|
82
|
+
# `notifications/cancelled` for an abandoned request, a raw `rpc_request`
|
|
83
|
+
# or nested list a notification listener issues -- reads the host afresh
|
|
84
|
+
# and leaves the reservation for the request that holds it.
|
|
85
|
+
HeldRequestMeta = Struct.new(:request_method, :evaluated, :value, :spent)
|
|
86
|
+
|
|
87
|
+
# Reserve the evaluation of the host's `request_meta` for the request
|
|
88
|
+
# `method` this operation leads to, for the operation's dynamic extent
|
|
89
|
+
# and no longer: however it ends -- a value returned, a reconnect that
|
|
90
|
+
# raised, a caller that swallowed the error -- the reservation goes with
|
|
91
|
+
# it, so no later request on this thread can carry it.
|
|
92
|
+
# @param method [String] the JSON-RPC method of the request the operation sends
|
|
93
|
+
# @yield the operation
|
|
94
|
+
# @return [Object] the block's value
|
|
95
|
+
def holding_request_meta(method)
|
|
96
|
+
open_request_meta_hold(method)
|
|
97
|
+
begin
|
|
98
|
+
yield
|
|
99
|
+
ensure
|
|
100
|
+
close_request_meta_hold
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Open a hold scope without a block, for a caller whose operation spans
|
|
105
|
+
# several transports (a client listing across its servers). Every opener
|
|
106
|
+
# closes it from an `ensure`.
|
|
107
|
+
#
|
|
108
|
+
# The operation adopts a reservation only when that very reservation was
|
|
109
|
+
# handed to it ({#offer_request_meta_hold}) -- the opener naming the
|
|
110
|
+
# operation it made it for, immediately before invoking it. Sharing a
|
|
111
|
+
# method name is not enough: an operation that begins meanwhile (a list a
|
|
112
|
+
# notification listener runs on a server the opener's loop has not
|
|
113
|
+
# reached) would otherwise spend an evaluation weighed for somebody else,
|
|
114
|
+
# sending its tenant, baggage or nonce on the wrong request and leaving
|
|
115
|
+
# the right one to go out under an evaluation nothing weighed.
|
|
116
|
+
# @param method [String] the JSON-RPC method of the request the operation sends
|
|
117
|
+
# @return [MCPClient::RequestMetadata::HeldRequestMeta] this operation's reservation
|
|
118
|
+
def open_request_meta_hold(method)
|
|
119
|
+
stack = (Thread.current[held_request_meta_key] ||= [])
|
|
120
|
+
reservation = adoptable_request_meta_hold(take_offered_request_meta_hold, stack.last, method) ||
|
|
121
|
+
HeldRequestMeta.new(method, false, nil, false)
|
|
122
|
+
stack.push(reservation)
|
|
123
|
+
reservation
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param offered [MCPClient::RequestMetadata::HeldRequestMeta, nil] the reservation handed over
|
|
127
|
+
# @param innermost [MCPClient::RequestMetadata::HeldRequestMeta, nil] the reservation open here
|
|
128
|
+
# @param method [String] the JSON-RPC method of the request the operation sends
|
|
129
|
+
# @return [MCPClient::RequestMetadata::HeldRequestMeta, nil] the offer when
|
|
130
|
+
# it really is the reservation open here, still waiting for the request
|
|
131
|
+
# it was made for
|
|
132
|
+
def adoptable_request_meta_hold(offered, innermost, method)
|
|
133
|
+
return nil if offered.nil? || !offered.equal?(innermost)
|
|
134
|
+
|
|
135
|
+
offered if offered.request_method == method && !offered.spent
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Hand a reservation to the one operation it was opened for: the next
|
|
139
|
+
# operation this transport opens adopts it, and only if it really is the
|
|
140
|
+
# reservation currently innermost here. The offer is taken up once; an
|
|
141
|
+
# opener that never invokes the operation withdraws it.
|
|
142
|
+
# @param reservation [MCPClient::RequestMetadata::HeldRequestMeta]
|
|
143
|
+
# @return [void]
|
|
144
|
+
def offer_request_meta_hold(reservation)
|
|
145
|
+
Thread.current[offered_request_meta_key] = reservation
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [void]
|
|
150
|
+
def withdraw_request_meta_hold
|
|
151
|
+
Thread.current[offered_request_meta_key] = nil
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [MCPClient::RequestMetadata::HeldRequestMeta, nil] the offer,
|
|
156
|
+
# which no later operation can take up again
|
|
157
|
+
def take_offered_request_meta_hold
|
|
158
|
+
offered = Thread.current[offered_request_meta_key]
|
|
159
|
+
Thread.current[offered_request_meta_key] = nil unless offered.nil?
|
|
160
|
+
offered
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The boundary a transport crosses when it hands control to host code: a
|
|
164
|
+
# notification listener, a handler for a server-initiated request. The
|
|
165
|
+
# reservation the open operation holds is out of reach behind it, so
|
|
166
|
+
# whatever that code issues -- a nested list, a raw `rpc_request`, a
|
|
167
|
+
# `fetch_prompts_list`, whatever method it names -- reads the host afresh
|
|
168
|
+
# and is an operation of its own.
|
|
169
|
+
# @yield the host code
|
|
170
|
+
# @return [Object] the block's value
|
|
171
|
+
def outside_request_meta_hold
|
|
172
|
+
stack = (Thread.current[held_request_meta_key] ||= [])
|
|
173
|
+
stack.push(nil)
|
|
174
|
+
begin
|
|
175
|
+
yield
|
|
176
|
+
ensure
|
|
177
|
+
close_request_meta_hold
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Close the innermost hold scope.
|
|
182
|
+
# @return [void]
|
|
183
|
+
def close_request_meta_hold
|
|
184
|
+
stack = Thread.current[held_request_meta_key]
|
|
185
|
+
return nil unless stack.is_a?(Array)
|
|
186
|
+
|
|
187
|
+
stack.pop
|
|
188
|
+
Thread.current[held_request_meta_key] = nil if stack.empty?
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [MCPClient::RequestMetadata::HeldRequestMeta, nil] the
|
|
193
|
+
# reservation of the innermost operation open on this thread
|
|
194
|
+
def held_request_meta
|
|
195
|
+
stack = Thread.current[held_request_meta_key]
|
|
196
|
+
stack.last if stack.is_a?(Array)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @return [MCPClient::RequestMetadata::HeldRequestMeta, nil] that
|
|
200
|
+
# reservation while the request it was made for may still claim it
|
|
201
|
+
def claimable_request_meta_hold
|
|
202
|
+
held = held_request_meta
|
|
203
|
+
held unless held.nil? || held.spent
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [String] the fingerprint of the effective parameters the next
|
|
207
|
+
# request on this transport would carry. Reading it evaluates the
|
|
208
|
+
# host's request_meta, and the open operation holds that evaluation for
|
|
209
|
+
# the request the decision leads to instead of spending it on the
|
|
210
|
+
# decision alone. Outside any operation nothing is held at all: an
|
|
211
|
+
# evaluation that no request is waiting for is never kept.
|
|
212
|
+
def current_params_fingerprint
|
|
213
|
+
params_fingerprint_of(with_request_meta({}, claim: :model))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Drop a held evaluation of the host's request_meta: the decision that
|
|
217
|
+
# took it leads to no request of its own, so the next one evaluates
|
|
218
|
+
# afresh rather than sending metadata read some time ago. The scope drops
|
|
219
|
+
# it too when the operation ends; this is for a decision that settles
|
|
220
|
+
# before that.
|
|
221
|
+
# @return [void]
|
|
222
|
+
def release_held_request_meta
|
|
223
|
+
held = held_request_meta
|
|
224
|
+
return nil unless held
|
|
225
|
+
|
|
226
|
+
held.evaluated = false
|
|
227
|
+
held.value = nil
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @return [Symbol] this transport's thread-local key for held metadata
|
|
232
|
+
def held_request_meta_key
|
|
233
|
+
:"mcp_client_held_request_meta_#{object_id}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @return [Symbol] this transport's thread-local key for the reservation
|
|
237
|
+
# offered to the operation about to be opened on it
|
|
238
|
+
def offered_request_meta_key
|
|
239
|
+
:"mcp_client_offered_request_meta_#{object_id}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A stable fingerprint of the metadata that shapes a result: the
|
|
243
|
+
# effective `_meta` without the protocol version, the log level and the
|
|
244
|
+
# per-request identifiers. The client identity and capabilities stay
|
|
245
|
+
# in: a server may vary a result by who asks and by the extensions and
|
|
246
|
+
# features a request advertises, and those change when the host sets
|
|
247
|
+
# client_info, drops it, declares an extension or registers a handler.
|
|
248
|
+
# @param params [Hash, nil] effective parameters
|
|
249
|
+
# @return [String]
|
|
250
|
+
def params_fingerprint_of(params)
|
|
251
|
+
meta = params.is_a?(Hash) ? (params['_meta'] || params[:_meta]) : nil
|
|
252
|
+
meta = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
|
|
253
|
+
meta = meta.except(META_PROTOCOL_VERSION, META_LOG_LEVEL, *CACHE_NEUTRAL_META_KEYS)
|
|
254
|
+
Digest::SHA256.hexdigest(JSON.generate(deep_sort_keys(meta)))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @return [Symbol] this transport's thread-local key for the request parameters
|
|
258
|
+
def request_params_key
|
|
259
|
+
:"mcp_client_request_params_#{object_id}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @param value [Object]
|
|
263
|
+
# @return [Object] the value with every nested Hash sorted by key
|
|
264
|
+
def deep_sort_keys(value)
|
|
265
|
+
case value
|
|
266
|
+
when Hash then value.map { |k, v| [k.to_s, deep_sort_keys(v)] }.sort_by(&:first).to_h
|
|
267
|
+
when Array then value.map { |v| deep_sort_keys(v) }
|
|
268
|
+
else value
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Reserved `_meta` keys (MCP 2026-07-28 basic/index "_meta").
|
|
273
|
+
META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion'
|
|
274
|
+
META_CLIENT_INFO = 'io.modelcontextprotocol/clientInfo'
|
|
275
|
+
META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities'
|
|
276
|
+
META_LOG_LEVEL = 'io.modelcontextprotocol/logLevel'
|
|
277
|
+
META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo'
|
|
278
|
+
META_SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId'
|
|
279
|
+
|
|
280
|
+
# Per-request protocol fields the client owns. A host-supplied `_meta`
|
|
281
|
+
# may carry anything else (progressToken, trace context, vendor keys),
|
|
282
|
+
# but these are always set from the transport's own state so the body
|
|
283
|
+
# can never disagree with what the transport negotiated (on HTTP the
|
|
284
|
+
# MCP-Protocol-Version header must match the body).
|
|
285
|
+
PROTECTED_META_KEYS = [META_PROTOCOL_VERSION, META_CLIENT_INFO, META_CLIENT_CAPABILITIES].freeze
|
|
286
|
+
end
|
|
287
|
+
end
|
data/lib/mcp_client/resource.rb
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'deep_copy'
|
|
4
|
+
|
|
3
5
|
module MCPClient
|
|
4
6
|
# Representation of an MCP resource
|
|
5
7
|
class Resource
|
|
8
|
+
include MCPClient::DeepCopy
|
|
9
|
+
|
|
6
10
|
# @!attribute [r] uri
|
|
7
11
|
# @return [String] unique identifier for the resource
|
|
8
12
|
# @!attribute [r] name
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'deep_copy'
|
|
4
|
+
|
|
3
5
|
module MCPClient
|
|
4
6
|
# Representation of MCP resource content
|
|
5
7
|
# Resources can contain either text or binary data
|
|
@@ -45,6 +47,24 @@ module MCPClient
|
|
|
45
47
|
@meta = meta
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# A copy that shares nothing mutable with the original (cached contents
|
|
51
|
+
# are handed out as copies, so a caller's edits stay its own).
|
|
52
|
+
# @param source [ResourceContent]
|
|
53
|
+
# @return [void]
|
|
54
|
+
def initialize_copy(source)
|
|
55
|
+
super
|
|
56
|
+
@uri = source.uri.dup if source.uri
|
|
57
|
+
@name = source.name.dup if source.name
|
|
58
|
+
@title = source.title.dup if source.title
|
|
59
|
+
@mime_type = source.mime_type.dup if source.mime_type
|
|
60
|
+
@text = source.text.dup if source.text
|
|
61
|
+
@blob = source.blob.dup if source.blob
|
|
62
|
+
# Iterative: peer-supplied annotations or _meta may be nested deeper
|
|
63
|
+
# than the Ruby stack allows.
|
|
64
|
+
@annotations = MCPClient::DeepCopy.copy(source.annotations)
|
|
65
|
+
@meta = MCPClient::DeepCopy.copy(source.meta)
|
|
66
|
+
end
|
|
67
|
+
|
|
48
68
|
# Create a ResourceContent instance from JSON data
|
|
49
69
|
# @param data [Hash] JSON data from MCP server
|
|
50
70
|
# @return [MCPClient::ResourceContent] resource content instance
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'deep_copy'
|
|
4
|
+
|
|
3
5
|
module MCPClient
|
|
4
6
|
# Representation of an MCP resource template
|
|
5
7
|
# Resource templates allow servers to expose parameterized resources using URI templates
|
|
6
8
|
class ResourceTemplate
|
|
9
|
+
include MCPClient::DeepCopy
|
|
10
|
+
|
|
7
11
|
# @!attribute [r] uri_template
|
|
8
12
|
# @return [String] URI template following RFC 6570
|
|
9
13
|
# @!attribute [r] name
|