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
@@ -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
@@ -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
@@ -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