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
data/lib/mcp_client/auth.rb
CHANGED
|
@@ -12,20 +12,63 @@ module MCPClient
|
|
|
12
12
|
module Auth
|
|
13
13
|
# OAuth token model representing access/refresh tokens
|
|
14
14
|
class Token
|
|
15
|
-
attr_reader :access_token, :token_type, :expires_in, :scope, :refresh_token, :expires_at
|
|
15
|
+
attr_reader :access_token, :token_type, :expires_in, :scope, :refresh_token, :expires_at, :issuer
|
|
16
16
|
|
|
17
17
|
# @param access_token [String] The access token
|
|
18
18
|
# @param token_type [String] Token type (default: "Bearer")
|
|
19
19
|
# @param expires_in [Integer, nil] Token lifetime in seconds
|
|
20
20
|
# @param scope [String, nil] Token scope
|
|
21
21
|
# @param refresh_token [String, nil] Refresh token for renewal
|
|
22
|
-
|
|
22
|
+
# @param issuer [String, nil] issuer identifier of the authorization server that issued the token
|
|
23
|
+
# (MCP 2026-07-28: tokens are per authorization server and never presented to another)
|
|
24
|
+
# @param expires_at [Time, String, nil] an already-known expiry, as persisted by {#to_h}; it
|
|
25
|
+
# takes precedence over expires_in
|
|
26
|
+
def initialize(access_token:, token_type: 'Bearer', expires_in: nil, scope: nil, refresh_token: nil,
|
|
27
|
+
issuer: nil, expires_at: nil)
|
|
23
28
|
@access_token = access_token
|
|
24
29
|
@token_type = token_type
|
|
25
|
-
|
|
30
|
+
# A record read back from a storage backend that persists plain
|
|
31
|
+
# hashes carries whatever was written (or edited) there. The lifetime
|
|
32
|
+
# is validated BEFORE it is added to a Time: `Time.now + "3600"` is a
|
|
33
|
+
# TypeError out of `OAuthProvider#access_token` — a crash in the
|
|
34
|
+
# request path, over a record the read-path checks were about to
|
|
35
|
+
# refuse anyway.
|
|
36
|
+
@expires_in = self.class.usable_lifetime(expires_in)
|
|
26
37
|
@scope = scope
|
|
38
|
+
@issuer = issuer
|
|
27
39
|
@refresh_token = refresh_token
|
|
28
|
-
@expires_at = expires_in
|
|
40
|
+
@expires_at = resolve_expiry(expires_in, expires_at)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# An expiry this client cannot read is recorded as this instant. An
|
|
44
|
+
# unreadable expiry is not "no expiry": read that way, a mangled
|
|
45
|
+
# lifetime would make a token that never expires and is never
|
|
46
|
+
# refreshed. Read as an instant long past, the record is refreshed or
|
|
47
|
+
# re-authorized instead — and says so again after a round trip through
|
|
48
|
+
# storage.
|
|
49
|
+
UNREADABLE_EXPIRY = Time.at(0).utc.freeze
|
|
50
|
+
|
|
51
|
+
# A lifetime that can be added to a Time. RFC 6749 Section 5.1 makes
|
|
52
|
+
# expires_in a number of seconds; anything else says nothing.
|
|
53
|
+
# @param value [Object, nil]
|
|
54
|
+
# @return [Integer, Float, nil]
|
|
55
|
+
def self.usable_lifetime(value)
|
|
56
|
+
value if value.is_a?(Integer) || value.is_a?(Float)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An expiry instant, as persisted by {#to_h} (an ISO 8601 string) or as
|
|
60
|
+
# given. Unparseable text, or a value of another type, is no expiry.
|
|
61
|
+
# @param value [Object, nil]
|
|
62
|
+
# @return [Time, nil]
|
|
63
|
+
def self.usable_expiry(value)
|
|
64
|
+
return value if value.is_a?(Time)
|
|
65
|
+
return nil unless value.is_a?(String)
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
Time.parse(value)
|
|
69
|
+
rescue ArgumentError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
29
72
|
end
|
|
30
73
|
|
|
31
74
|
# Check if the token is expired
|
|
@@ -44,6 +87,23 @@ module MCPClient
|
|
|
44
87
|
Time.now >= (@expires_at - 300) # 5 minutes buffer
|
|
45
88
|
end
|
|
46
89
|
|
|
90
|
+
# Issuer value marking a token that must never be presented again
|
|
91
|
+
# (retired after an authorization server change, on a storage backend
|
|
92
|
+
# that cannot delete it).
|
|
93
|
+
RETIRED_ISSUER = 'urn:mcp:retired-token'
|
|
94
|
+
|
|
95
|
+
# A copy of this token bound to an authorization server (or retired).
|
|
96
|
+
# @param issuer [String]
|
|
97
|
+
# @return [Token]
|
|
98
|
+
def with_issuer(issuer)
|
|
99
|
+
self.class.from_h(to_h.merge(issuer: issuer))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Boolean] whether the token was retired
|
|
103
|
+
def retired?
|
|
104
|
+
@issuer == RETIRED_ISSUER
|
|
105
|
+
end
|
|
106
|
+
|
|
47
107
|
# Convert token to authorization header value
|
|
48
108
|
# @return [String] Authorization header value
|
|
49
109
|
def to_header
|
|
@@ -60,34 +120,55 @@ module MCPClient
|
|
|
60
120
|
scope: @scope,
|
|
61
121
|
refresh_token: @refresh_token,
|
|
62
122
|
expires_at: @expires_at&.iso8601
|
|
63
|
-
}
|
|
123
|
+
}.tap { |hash| hash[:issuer] = @issuer if @issuer }
|
|
64
124
|
end
|
|
65
125
|
|
|
126
|
+
# The instant this token expires: the recorded one, else the lifetime
|
|
127
|
+
# added to now, else none at all — and {UNREADABLE_EXPIRY} when the
|
|
128
|
+
# record carries an expiry, or a lifetime, that is neither.
|
|
129
|
+
#
|
|
130
|
+
# A record that carries an expires_at is answered by that expires_at
|
|
131
|
+
# alone. `expires_in` is the lifetime of a token at the moment it was
|
|
132
|
+
# ISSUED (RFC 6749 Section 5.1), and a record read back from storage was
|
|
133
|
+
# not issued now: reading `{ expires_in: 3600, expires_at: 'not a time' }`
|
|
134
|
+
# — the exact shape {#to_h} persists, with the one field this client
|
|
135
|
+
# depends on mangled — as an hour from now hands back a token that is
|
|
136
|
+
# very likely expired and is never refreshed until a request fails with
|
|
137
|
+
# it. An expiry that cannot be read is not a fresh lifetime; it is an
|
|
138
|
+
# expiry this client cannot vouch for, so the record fails closed and
|
|
139
|
+
# is refreshed or re-authorized instead.
|
|
140
|
+
# @param expires_in [Object, nil] the lifetime as given
|
|
141
|
+
# @param expires_at [Object, nil] the expiry as given
|
|
142
|
+
# @return [Time, nil]
|
|
143
|
+
def resolve_expiry(expires_in, expires_at)
|
|
144
|
+
return self.class.usable_expiry(expires_at) || UNREADABLE_EXPIRY unless expires_at.nil?
|
|
145
|
+
return Time.now + @expires_in if @expires_in
|
|
146
|
+
return nil if expires_in.nil?
|
|
147
|
+
|
|
148
|
+
UNREADABLE_EXPIRY
|
|
149
|
+
end
|
|
150
|
+
private :resolve_expiry
|
|
151
|
+
|
|
66
152
|
# Create token from hash
|
|
67
153
|
# @param data [Hash] Token data
|
|
68
154
|
# @return [Token] New token instance
|
|
69
155
|
def self.from_h(data)
|
|
70
|
-
|
|
156
|
+
new(
|
|
71
157
|
access_token: data[:access_token] || data['access_token'],
|
|
72
158
|
token_type: data[:token_type] || data['token_type'] || 'Bearer',
|
|
73
159
|
expires_in: data[:expires_in] || data['expires_in'],
|
|
74
160
|
scope: data[:scope] || data['scope'],
|
|
75
|
-
refresh_token: data[:refresh_token] || data['refresh_token']
|
|
161
|
+
refresh_token: data[:refresh_token] || data['refresh_token'],
|
|
162
|
+
issuer: data[:issuer] || data['issuer'],
|
|
163
|
+
expires_at: data[:expires_at] || data['expires_at']
|
|
76
164
|
)
|
|
77
|
-
|
|
78
|
-
# Set expires_at if provided
|
|
79
|
-
if (expires_at_str = data[:expires_at] || data['expires_at'])
|
|
80
|
-
token.instance_variable_set(:@expires_at, Time.parse(expires_at_str))
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
token
|
|
84
165
|
end
|
|
85
166
|
end
|
|
86
167
|
|
|
87
168
|
# OAuth client metadata for registration and authorization
|
|
88
169
|
class ClientMetadata
|
|
89
170
|
attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope,
|
|
90
|
-
:client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts
|
|
171
|
+
:client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts, :application_type
|
|
91
172
|
|
|
92
173
|
# @param redirect_uris [Array<String>] List of valid redirect URIs
|
|
93
174
|
# @param token_endpoint_auth_method [String] Authentication method for token endpoint
|
|
@@ -100,11 +181,13 @@ module MCPClient
|
|
|
100
181
|
# @param tos_uri [String, nil] URL of the client terms of service
|
|
101
182
|
# @param policy_uri [String, nil] URL of the client privacy policy
|
|
102
183
|
# @param contacts [Array<String>, nil] List of contact emails for the client
|
|
184
|
+
# @param application_type [String, nil] OIDC application type ('native' or 'web'), required in
|
|
185
|
+
# Dynamic Client Registration by MCP 2026-07-28
|
|
103
186
|
def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
|
|
104
187
|
grant_types: %w[authorization_code refresh_token],
|
|
105
188
|
response_types: ['code'], scope: nil,
|
|
106
189
|
client_name: nil, client_uri: nil, logo_uri: nil,
|
|
107
|
-
tos_uri: nil, policy_uri: nil, contacts: nil)
|
|
190
|
+
tos_uri: nil, policy_uri: nil, contacts: nil, application_type: nil)
|
|
108
191
|
@redirect_uris = redirect_uris
|
|
109
192
|
@token_endpoint_auth_method = token_endpoint_auth_method
|
|
110
193
|
@grant_types = grant_types
|
|
@@ -116,6 +199,7 @@ module MCPClient
|
|
|
116
199
|
@tos_uri = tos_uri
|
|
117
200
|
@policy_uri = policy_uri
|
|
118
201
|
@contacts = contacts
|
|
202
|
+
@application_type = application_type
|
|
119
203
|
end
|
|
120
204
|
|
|
121
205
|
# Convert to hash for HTTP requests
|
|
@@ -132,33 +216,84 @@ module MCPClient
|
|
|
132
216
|
logo_uri: @logo_uri,
|
|
133
217
|
tos_uri: @tos_uri,
|
|
134
218
|
policy_uri: @policy_uri,
|
|
135
|
-
contacts: @contacts
|
|
219
|
+
contacts: @contacts,
|
|
220
|
+
application_type: @application_type
|
|
136
221
|
}.compact
|
|
137
222
|
end
|
|
138
223
|
end
|
|
139
224
|
|
|
140
225
|
# Registered OAuth client information
|
|
141
226
|
class ClientInfo
|
|
142
|
-
|
|
227
|
+
# How the client id was obtained (MCP 2026-07-28 client registration):
|
|
228
|
+
# 'pre_registered' credentials belong to one authorization server and
|
|
229
|
+
# must not be reused with another; 'dynamic' registrations are redone
|
|
230
|
+
# for a new authorization server; 'cimd' (Client ID Metadata Document)
|
|
231
|
+
# client ids are portable across authorization servers.
|
|
232
|
+
REGISTRATION_TYPES = %w[pre_registered dynamic cimd].freeze
|
|
233
|
+
|
|
234
|
+
attr_reader :client_id, :client_secret, :client_id_issued_at, :client_secret_expires_at, :metadata,
|
|
235
|
+
:issuer, :registration_type
|
|
143
236
|
|
|
144
237
|
# @param client_id [String] OAuth client ID
|
|
145
238
|
# @param client_secret [String, nil] OAuth client secret (for confidential clients)
|
|
146
239
|
# @param client_id_issued_at [Integer, nil] Unix timestamp when client ID was issued
|
|
147
240
|
# @param client_secret_expires_at [Integer, nil] Unix timestamp when client secret expires
|
|
148
241
|
# @param metadata [ClientMetadata] Client metadata
|
|
242
|
+
# @param issuer [String, nil] issuer identifier of the authorization server these credentials
|
|
243
|
+
# belong to (MCP 2026-07-28 "Authorization Server Binding")
|
|
244
|
+
# @param registration_type [String, nil] one of REGISTRATION_TYPES (nil: unknown, treated as a
|
|
245
|
+
# dynamic registration; credentials a host pre-registered should say 'pre_registered')
|
|
149
246
|
def initialize(client_id:, metadata:, client_secret: nil, client_id_issued_at: nil,
|
|
150
|
-
client_secret_expires_at: nil)
|
|
247
|
+
client_secret_expires_at: nil, issuer: nil, registration_type: nil)
|
|
248
|
+
unless registration_type.nil? || REGISTRATION_TYPES.include?(registration_type)
|
|
249
|
+
raise ArgumentError, "registration_type must be one of #{REGISTRATION_TYPES.join(', ')}"
|
|
250
|
+
end
|
|
251
|
+
|
|
151
252
|
@client_id = client_id
|
|
152
253
|
@client_secret = client_secret
|
|
153
254
|
@client_id_issued_at = client_id_issued_at
|
|
154
255
|
@client_secret_expires_at = client_secret_expires_at
|
|
155
256
|
@metadata = metadata
|
|
257
|
+
@issuer = issuer
|
|
258
|
+
@registration_type = registration_type
|
|
156
259
|
end
|
|
157
260
|
|
|
158
|
-
#
|
|
261
|
+
# @return [Boolean] whether the client id is portable across authorization servers
|
|
262
|
+
def portable?
|
|
263
|
+
@registration_type == 'cimd'
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @return [Boolean] whether these are pre-registered (static) credentials
|
|
267
|
+
def pre_registered?
|
|
268
|
+
effective_registration_type == 'pre_registered'
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The registration type. Credentials persisted before the field existed
|
|
272
|
+
# count as a dynamic registration: RFC 7591's client_id_issued_at is
|
|
273
|
+
# optional, so its absence proves nothing, and this library only ever
|
|
274
|
+
# stored the registrations it made. Credentials a host pre-registered
|
|
275
|
+
# are recorded as such explicitly (registration_type: 'pre_registered').
|
|
276
|
+
# @return [String]
|
|
277
|
+
def effective_registration_type
|
|
278
|
+
@registration_type || 'dynamic'
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# A copy bound to an authorization server.
|
|
282
|
+
# @param issuer [String] the issuer identifier
|
|
283
|
+
# @param registration_type [String] the type to record (defaults to the effective type)
|
|
284
|
+
# @return [ClientInfo]
|
|
285
|
+
def with_issuer(issuer, registration_type: effective_registration_type)
|
|
286
|
+
self.class.new(client_id: @client_id, metadata: @metadata, client_secret: @client_secret,
|
|
287
|
+
client_id_issued_at: @client_id_issued_at, client_secret_expires_at: @client_secret_expires_at,
|
|
288
|
+
issuer: issuer, registration_type: registration_type)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Check if client secret is expired. RFC 7591 Section 3.2.1 gives 0 the
|
|
292
|
+
# meaning "this secret does not expire", so it is not an expiry in the
|
|
293
|
+
# past.
|
|
159
294
|
# @return [Boolean] true if client secret is expired
|
|
160
295
|
def client_secret_expired?
|
|
161
|
-
return false
|
|
296
|
+
return false if @client_secret_expires_at.nil? || @client_secret_expires_at.zero?
|
|
162
297
|
|
|
163
298
|
Time.now.to_i >= @client_secret_expires_at
|
|
164
299
|
end
|
|
@@ -171,7 +306,9 @@ module MCPClient
|
|
|
171
306
|
client_secret: @client_secret,
|
|
172
307
|
client_id_issued_at: @client_id_issued_at,
|
|
173
308
|
client_secret_expires_at: @client_secret_expires_at,
|
|
174
|
-
metadata: @metadata.to_h
|
|
309
|
+
metadata: @metadata.to_h,
|
|
310
|
+
issuer: @issuer,
|
|
311
|
+
registration_type: @registration_type
|
|
175
312
|
}.compact
|
|
176
313
|
end
|
|
177
314
|
|
|
@@ -187,7 +324,9 @@ module MCPClient
|
|
|
187
324
|
client_secret: data[:client_secret] || data['client_secret'],
|
|
188
325
|
client_id_issued_at: data[:client_id_issued_at] || data['client_id_issued_at'],
|
|
189
326
|
client_secret_expires_at: data[:client_secret_expires_at] || data['client_secret_expires_at'],
|
|
190
|
-
metadata: metadata
|
|
327
|
+
metadata: metadata,
|
|
328
|
+
issuer: data[:issuer] || data['issuer'],
|
|
329
|
+
registration_type: data[:registration_type] || data['registration_type']
|
|
191
330
|
)
|
|
192
331
|
end
|
|
193
332
|
|
|
@@ -207,7 +346,8 @@ module MCPClient
|
|
|
207
346
|
logo_uri: metadata_data[:logo_uri] || metadata_data['logo_uri'],
|
|
208
347
|
tos_uri: metadata_data[:tos_uri] || metadata_data['tos_uri'],
|
|
209
348
|
policy_uri: metadata_data[:policy_uri] || metadata_data['policy_uri'],
|
|
210
|
-
contacts: metadata_data[:contacts] || metadata_data['contacts']
|
|
349
|
+
contacts: metadata_data[:contacts] || metadata_data['contacts'],
|
|
350
|
+
application_type: metadata_data[:application_type] || metadata_data['application_type']
|
|
211
351
|
)
|
|
212
352
|
end
|
|
213
353
|
|
|
@@ -224,7 +364,8 @@ module MCPClient
|
|
|
224
364
|
class ServerMetadata
|
|
225
365
|
attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
|
|
226
366
|
:scopes_supported, :response_types_supported, :grant_types_supported,
|
|
227
|
-
:code_challenge_methods_supported, :client_id_metadata_document_supported
|
|
367
|
+
:code_challenge_methods_supported, :client_id_metadata_document_supported,
|
|
368
|
+
:authorization_response_iss_parameter_supported
|
|
228
369
|
|
|
229
370
|
# @param issuer [String] Issuer identifier URL
|
|
230
371
|
# @param authorization_endpoint [String] Authorization endpoint URL
|
|
@@ -236,9 +377,14 @@ module MCPClient
|
|
|
236
377
|
# @param code_challenge_methods_supported [Array<String>, nil] Supported PKCE code challenge methods (RFC 8414)
|
|
237
378
|
# @param client_id_metadata_document_supported [Boolean, nil] Whether the server accepts
|
|
238
379
|
# Client ID Metadata Document client IDs (MCP 2025-11-25 / SEP-991)
|
|
380
|
+
# @param authorization_response_iss_parameter_supported [Boolean, nil] Whether the server includes
|
|
381
|
+
# the `iss` parameter in authorization responses (RFC 9207 Section 2.3, MCP 2026-07-28).
|
|
382
|
+
# Defaults to the RFC 8414 default (false, "not advertised"); an explicit nil means the
|
|
383
|
+
# record carries no answer at all — see {#iss_parameter_recorded?}
|
|
239
384
|
def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
|
|
240
385
|
scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil,
|
|
241
|
-
code_challenge_methods_supported: nil, client_id_metadata_document_supported: nil
|
|
386
|
+
code_challenge_methods_supported: nil, client_id_metadata_document_supported: nil,
|
|
387
|
+
authorization_response_iss_parameter_supported: false)
|
|
242
388
|
@issuer = issuer
|
|
243
389
|
@authorization_endpoint = authorization_endpoint
|
|
244
390
|
@token_endpoint = token_endpoint
|
|
@@ -248,6 +394,45 @@ module MCPClient
|
|
|
248
394
|
@grant_types_supported = grant_types_supported
|
|
249
395
|
@code_challenge_methods_supported = code_challenge_methods_supported
|
|
250
396
|
@client_id_metadata_document_supported = client_id_metadata_document_supported
|
|
397
|
+
@authorization_response_iss_parameter_supported =
|
|
398
|
+
self.class.normalize_iss_advertisement(authorization_response_iss_parameter_supported)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Whether the server advertises the RFC 9207 `iss` authorization
|
|
402
|
+
# response parameter; when it does, a response without `iss` MUST be
|
|
403
|
+
# rejected (MCP 2026-07-28 "Authorization Response Validation").
|
|
404
|
+
# @return [Boolean]
|
|
405
|
+
def iss_parameter_supported?
|
|
406
|
+
@authorization_response_iss_parameter_supported == true
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# RFC 8414 makes authorization_response_iss_parameter_supported a JSON
|
|
410
|
+
# boolean. A document (or a persisted record) carrying anything else —
|
|
411
|
+
# "true", 1, {} — says nothing this client can act on, and "says
|
|
412
|
+
# nothing" must never be read as "not advertised": that is the reading
|
|
413
|
+
# that accepts a callback with no `iss` from a server that does send
|
|
414
|
+
# one, which is exactly the mix-up RFC 9207 exists to stop. An
|
|
415
|
+
# unusable answer is therefore treated as an advertisement — the check
|
|
416
|
+
# fails closed, refusing a response without `iss` — and is recorded as
|
|
417
|
+
# one, so a round trip through storage keeps the same reading. Only an
|
|
418
|
+
# absent value (nil) stays "no answer at all" (see
|
|
419
|
+
# {#iss_parameter_recorded?}).
|
|
420
|
+
# @param value [Object, nil] the value as given
|
|
421
|
+
# @return [Boolean, nil]
|
|
422
|
+
def self.normalize_iss_advertisement(value)
|
|
423
|
+
return value if value.nil? || value == true || value == false
|
|
424
|
+
|
|
425
|
+
true
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Whether this record actually carries an answer about the RFC 9207
|
|
429
|
+
# `iss` parameter. A record persisted before this client read the field
|
|
430
|
+
# carries none, and "no answer" must not be read as "not supported":
|
|
431
|
+
# that would accept a response without `iss` from a server that
|
|
432
|
+
# advertises it.
|
|
433
|
+
# @return [Boolean]
|
|
434
|
+
def iss_parameter_recorded?
|
|
435
|
+
!@authorization_response_iss_parameter_supported.nil?
|
|
251
436
|
end
|
|
252
437
|
|
|
253
438
|
# Check if dynamic client registration is supported
|
|
@@ -275,7 +460,8 @@ module MCPClient
|
|
|
275
460
|
response_types_supported: @response_types_supported,
|
|
276
461
|
grant_types_supported: @grant_types_supported,
|
|
277
462
|
code_challenge_methods_supported: @code_challenge_methods_supported,
|
|
278
|
-
client_id_metadata_document_supported: @client_id_metadata_document_supported
|
|
463
|
+
client_id_metadata_document_supported: @client_id_metadata_document_supported,
|
|
464
|
+
authorization_response_iss_parameter_supported: @authorization_response_iss_parameter_supported
|
|
279
465
|
}.compact
|
|
280
466
|
end
|
|
281
467
|
|
|
@@ -293,10 +479,27 @@ module MCPClient
|
|
|
293
479
|
grant_types_supported: data[:grant_types_supported] || data['grant_types_supported'],
|
|
294
480
|
code_challenge_methods_supported: data[:code_challenge_methods_supported] ||
|
|
295
481
|
data['code_challenge_methods_supported'],
|
|
296
|
-
client_id_metadata_document_supported: fetch_boolean(data, :client_id_metadata_document_supported)
|
|
482
|
+
client_id_metadata_document_supported: fetch_boolean(data, :client_id_metadata_document_supported),
|
|
483
|
+
authorization_response_iss_parameter_supported:
|
|
484
|
+
fetch_boolean(data, :authorization_response_iss_parameter_supported)
|
|
297
485
|
)
|
|
298
486
|
end
|
|
299
487
|
|
|
488
|
+
# Read an authorization server's own metadata document. An absent
|
|
489
|
+
# authorization_response_iss_parameter_supported in a FETCHED document
|
|
490
|
+
# is the server's own answer ("no", the RFC 8414 default), so it is
|
|
491
|
+
# recorded as an explicit false; only a record PERSISTED before this
|
|
492
|
+
# client read the field ({.from_h} of a hash without the key) is left
|
|
493
|
+
# without an answer.
|
|
494
|
+
# @param data [Hash] the parsed metadata document
|
|
495
|
+
# @return [ServerMetadata]
|
|
496
|
+
def self.from_discovery_document(data)
|
|
497
|
+
metadata = from_h(data)
|
|
498
|
+
return metadata if metadata.iss_parameter_recorded?
|
|
499
|
+
|
|
500
|
+
from_h(data.merge(authorization_response_iss_parameter_supported: false))
|
|
501
|
+
end
|
|
502
|
+
|
|
300
503
|
# Fetch a possibly-false value from a hash by symbol or string key.
|
|
301
504
|
# Unlike the `||` chains above, this preserves an explicit false.
|
|
302
505
|
# @param data [Hash] Source hash
|
|
@@ -348,26 +551,78 @@ module MCPClient
|
|
|
348
551
|
|
|
349
552
|
# PKCE (Proof Key for Code Exchange) helper
|
|
350
553
|
class PKCE
|
|
351
|
-
attr_reader :code_verifier, :code_challenge, :code_challenge_method
|
|
554
|
+
attr_reader :code_verifier, :code_challenge, :code_challenge_method, :issuer, :iss_parameter_supported,
|
|
555
|
+
:client_id, :redirect_uri, :state, :resource, :scope
|
|
556
|
+
|
|
557
|
+
# Issuer value marking an authorization request that can no longer
|
|
558
|
+
# complete as this resource's: the resource left the authorization
|
|
559
|
+
# server the request was made with (see
|
|
560
|
+
# {OAuthProvider::PendingRequests}). The record stays in the pending
|
|
561
|
+
# slot, so a late callback is refused for that reason rather than
|
|
562
|
+
# mistaken for a stranger's, on every storage backend alike.
|
|
563
|
+
ENDED_ISSUER = 'urn:mcp:ended-request'
|
|
352
564
|
|
|
353
565
|
# Generate PKCE parameters
|
|
354
566
|
# @param code_verifier [String, nil] Existing code verifier (for deserialization)
|
|
355
567
|
# @param code_challenge [String, nil] Existing code challenge (for deserialization)
|
|
356
568
|
# @param code_challenge_method [String] Challenge method (default: 'S256')
|
|
357
|
-
|
|
569
|
+
# @param issuer [String, nil] the selected authorization server's issuer, recorded with this
|
|
570
|
+
# per-request record for RFC 9207 validation of the authorization response (MCP 2026-07-28)
|
|
571
|
+
# @param iss_parameter_supported [Boolean, nil] whether that authorization server advertised
|
|
572
|
+
# authorization_response_iss_parameter_supported, recorded with the request so the response
|
|
573
|
+
# is judged by the server the request went to
|
|
574
|
+
# @param client_id [String, nil] the client id the authorization request was made with, so the
|
|
575
|
+
# code is redeemed with the same credentials
|
|
576
|
+
# @param redirect_uri [String, nil] the redirect URI the authorization request was made with, so
|
|
577
|
+
# the code is redeemed with the same value (RFC 6749 Section 4.1.3)
|
|
578
|
+
# @param state [String, nil] the `state` of the authorization request. MCP 2026-07-28 requires the
|
|
579
|
+
# issuer to be associated with "the same per-request record used to store the PKCE code verifier
|
|
580
|
+
# (and the `state` value, if used)": keeping the state in a slot of its own lets two flows sharing
|
|
581
|
+
# one storage backend interleave their writes until one flow's state names another flow's record,
|
|
582
|
+
# so it is recorded here as well and checked against the callback's state
|
|
583
|
+
# @param resource [String, nil] the resource (the MCP server URL) the authorization request named,
|
|
584
|
+
# so the token the code buys is only ever kept for that resource — a provider retargeted at
|
|
585
|
+
# another resource of the same authorization server while the exchange is in flight must not
|
|
586
|
+
# store it as the other resource's token (MCP 2026-07-28 token audience binding)
|
|
587
|
+
# @param scope [String, nil] the scope the authorization request asked for: a token response
|
|
588
|
+
# that omits `scope` granted exactly that (RFC 6749 Section 5.1), and the step-up union of a
|
|
589
|
+
# rebuilt provider must not lose it
|
|
590
|
+
def initialize(code_verifier: nil, code_challenge: nil, code_challenge_method: nil, issuer: nil,
|
|
591
|
+
iss_parameter_supported: nil, client_id: nil, redirect_uri: nil, state: nil,
|
|
592
|
+
resource: nil, scope: nil)
|
|
358
593
|
@code_verifier = code_verifier || generate_code_verifier
|
|
359
594
|
@code_challenge = code_challenge || generate_code_challenge(@code_verifier)
|
|
360
595
|
@code_challenge_method = code_challenge_method || 'S256'
|
|
596
|
+
@issuer = issuer
|
|
597
|
+
# A record read back from a hash-persisting backend can carry
|
|
598
|
+
# anything here. Not a boolean is not an answer: the record says
|
|
599
|
+
# nothing about the `iss` parameter, so the authorization server's own
|
|
600
|
+
# metadata decides (and that reading fails closed), rather than a
|
|
601
|
+
# mangled value silently meaning "not advertised".
|
|
602
|
+
@iss_parameter_supported = iss_parameter_supported if [true, false].include?(iss_parameter_supported)
|
|
603
|
+
@client_id = client_id
|
|
604
|
+
@redirect_uri = redirect_uri
|
|
605
|
+
@state = state
|
|
606
|
+
@resource = resource
|
|
607
|
+
@scope = scope
|
|
361
608
|
end
|
|
362
609
|
|
|
363
610
|
# Convert to hash for serialization
|
|
364
611
|
# @return [Hash] Hash representation
|
|
365
612
|
def to_h
|
|
366
|
-
{
|
|
613
|
+
hash = {
|
|
367
614
|
code_verifier: @code_verifier,
|
|
368
615
|
code_challenge: @code_challenge,
|
|
369
616
|
code_challenge_method: @code_challenge_method
|
|
370
617
|
}
|
|
618
|
+
hash[:issuer] = @issuer if @issuer
|
|
619
|
+
hash[:iss_parameter_supported] = @iss_parameter_supported unless @iss_parameter_supported.nil?
|
|
620
|
+
hash[:client_id] = @client_id if @client_id
|
|
621
|
+
hash[:redirect_uri] = @redirect_uri if @redirect_uri
|
|
622
|
+
hash[:state] = @state if @state
|
|
623
|
+
hash[:resource] = @resource if @resource
|
|
624
|
+
hash[:scope] = @scope if @scope
|
|
625
|
+
hash
|
|
371
626
|
end
|
|
372
627
|
|
|
373
628
|
# Create PKCE instance from hash
|
|
@@ -381,11 +636,22 @@ module MCPClient
|
|
|
381
636
|
verifier = data[:code_verifier] || data['code_verifier']
|
|
382
637
|
challenge = data[:code_challenge] || data['code_challenge']
|
|
383
638
|
method = data[:code_challenge_method] || data['code_challenge_method']
|
|
639
|
+
issuer = data[:issuer] || data['issuer']
|
|
640
|
+
supported = if data.key?(:iss_parameter_supported)
|
|
641
|
+
data[:iss_parameter_supported]
|
|
642
|
+
else
|
|
643
|
+
data['iss_parameter_supported']
|
|
644
|
+
end
|
|
384
645
|
|
|
385
646
|
raise ArgumentError, 'Missing code_verifier' unless verifier
|
|
386
647
|
raise ArgumentError, 'Missing code_challenge' unless challenge
|
|
387
648
|
|
|
388
|
-
new(code_verifier: verifier, code_challenge: challenge, code_challenge_method: method
|
|
649
|
+
new(code_verifier: verifier, code_challenge: challenge, code_challenge_method: method, issuer: issuer,
|
|
650
|
+
iss_parameter_supported: supported, client_id: data[:client_id] || data['client_id'],
|
|
651
|
+
redirect_uri: data[:redirect_uri] || data['redirect_uri'],
|
|
652
|
+
state: data[:state] || data['state'],
|
|
653
|
+
resource: data[:resource] || data['resource'],
|
|
654
|
+
scope: data[:scope] || data['scope'])
|
|
389
655
|
end
|
|
390
656
|
|
|
391
657
|
private
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# A cached server result with its freshness hint (MCP 2026-07-28
|
|
5
|
+
# server/utilities/caching): `ttlMs` says how long the client MAY consider
|
|
6
|
+
# the result fresh after receipt (0 = immediately stale; absent = no hint,
|
|
7
|
+
# only older servers), and `cacheScope` whether the response may be shared
|
|
8
|
+
# across authorization contexts ("public") or not ("private").
|
|
9
|
+
class CachedResult
|
|
10
|
+
SCOPES = %w[public private].freeze
|
|
11
|
+
|
|
12
|
+
# @return [Object] the cached value (whatever the caller stored)
|
|
13
|
+
attr_accessor :value
|
|
14
|
+
|
|
15
|
+
# The context of a private list whose pages were fetched under different
|
|
16
|
+
# credentials: it belongs to no context and never matches one.
|
|
17
|
+
MIXED_CONTEXT = Object.new.freeze
|
|
18
|
+
# The context of an entry whose request nothing could record: an
|
|
19
|
+
# unknown request is not an anonymous one, so this belongs to no
|
|
20
|
+
# context either and is never served across one.
|
|
21
|
+
UNKNOWN_CONTEXT = Object.new.freeze
|
|
22
|
+
# The params fingerprint of a list whose pages were fetched under
|
|
23
|
+
# differing effective parameters: no request's parameters match it.
|
|
24
|
+
MIXED_PARAMS = Object.new.freeze
|
|
25
|
+
# @return [Float] monotonic receipt time (seconds)
|
|
26
|
+
attr_reader :received_at
|
|
27
|
+
# @return [Integer, Float, nil] the server's ttlMs (nil when it sent none)
|
|
28
|
+
attr_reader :ttl_ms
|
|
29
|
+
# @return [String, nil] "public", "private", or nil when absent/unknown
|
|
30
|
+
attr_reader :cache_scope
|
|
31
|
+
|
|
32
|
+
# The authorization context (the Authorization header) of the request
|
|
33
|
+
# that produced a privately scoped entry; such an entry is served only
|
|
34
|
+
# in that context ("MUST NOT be shared across authorization contexts").
|
|
35
|
+
# @return [String, nil]
|
|
36
|
+
attr_accessor :authorization_context
|
|
37
|
+
|
|
38
|
+
# Identity of the fetch that recorded this entry: the list that fetch
|
|
39
|
+
# converts afterwards attaches to this entry and to no other.
|
|
40
|
+
# @return [Object, nil]
|
|
41
|
+
attr_accessor :fetch_token
|
|
42
|
+
|
|
43
|
+
# Fingerprint of the effective request parameters (host `_meta`) the
|
|
44
|
+
# request that produced this entry went out with; the entry is served
|
|
45
|
+
# only to requests that would carry the same.
|
|
46
|
+
# @return [String, nil]
|
|
47
|
+
attr_accessor :params_fingerprint
|
|
48
|
+
|
|
49
|
+
# Build an entry from a CacheableResult.
|
|
50
|
+
# @param result [Hash, nil] the JSON-RPC result carrying ttlMs/cacheScope
|
|
51
|
+
# @param value [Object] what to cache
|
|
52
|
+
# @param now [Float] monotonic receipt time
|
|
53
|
+
# @return [CachedResult]
|
|
54
|
+
# @param assume_zero [Boolean] treat an absent ttlMs as 0 ("if ttlMs is absent, clients SHOULD
|
|
55
|
+
# assume 0"): the rule for a 2026-07-28 server; an older server keeps the client's own heuristic
|
|
56
|
+
def self.from_result(result, value, now:, assume_zero: false)
|
|
57
|
+
ttl = if result.is_a?(Hash) && result.key?('ttlMs')
|
|
58
|
+
normalize_ttl(result['ttlMs'])
|
|
59
|
+
elsif assume_zero
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
# Cross-context reuse needs an explicit "public": an absent or unknown
|
|
63
|
+
# cacheScope keeps the entry within the authorization context that
|
|
64
|
+
# produced it.
|
|
65
|
+
scope = result.is_a?(Hash) ? result['cacheScope'] : nil
|
|
66
|
+
new(value: value, received_at: now, ttl_ms: ttl, cache_scope: SCOPES.include?(scope) ? scope : 'private')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Combine the hints of several pages of one list into one entry: the
|
|
70
|
+
# shortest TTL wins, and one private page makes the whole list private.
|
|
71
|
+
# @param entries [Array<CachedResult>] per-page entries
|
|
72
|
+
# @param value [Object] the combined value
|
|
73
|
+
# @param now [Float] monotonic receipt time
|
|
74
|
+
# @return [CachedResult]
|
|
75
|
+
def self.combine(entries, value, now:)
|
|
76
|
+
hinted = entries.select(&:hint?)
|
|
77
|
+
# Each page expires at its own received_at + ttlMs; the combined entry
|
|
78
|
+
# (received now) lives until the earliest of those.
|
|
79
|
+
ttl = hinted.map { |e| [e.ttl_ms - ((now - e.received_at) * 1000.0), 0].max }.min
|
|
80
|
+
scope = if entries.any? { |e| e.cache_scope == 'private' }
|
|
81
|
+
'private'
|
|
82
|
+
else
|
|
83
|
+
entries.map(&:cache_scope).compact.first
|
|
84
|
+
end
|
|
85
|
+
combined = new(value: value, received_at: now, ttl_ms: ttl, cache_scope: scope)
|
|
86
|
+
combined.params_fingerprint = entries.first&.params_fingerprint
|
|
87
|
+
combined
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# An entry that is stale from the start: what a change notification
|
|
91
|
+
# leaves behind so the kind reads as "known and stale", not "unknown".
|
|
92
|
+
# @param now [Float] monotonic time
|
|
93
|
+
# @return [CachedResult]
|
|
94
|
+
# @param like [CachedResult, nil] the entry being replaced: its scope and
|
|
95
|
+
# authorization context are kept, so a stale private entry stays private
|
|
96
|
+
def self.stale(now:, like: nil)
|
|
97
|
+
entry = new(value: nil, received_at: now, ttl_ms: 0, cache_scope: like&.cache_scope)
|
|
98
|
+
entry.authorization_context = like&.authorization_context
|
|
99
|
+
entry.params_fingerprint = like&.params_fingerprint
|
|
100
|
+
entry
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# "Servers MUST provide a ttlMs value that is >= 0"; anything else is
|
|
104
|
+
# treated as 0 (immediately stale).
|
|
105
|
+
# @param raw [Object] the ttlMs member
|
|
106
|
+
# @return [Integer, Float]
|
|
107
|
+
def self.normalize_ttl(raw)
|
|
108
|
+
return 0 unless raw.is_a?(Numeric) && raw.finite?
|
|
109
|
+
|
|
110
|
+
[raw, 0].max
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def initialize(value:, received_at:, ttl_ms:, cache_scope:)
|
|
114
|
+
@value = value
|
|
115
|
+
@received_at = received_at
|
|
116
|
+
@ttl_ms = ttl_ms
|
|
117
|
+
# Kept frozen: the scope is compared against the exact sentinels and
|
|
118
|
+
# is handed out through #to_info.
|
|
119
|
+
@cache_scope = cache_scope.is_a?(String) ? -cache_scope : cache_scope
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Boolean] whether the server gave a ttlMs at all
|
|
123
|
+
def hint?
|
|
124
|
+
!@ttl_ms.nil?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Fresh while now < t_received + ttlMs. Without a hint the client keeps
|
|
128
|
+
# its own heuristic (cache until a change notification), so the entry
|
|
129
|
+
# counts as fresh.
|
|
130
|
+
# @param now [Float] monotonic time
|
|
131
|
+
# @return [Boolean]
|
|
132
|
+
def fresh?(now:)
|
|
133
|
+
return true unless hint?
|
|
134
|
+
|
|
135
|
+
now < @received_at + (@ttl_ms / 1000.0)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param now [Float] monotonic time
|
|
139
|
+
# @return [Hash] ttl_ms, cache_scope, received_at, fresh
|
|
140
|
+
def to_info(now:)
|
|
141
|
+
# Detached values: nothing a caller does to them reaches the entry.
|
|
142
|
+
{ ttl_ms: @ttl_ms, cache_scope: @cache_scope&.dup, received_at: @received_at, fresh: fresh?(now: now) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|