mcp 1.6.1 → 1.7.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/lib/mcp/client/http.rb +11 -1
- data/lib/mcp/client/oauth/flow.rb +208 -2
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +66 -6
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +231 -47
- data/lib/mcp/server.rb +8 -3
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87f6233cbd083a14b47183b21558ee995c06953ef5bb8d9ea568eca8e69103de
|
|
4
|
+
data.tar.gz: 11697a1b6e71297211fac5ecf411ac381a064e66274ac9c9a9640087284859ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec9899cc307eb88706aded076b0e7be9bb2bdbf2fbf52c3d8f8f7a7ebe05dbb1da802ebfc8ea9bc1d5ecc18c361a2249488815c60175650203815a084aad9833
|
|
7
|
+
data.tar.gz: c43d95b541108f64ac594567e44f3de9e103f4f52c2c122ef0f0d90dcaa52b391b419c536cf4653c784299324a71c23c9bb7da9a7c4f9f69db87c2ab1d730871
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -902,15 +902,25 @@ module MCP
|
|
|
902
902
|
MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
|
|
903
903
|
end
|
|
904
904
|
|
|
905
|
+
# A provider without a `callback_handler` finishes the authorization in the request that receives the redirect,
|
|
906
|
+
# not here, so there is nothing to retry with yet: the pending authorization surfaces as
|
|
907
|
+
# `Flow::AuthorizationPendingError`, and requests made after `Flow#finish!` pick up the stored tokens.
|
|
905
908
|
def run_full_authorization_flow!(flow:, params:)
|
|
906
909
|
# Use the URL snapshotted at `initialize` time so a post-construction
|
|
907
910
|
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
|
|
908
911
|
# URL to an attacker-controlled host.
|
|
909
|
-
flow.run!(
|
|
912
|
+
result = flow.run!(
|
|
910
913
|
server_url: @oauth_server_url,
|
|
911
914
|
resource_metadata_url: params["resource_metadata"],
|
|
912
915
|
scope: params["scope"],
|
|
913
916
|
)
|
|
917
|
+
return unless result == :redirect
|
|
918
|
+
|
|
919
|
+
raise MCP::Client::OAuth::Flow::AuthorizationPendingError.new(
|
|
920
|
+
"Authorization is pending: the user was sent to the authorization server, and the request can be retried " \
|
|
921
|
+
"once `MCP::Client::OAuth::Flow#finish!` completes the authorization with the redirect's query.",
|
|
922
|
+
authorization_url: flow.authorization_url,
|
|
923
|
+
)
|
|
914
924
|
end
|
|
915
925
|
|
|
916
926
|
# Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
|
|
@@ -83,6 +83,27 @@ module MCP
|
|
|
83
83
|
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
|
|
84
84
|
class DestinationMismatchError < ArgumentError; end
|
|
85
85
|
|
|
86
|
+
# Raised by `MCP::Client::HTTP` when a provider without a `callback_handler` has sent the user to the authorization server:
|
|
87
|
+
# the request cannot be retried until the application finishes the authorization with `finish!` in the request that
|
|
88
|
+
# receives the redirect. `authorization_url` is the URL handed to `redirect_handler`; it is kept out of the message,
|
|
89
|
+
# which may reach logs, because it carries the `state` that `finish!` looks the pending authorization up by.
|
|
90
|
+
# Deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on a failed refresh as a reason to run
|
|
91
|
+
# the interactive flow: this is the interactive flow waiting on the user, not a failure.
|
|
92
|
+
class AuthorizationPendingError < StandardError
|
|
93
|
+
attr_reader :authorization_url
|
|
94
|
+
|
|
95
|
+
def initialize(message = nil, authorization_url: nil)
|
|
96
|
+
super(message)
|
|
97
|
+
@authorization_url = authorization_url
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A `state` the SDK generates is 43 characters; a callback carrying a much longer one is refused before storage is asked.
|
|
102
|
+
CALLBACK_STATE_MAX_LENGTH = 128
|
|
103
|
+
|
|
104
|
+
UNKNOWN_PENDING_AUTHORIZATION_MESSAGE =
|
|
105
|
+
"Authorization callback `state` matches no pending authorization; it is unknown, already used, or expired."
|
|
106
|
+
|
|
86
107
|
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
|
|
87
108
|
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
|
|
88
109
|
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
|
|
@@ -189,13 +210,20 @@ module MCP
|
|
|
189
210
|
end
|
|
190
211
|
end
|
|
191
212
|
|
|
213
|
+
# The authorization URL the last `run!` handed to `redirect_handler` before returning `:redirect`, or `nil`.
|
|
214
|
+
attr_reader :authorization_url
|
|
215
|
+
|
|
192
216
|
def initialize(provider:, http_client_factory: nil)
|
|
193
217
|
@provider = provider
|
|
194
218
|
@http_client_factory = http_client_factory || -> { default_http_client }
|
|
219
|
+
@authorization_url = nil
|
|
195
220
|
end
|
|
196
221
|
|
|
197
222
|
# Runs the full discovery, registration, authorization, and token exchange flow.
|
|
198
223
|
# On success, persists tokens via the provider and returns `:authorized`.
|
|
224
|
+
# A provider without a `callback_handler` stops after the redirect instead: the flow saves
|
|
225
|
+
# a pending authorization in the provider's storage, keyed by `state`, and returns `:redirect`, leaving the code
|
|
226
|
+
# exchange to `finish!` in the request that receives the redirect.
|
|
199
227
|
def run!(server_url:, resource_metadata_url: nil, scope: nil)
|
|
200
228
|
# The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
|
|
201
229
|
# first thing we contact in the OAuth flow, so it has to clear the same
|
|
@@ -257,6 +285,20 @@ module MCP
|
|
|
257
285
|
resource: resource,
|
|
258
286
|
)
|
|
259
287
|
|
|
288
|
+
if @provider.callback_handler.nil?
|
|
289
|
+
save_pending_authorization(
|
|
290
|
+
state: state,
|
|
291
|
+
code_verifier: pkce[:code_verifier],
|
|
292
|
+
server_url: server_url,
|
|
293
|
+
resource: resource,
|
|
294
|
+
client_id: client_info_required_value(client_info, "client_id"),
|
|
295
|
+
as_metadata: as_metadata,
|
|
296
|
+
)
|
|
297
|
+
@authorization_url = authorization_url
|
|
298
|
+
@provider.redirect_handler.call(authorization_url)
|
|
299
|
+
return :redirect
|
|
300
|
+
end
|
|
301
|
+
|
|
260
302
|
@provider.redirect_handler.call(authorization_url)
|
|
261
303
|
callback_result = Array(@provider.callback_handler.call)
|
|
262
304
|
code, returned_state, returned_iss = callback_result
|
|
@@ -449,6 +491,87 @@ module MCP
|
|
|
449
491
|
:refreshed
|
|
450
492
|
end
|
|
451
493
|
|
|
494
|
+
# Finishes an authorization that `run!` left pending, in the request that receives the redirect to `redirect_uri`,
|
|
495
|
+
# which may run in another process. `callback_params` is that redirect's whole query as a Hash (`code`, `state`, and,
|
|
496
|
+
# when present, `iss`, `error`, and `error_description`); passing all of it, rather than picking values out, is
|
|
497
|
+
# the caller's part of the `iss` check: an `iss` the caller drops reads as absent, and the flow cannot tell the difference.
|
|
498
|
+
#
|
|
499
|
+
# The pending authorization is looked up by `state` before any request is made, and it binds the rest of the exchange:
|
|
500
|
+
# the code is redeemed at the token endpoint recorded when the authorization began, with the client registration,
|
|
501
|
+
# `resource`, and `redirect_uri` used then, and without discovery running again, so the code reaches the authorization
|
|
502
|
+
# server the user was sent to (SEP-2352). The RFC 9207 `iss` is validated against the recorded issuer before the pending
|
|
503
|
+
# authorization is consumed, so a callback from another authorization server, as in a mix-up, is refused without
|
|
504
|
+
# consuming the entry the legitimate callback needs, and before the callback's `error` is read, since in a mix-up
|
|
505
|
+
# those parameters are the other server's. The check establishes only that a present `iss` matches the recorded issuer,
|
|
506
|
+
# not who sent the callback, and a callback without `iss` passes it unless the metadata advertises
|
|
507
|
+
# `authorization_response_iss_parameter_supported`: whoever holds the `state`, passes that check, and reaches
|
|
508
|
+
# the consume first takes the entry, and the legitimate callback then finds nothing.
|
|
509
|
+
#
|
|
510
|
+
# Past that check the pending authorization is consumed with `delete_pending_authorization`, which returns the entry
|
|
511
|
+
# it removed; only a callback that gets the entry back redeems the code, so of callbacks racing on the same `state`
|
|
512
|
+
# (a retried redirect, say), at most one does.
|
|
513
|
+
#
|
|
514
|
+
# Binding the callback to the user who started the authorization stays with the application: scoping `storage`
|
|
515
|
+
# to that user means a callback delivered to another user's session finds no pending authorization.
|
|
516
|
+
#
|
|
517
|
+
# On success, persists tokens via the provider and returns `:authorized`.
|
|
518
|
+
def finish!(server_url:, callback_params:)
|
|
519
|
+
unless provider_authorization_flow == :authorization_code && @provider.callback_handler.nil?
|
|
520
|
+
raise ArgumentError,
|
|
521
|
+
"finish! completes an authorization started by a provider without a callback_handler; " \
|
|
522
|
+
"this provider finishes its authorizations in `run!`."
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
state = callback_param(callback_params, "state")
|
|
526
|
+
raise AuthorizationError, "Authorization callback carried no `state`." unless state
|
|
527
|
+
|
|
528
|
+
pending = @provider.pending_authorization(state) if state.bytesize <= CALLBACK_STATE_MAX_LENGTH
|
|
529
|
+
raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(pending)
|
|
530
|
+
|
|
531
|
+
if pending_authorization_expired?(pending)
|
|
532
|
+
@provider.delete_pending_authorization(state)
|
|
533
|
+
raise AuthorizationError, "The pending authorization has expired; start a new authorization."
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
unless safe_canonicalize_url(server_url, label: "MCP server URL") == pending["server_url"]
|
|
537
|
+
raise AuthorizationError, "The pending authorization was started for a different MCP server."
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
as_metadata = pending["authorization_server_metadata"]
|
|
541
|
+
# Checked when the authorization began, and checked again because the metadata has since made a round trip
|
|
542
|
+
# through the application's storage.
|
|
543
|
+
ensure_secure_endpoints!(as_metadata, server_url: pending["server_url"])
|
|
544
|
+
|
|
545
|
+
validate_authorization_response_issuer!(
|
|
546
|
+
as_metadata: as_metadata,
|
|
547
|
+
iss: callback_param(callback_params, "iss"),
|
|
548
|
+
iss_provided: true,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# The read above and this delete are separate calls, so the storage's atomic delete is what decides
|
|
552
|
+
# which of two concurrent callbacks carrying this `state` proceeds.
|
|
553
|
+
consumed = @provider.delete_pending_authorization(state)
|
|
554
|
+
raise AuthorizationError, UNKNOWN_PENDING_AUTHORIZATION_MESSAGE unless valid_pending_authorization?(consumed)
|
|
555
|
+
|
|
556
|
+
error = callback_param(callback_params, "error")
|
|
557
|
+
raise authorization_response_error(error, callback_param(callback_params, "error_description")) if error
|
|
558
|
+
|
|
559
|
+
code = callback_param(callback_params, "code")
|
|
560
|
+
raise AuthorizationError, "Authorization callback carried no authorization code." unless code
|
|
561
|
+
|
|
562
|
+
tokens = exchange_authorization_code(
|
|
563
|
+
as_metadata: as_metadata,
|
|
564
|
+
client_info: pending_client_information(pending, as_metadata: as_metadata),
|
|
565
|
+
code: code,
|
|
566
|
+
code_verifier: pending["code_verifier"],
|
|
567
|
+
resource: pending["resource"],
|
|
568
|
+
redirect_uri: pending["redirect_uri"],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
572
|
+
:authorized
|
|
573
|
+
end
|
|
574
|
+
|
|
452
575
|
private
|
|
453
576
|
|
|
454
577
|
def read_token(key)
|
|
@@ -1096,6 +1219,89 @@ module MCP
|
|
|
1096
1219
|
result.zero?
|
|
1097
1220
|
end
|
|
1098
1221
|
|
|
1222
|
+
def provider_pending_authorization_max_age
|
|
1223
|
+
return Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE unless @provider.respond_to?(:pending_authorization_max_age)
|
|
1224
|
+
|
|
1225
|
+
@provider.pending_authorization_max_age
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
# Records what `finish!` needs to redeem the code exactly as this authorization began: the PKCE verifier, the MCP server,
|
|
1229
|
+
# the `resource` and `redirect_uri` sent, the client identity used, and the authorization server metadata already validated.
|
|
1230
|
+
# The client secret is not copied: `finish!` reads the registration from storage and requires the same `client_id`.
|
|
1231
|
+
# Every value is JSON-compatible, so storage can serialize the entry as-is.
|
|
1232
|
+
def save_pending_authorization(state:, code_verifier:, server_url:, resource:, client_id:, as_metadata:)
|
|
1233
|
+
@provider.save_pending_authorization(
|
|
1234
|
+
state,
|
|
1235
|
+
{
|
|
1236
|
+
"code_verifier" => code_verifier,
|
|
1237
|
+
"server_url" => safe_canonicalize_url(server_url, label: "MCP server URL"),
|
|
1238
|
+
"resource" => resource,
|
|
1239
|
+
"redirect_uri" => @provider.redirect_uri,
|
|
1240
|
+
"client_id" => client_id,
|
|
1241
|
+
"authorization_server_metadata" => as_metadata,
|
|
1242
|
+
"created_at" => Time.now.to_i,
|
|
1243
|
+
},
|
|
1244
|
+
)
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
def valid_pending_authorization?(pending)
|
|
1248
|
+
return false unless pending.is_a?(Hash)
|
|
1249
|
+
return false unless ["code_verifier", "server_url", "redirect_uri", "client_id"].all? { |key| non_empty_string?(pending[key]) }
|
|
1250
|
+
return false unless pending["resource"].nil? || non_empty_string?(pending["resource"])
|
|
1251
|
+
|
|
1252
|
+
pending["authorization_server_metadata"].is_a?(Hash) && pending["created_at"].is_a?(Integer)
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
def pending_authorization_expired?(pending)
|
|
1256
|
+
Time.now.to_i - pending["created_at"] > provider_pending_authorization_max_age
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
# The registration the authorization began with. A stored registration is used only while it still carries
|
|
1260
|
+
# that `client_id` and is bound to the recorded authorization server; a Client ID Metadata Document URL,
|
|
1261
|
+
# which is never stored, is used again while the recorded metadata advertises support for it.
|
|
1262
|
+
def pending_client_information(pending, as_metadata:)
|
|
1263
|
+
client_id = pending["client_id"]
|
|
1264
|
+
|
|
1265
|
+
stored = @provider.client_information
|
|
1266
|
+
if stored.is_a?(Hash) &&
|
|
1267
|
+
client_info_required_value(stored, "client_id") == client_id &&
|
|
1268
|
+
client_info_required_value(stored, "issuer") == as_metadata["issuer"]
|
|
1269
|
+
return stored
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
if client_id == provider_client_id_metadata_document_url && as_metadata["client_id_metadata_document_supported"] == true
|
|
1273
|
+
return { "client_id" => client_id }
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
raise AuthorizationError, "The client registration changed after the authorization began; start a new authorization."
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
# Reads one parameter of an authorization response. Only a non-empty String counts: a repeated parameter that
|
|
1280
|
+
# a framework delivers as an Array, or an empty value, is treated as absent.
|
|
1281
|
+
def callback_param(params, key)
|
|
1282
|
+
return unless params.is_a?(Hash)
|
|
1283
|
+
|
|
1284
|
+
value = params[key] || params[key.to_sym]
|
|
1285
|
+
non_empty_string?(value) ? value : nil
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def non_empty_string?(value)
|
|
1289
|
+
value.is_a?(String) && !value.empty?
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
# An RFC 6749 Section 4.1.2.1 error response, reported with the bounds a token endpoint error gets.
|
|
1293
|
+
# Reached only after the `iss` check, so any `iss` beside the values matched the recorded issuer; that does not
|
|
1294
|
+
# prove who sent them, and they are text the sender chose, so they are cut to a bounded length and confined to
|
|
1295
|
+
# the printable ASCII the RFC permits.
|
|
1296
|
+
def authorization_response_error(error, description)
|
|
1297
|
+
error = bounded_diagnostic(error, limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
|
|
1298
|
+
description = bounded_diagnostic(description, limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
|
|
1299
|
+
message = "The authorization server returned an error to the authorization callback."
|
|
1300
|
+
message += " #{[error, description].compact.join(": ")}" if error || description
|
|
1301
|
+
|
|
1302
|
+
AuthorizationError.new(message, error: error, error_description: description)
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1099
1305
|
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
|
|
1100
1306
|
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
|
|
1101
1307
|
# from the Protected Resource Metadata, and falls back to a provider-supplied
|
|
@@ -1233,11 +1439,11 @@ module MCP
|
|
|
1233
1439
|
uri
|
|
1234
1440
|
end
|
|
1235
1441
|
|
|
1236
|
-
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
|
|
1442
|
+
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:, redirect_uri: @provider.redirect_uri)
|
|
1237
1443
|
form = {
|
|
1238
1444
|
"grant_type" => "authorization_code",
|
|
1239
1445
|
"code" => code,
|
|
1240
|
-
"redirect_uri" =>
|
|
1446
|
+
"redirect_uri" => redirect_uri,
|
|
1241
1447
|
"code_verifier" => code_verifier,
|
|
1242
1448
|
}
|
|
1243
1449
|
form["resource"] = resource if resource
|
|
@@ -16,6 +16,17 @@ module MCP
|
|
|
16
16
|
# binding the credentials to the authorization server that issued them (SEP-2352);
|
|
17
17
|
# custom storages should treat the hash as opaque and persist it as-is.
|
|
18
18
|
#
|
|
19
|
+
# A provider without a `callback_handler` also keeps each pending authorization here, keyed by its `state`,
|
|
20
|
+
# between the request that sends the user to the authorization server and the request that receives
|
|
21
|
+
# the redirect (`save_pending_authorization(state, pending)`, `pending_authorization(state)`,
|
|
22
|
+
# `delete_pending_authorization(state)`). A pending authorization holds the PKCE verifier, so custom storages
|
|
23
|
+
# should treat it as a secret and persist it as-is. `Flow#finish!` touches only the entry its callback's `state`
|
|
24
|
+
# names, so an authorization the user never finished stays until the storage drops it: this class drops entries
|
|
25
|
+
# older than `pending_authorization_max_age` the next time one is saved, and a custom storage should expire them,
|
|
26
|
+
# with a TTL of that age. `delete_pending_authorization` must remove the entry and return it in one atomic step
|
|
27
|
+
# (`Hash#delete` under a mutex here; `GETDEL` in Redis, `DELETE ... RETURNING` in SQL), returning `nil` when
|
|
28
|
+
# there was none: the flow redeems the code only when it gets the entry back.
|
|
29
|
+
#
|
|
19
30
|
# This class keeps everything in process memory, so the credentials live
|
|
20
31
|
# only for the lifetime of the Ruby process. Applications that need
|
|
21
32
|
# persistence across restarts should supply a custom object responding to
|
|
@@ -24,12 +35,25 @@ module MCP
|
|
|
24
35
|
# `Provider.new(storage: ...)`. The shape mirrors Python SDK's
|
|
25
36
|
# `TokenStorage` Protocol; TypeScript's `OAuthClientProvider` rolls
|
|
26
37
|
# the same responsibilities into a single object.
|
|
38
|
+
# A web application may receive the redirect in another process, so pending authorizations
|
|
39
|
+
# need shared storage.
|
|
27
40
|
class InMemoryStorage
|
|
28
41
|
attr_accessor :tokens, :client_information
|
|
29
42
|
|
|
30
|
-
|
|
43
|
+
# @param pending_authorization_max_age [Integer] seconds after which a pending authorization that was
|
|
44
|
+
# never finished is dropped from this storage, at its next save. `Provider.new` passes its own
|
|
45
|
+
# `pending_authorization_max_age` when it builds the default storage, so the two ages agree; a storage built
|
|
46
|
+
# by hand defaults to `Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
|
|
47
|
+
def initialize(pending_authorization_max_age: Provider::DEFAULT_PENDING_AUTHORIZATION_MAX_AGE)
|
|
48
|
+
unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
|
|
49
|
+
raise ArgumentError, "pending_authorization_max_age must be a positive Integer number of seconds (got #{pending_authorization_max_age.inspect})."
|
|
50
|
+
end
|
|
51
|
+
|
|
31
52
|
@tokens = nil
|
|
32
53
|
@client_information = nil
|
|
54
|
+
@pending_authorizations = {}
|
|
55
|
+
@pending_authorization_max_age = pending_authorization_max_age
|
|
56
|
+
@pending_authorizations_mutex = Mutex.new
|
|
33
57
|
end
|
|
34
58
|
|
|
35
59
|
def save_tokens(tokens)
|
|
@@ -39,6 +63,55 @@ module MCP
|
|
|
39
63
|
def save_client_information(info)
|
|
40
64
|
@client_information = info
|
|
41
65
|
end
|
|
66
|
+
|
|
67
|
+
# Drops every pending authorization older than `pending_authorization_max_age` before saving the new one.
|
|
68
|
+
# The flow refuses such an entry when it is looked up, but only a callback carrying its `state` looks it up,
|
|
69
|
+
# and an authorization the user abandoned never gets one; every `401` a provider without a `callback_handler` meets
|
|
70
|
+
# starts another, so without this the entries, each holding the verifier and the authorization server metadata,
|
|
71
|
+
# would accumulate for the life of the process.
|
|
72
|
+
def save_pending_authorization(state, pending)
|
|
73
|
+
@pending_authorizations_mutex.synchronize do
|
|
74
|
+
drop_expired_pending_authorizations
|
|
75
|
+
@pending_authorizations[state] = pending
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pending_authorization(state)
|
|
80
|
+
@pending_authorizations_mutex.synchronize { @pending_authorizations[state] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The mutex is what makes the removal and the return one step on every Ruby implementation: `Hash#delete`
|
|
84
|
+
# alone is atomic only where a global interpreter lock serializes it, and `Flow#finish!` relies on exactly
|
|
85
|
+
# one of two callbacks racing on the same `state` getting the entry back.
|
|
86
|
+
def delete_pending_authorization(state)
|
|
87
|
+
@pending_authorizations_mutex.synchronize { @pending_authorizations.delete(state) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Every hash this storage holds may carry a secret (an access token, a client secret, a PKCE verifier),
|
|
91
|
+
# and `inspect` is what error reporters and consoles print an object with, so it shows only whether each is present.
|
|
92
|
+
def inspect
|
|
93
|
+
pending_count = @pending_authorizations_mutex.synchronize { @pending_authorizations.size }
|
|
94
|
+
|
|
95
|
+
"#<#{self.class.name} tokens=#{present_or_nil(@tokens)} client_information=#{present_or_nil(@client_information)} pending_authorizations=#{pending_count}>"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# An entry's age is read from the Integer `created_at` the flow records when it saves the entry; an entry without one,
|
|
101
|
+
# which the flow never writes, is left alone.
|
|
102
|
+
def drop_expired_pending_authorizations
|
|
103
|
+
now = Time.now.to_i
|
|
104
|
+
|
|
105
|
+
@pending_authorizations.delete_if do |_state, pending|
|
|
106
|
+
created_at = pending.is_a?(Hash) ? pending["created_at"] : nil
|
|
107
|
+
|
|
108
|
+
created_at.is_a?(Integer) && now - created_at > @pending_authorization_max_age
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def present_or_nil(value)
|
|
113
|
+
value.nil? ? "nil" : "[present]"
|
|
114
|
+
end
|
|
42
115
|
end
|
|
43
116
|
end
|
|
44
117
|
end
|
|
@@ -20,6 +20,8 @@ module MCP
|
|
|
20
20
|
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
21
21
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
|
22
22
|
# URL (a `URI`). Implementations typically open the user's browser.
|
|
23
|
+
#
|
|
24
|
+
# Optional keyword arguments:
|
|
23
25
|
# - `callback_handler` - Callable invoked after `redirect_handler`. Returns
|
|
24
26
|
# `[code, state]` or `[code, state, iss]`, where `code` is the authorization code,
|
|
25
27
|
# `state` is the `state` parameter received on the redirect URI, and `iss` is
|
|
@@ -28,8 +30,11 @@ module MCP
|
|
|
28
30
|
# must match the authorization server's issuer, and a nil `iss` is
|
|
29
31
|
# rejected when the AS advertises `authorization_response_iss_parameter_supported`.
|
|
30
32
|
# The 2-element form skips the check for backward compatibility.
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
+
# Omit it when the redirect arrives in a later request, as it does in a web application:
|
|
34
|
+
# the flow then stops after `redirect_handler` with a pending authorization saved in `storage`,
|
|
35
|
+
# and the request that receives the redirect finishes it with `Flow#finish!`.
|
|
36
|
+
# - `pending_authorization_max_age` - Seconds a pending authorization stays redeemable, counted from the moment
|
|
37
|
+
# `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
|
|
33
38
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
34
39
|
# `WWW-Authenticate` does not specify one.
|
|
35
40
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
@@ -38,6 +43,10 @@ module MCP
|
|
|
38
43
|
# an `"issuer"` member binding it to the authorization server that
|
|
39
44
|
# issued it (SEP-2352); when the authorization server changes, the SDK discards
|
|
40
45
|
# the stale registration and tokens and re-registers.
|
|
46
|
+
# Without `callback_handler`, it must also respond to `save_pending_authorization(state, pending)`,
|
|
47
|
+
# `pending_authorization(state)`, and `delete_pending_authorization(state)`; the last must remove the entry
|
|
48
|
+
# and return it atomically, returning `nil` when there was none, and the storage should expire entries
|
|
49
|
+
# older than `pending_authorization_max_age`, as `InMemoryStorage` does.
|
|
41
50
|
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
|
|
42
51
|
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
|
|
43
52
|
# When the authorization server advertises `client_id_metadata_document_supported: true`,
|
|
@@ -78,25 +87,45 @@ module MCP
|
|
|
78
87
|
# applies and the value must unambiguously identify the document.
|
|
79
88
|
class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
|
|
80
89
|
|
|
90
|
+
# Raised when `Provider#initialize` is called without `callback_handler` and with a `storage`
|
|
91
|
+
# that cannot hold a pending authorization between the request that sends the user to the authorization server
|
|
92
|
+
# and the request that receives the redirect.
|
|
93
|
+
class PendingAuthorizationStorageError < ArgumentError; end
|
|
94
|
+
|
|
95
|
+
# Seconds a pending authorization stays redeemable, counted from the moment `run!` saves it, when the provider has
|
|
96
|
+
# no `callback_handler`; long enough for a user to sign in and consent at the authorization server. It is also
|
|
97
|
+
# the age past which `InMemoryStorage` drops an authorization that was never finished, at its next save, so that
|
|
98
|
+
# an abandoned one does not keep its PKCE verifier in memory for the life of the process; a custom storage should
|
|
99
|
+
# expire entries at the same age.
|
|
100
|
+
DEFAULT_PENDING_AUTHORIZATION_MAX_AGE = 600
|
|
101
|
+
|
|
102
|
+
PENDING_AUTHORIZATION_STORAGE_METHODS = [
|
|
103
|
+
:save_pending_authorization,
|
|
104
|
+
:pending_authorization,
|
|
105
|
+
:delete_pending_authorization,
|
|
106
|
+
].freeze
|
|
107
|
+
|
|
81
108
|
attr_reader :client_metadata,
|
|
82
109
|
:redirect_uri,
|
|
83
110
|
:scope,
|
|
84
111
|
:storage,
|
|
85
112
|
:redirect_handler,
|
|
86
113
|
:callback_handler,
|
|
87
|
-
:client_id_metadata_document_url
|
|
114
|
+
:client_id_metadata_document_url,
|
|
115
|
+
:pending_authorization_max_age
|
|
88
116
|
|
|
89
117
|
def initialize(
|
|
90
118
|
client_metadata:,
|
|
91
119
|
redirect_uri:,
|
|
92
120
|
redirect_handler:,
|
|
93
|
-
callback_handler
|
|
121
|
+
callback_handler: nil,
|
|
94
122
|
scope: nil,
|
|
95
123
|
storage: nil,
|
|
96
124
|
client_id_metadata_document_url: nil,
|
|
97
125
|
authorization_request_validator: nil,
|
|
98
126
|
token_request_params: nil,
|
|
99
|
-
http_client_customizer: nil
|
|
127
|
+
http_client_customizer: nil,
|
|
128
|
+
pending_authorization_max_age: DEFAULT_PENDING_AUTHORIZATION_MAX_AGE
|
|
100
129
|
)
|
|
101
130
|
unless Discovery.secure_url?(redirect_uri)
|
|
102
131
|
raise InsecureRedirectURIError,
|
|
@@ -120,16 +149,33 @@ module MCP
|
|
|
120
149
|
|
|
121
150
|
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
122
151
|
|
|
152
|
+
unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
|
|
153
|
+
raise ArgumentError, "pending_authorization_max_age must be a positive Integer number of seconds (got #{pending_authorization_max_age.inspect})."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The default storage drops abandoned pending authorizations at the same age the flow stops redeeming them.
|
|
157
|
+
storage ||= InMemoryStorage.new(pending_authorization_max_age: pending_authorization_max_age)
|
|
158
|
+
|
|
159
|
+
if callback_handler.nil?
|
|
160
|
+
missing = PENDING_AUTHORIZATION_STORAGE_METHODS.reject { |method| storage.respond_to?(method) }
|
|
161
|
+
unless missing.empty?
|
|
162
|
+
raise PendingAuthorizationStorageError,
|
|
163
|
+
"Without a callback_handler the authorization finishes in a later request, so storage must also respond to " \
|
|
164
|
+
"#{missing.join(", ")} (#{storage.class} does not)."
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
123
168
|
@client_metadata = client_metadata
|
|
124
169
|
@redirect_uri = redirect_uri
|
|
125
170
|
@redirect_handler = redirect_handler
|
|
126
171
|
@callback_handler = callback_handler
|
|
127
172
|
@scope = scope
|
|
128
|
-
@storage = storage
|
|
173
|
+
@storage = storage
|
|
129
174
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
130
175
|
@authorization_request_validator = authorization_request_validator
|
|
131
176
|
@token_request_params = frozen_token_request_params(token_request_params)
|
|
132
177
|
@http_client_customizer = http_client_customizer
|
|
178
|
+
@pending_authorization_max_age = pending_authorization_max_age
|
|
133
179
|
end
|
|
134
180
|
|
|
135
181
|
# Identifies the OAuth flow this provider drives.
|
|
@@ -138,6 +184,20 @@ module MCP
|
|
|
138
184
|
def authorization_flow
|
|
139
185
|
:authorization_code
|
|
140
186
|
end
|
|
187
|
+
|
|
188
|
+
def save_pending_authorization(state, pending)
|
|
189
|
+
@storage.save_pending_authorization(state, pending)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def pending_authorization(state)
|
|
193
|
+
@storage.pending_authorization(state)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Returns the entry the storage removed, or `nil`. `Flow#finish!` redeems the code only when it gets
|
|
197
|
+
# the entry back, so of two callbacks racing on the same `state`, only one can.
|
|
198
|
+
def delete_pending_authorization(state)
|
|
199
|
+
@storage.delete_pending_authorization(state)
|
|
200
|
+
end
|
|
141
201
|
end
|
|
142
202
|
end
|
|
143
203
|
end
|
data/lib/mcp/icon.rb
CHANGED
|
@@ -9,9 +9,70 @@ module MCP
|
|
|
9
9
|
# is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
|
|
10
10
|
# is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
|
|
11
11
|
# (the specification expects `WxH` or `"any"`).
|
|
12
|
+
#
|
|
13
|
+
# Wherever an `Icon` is accepted (`Server.new(icons:)` and the `icons` of a tool, prompt, resource, or resource template),
|
|
14
|
+
# a Hash is accepted too and converted through `Icon.from`, so it is checked the same way.
|
|
12
15
|
class Icon
|
|
13
16
|
SUPPORTED_THEMES = ["light", "dark"].freeze
|
|
14
17
|
|
|
18
|
+
# The keys `from` accepts in a Hash: the keyword names of `new` and the wire name `mimeType`,
|
|
19
|
+
# as Symbols or Strings.
|
|
20
|
+
HASH_KEYWORDS = {
|
|
21
|
+
"src" => :src,
|
|
22
|
+
"mimeType" => :mime_type,
|
|
23
|
+
"mime_type" => :mime_type,
|
|
24
|
+
"sizes" => :sizes,
|
|
25
|
+
"theme" => :theme,
|
|
26
|
+
}.freeze
|
|
27
|
+
private_constant :HASH_KEYWORDS
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Returns `value` when it is already an `Icon`, builds one from a Hash, and refuses anything else.
|
|
31
|
+
def from(value)
|
|
32
|
+
return value if value.is_a?(Icon)
|
|
33
|
+
|
|
34
|
+
unless value.is_a?(Hash)
|
|
35
|
+
raise ArgumentError, "An icon must be an MCP::Icon or a Hash (got #{value.class})."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
keywords = {}
|
|
39
|
+
value.each do |key, member|
|
|
40
|
+
unless key.is_a?(Symbol) || key.is_a?(String)
|
|
41
|
+
raise ArgumentError, "An icon Hash key must be a Symbol or a String (got #{key.class})."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
keyword = HASH_KEYWORDS[key.to_s]
|
|
45
|
+
unless keyword
|
|
46
|
+
raise ArgumentError, "An icon Hash may only hold src, mimeType (or mime_type), sizes, and theme (got #{key.inspect})."
|
|
47
|
+
end
|
|
48
|
+
raise ArgumentError, "An icon Hash gives #{keyword} twice." if keywords.key?(keyword)
|
|
49
|
+
|
|
50
|
+
keywords[keyword] = member
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
new(**keywords)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Converts the `icons` argument of a server, tool, prompt, resource, or resource template: `nil` stays `nil`,
|
|
57
|
+
# each element of an Array goes through `from` into a frozen Array, and anything else is refused.
|
|
58
|
+
# The frozen Array keeps a later `<<` on a reader from adding an element these checks never saw.
|
|
59
|
+
def from_list(value)
|
|
60
|
+
return if value.nil?
|
|
61
|
+
|
|
62
|
+
unless value.is_a?(Array)
|
|
63
|
+
raise ArgumentError, "icons must be nil or an Array of MCP::Icon or Hash (got #{value.class})."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
icons = value.each_with_index.map do |icon, index|
|
|
67
|
+
from(icon)
|
|
68
|
+
rescue ArgumentError => e
|
|
69
|
+
raise ArgumentError, "icons[#{index}]: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
icons.freeze
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
15
76
|
attr_reader :mime_type, :sizes, :src, :theme
|
|
16
77
|
|
|
17
78
|
def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
|
data/lib/mcp/prompt.rb
CHANGED
data/lib/mcp/resource.rb
CHANGED
|
@@ -88,7 +88,7 @@ module MCP
|
|
|
88
88
|
if value == NOT_SET
|
|
89
89
|
@icons_value
|
|
90
90
|
else
|
|
91
|
-
@icons_value = value
|
|
91
|
+
@icons_value = Icon.from_list(value)
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -147,7 +147,7 @@ module MCP
|
|
|
147
147
|
@name = name
|
|
148
148
|
@title = title
|
|
149
149
|
@description = description
|
|
150
|
-
@icons = icons
|
|
150
|
+
@icons = Icon.from_list(icons)
|
|
151
151
|
@mime_type = mime_type
|
|
152
152
|
@annotations = annotations
|
|
153
153
|
@size = size
|
|
@@ -89,7 +89,7 @@ module MCP
|
|
|
89
89
|
if value == NOT_SET
|
|
90
90
|
@icons_value
|
|
91
91
|
else
|
|
92
|
-
@icons_value = value
|
|
92
|
+
@icons_value = Icon.from_list(value)
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
@@ -158,7 +158,7 @@ module MCP
|
|
|
158
158
|
@name = name
|
|
159
159
|
@title = title
|
|
160
160
|
@description = description
|
|
161
|
-
@icons = icons
|
|
161
|
+
@icons = Icon.from_list(icons)
|
|
162
162
|
@mime_type = mime_type
|
|
163
163
|
@annotations = annotations
|
|
164
164
|
@meta = meta
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "set"
|
|
4
5
|
require_relative "../../result_type"
|
|
5
6
|
require_relative "../../transport"
|
|
6
7
|
|
|
@@ -48,6 +49,20 @@ module MCP
|
|
|
48
49
|
# pass `max_listen_subscriptions: nil` to opt out.
|
|
49
50
|
DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000
|
|
50
51
|
|
|
52
|
+
# Bound on the bytes of resource URIs one `subscriptions/listen` request may name in `resourceSubscriptions`
|
|
53
|
+
# (the sum of their byte lengths after duplicates are dropped). The URIs are held for the life of the stream,
|
|
54
|
+
# so without a bound each stream could retain a request body's worth of URIs and the retained total would be
|
|
55
|
+
# `max_listen_subscriptions` times `max_request_bytes`. 64 KiB names about a thousand ordinary URIs;
|
|
56
|
+
# pass `max_resource_subscription_bytes: nil` to opt out. The specification sets no limit, nor do the TypeScript
|
|
57
|
+
# and Python SDKs; this is the same kind of local safety limit as `max_request_bytes`.
|
|
58
|
+
DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES = 64 * 1024
|
|
59
|
+
|
|
60
|
+
# Bound on the number of distinct resource URIs one `subscriptions/listen` request may name. A byte bound does
|
|
61
|
+
# not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however
|
|
62
|
+
# short it is, so the count is bounded as well. Like `MAX_JSON_NESTING`, it is a structural limit applied
|
|
63
|
+
# whatever `max_resource_subscription_bytes` is, including `nil`.
|
|
64
|
+
MAX_RESOURCE_SUBSCRIPTION_URIS = 1_024
|
|
65
|
+
|
|
51
66
|
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
|
|
52
67
|
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
|
53
68
|
private_constant :UNSET_IDLE_TIMEOUT
|
|
@@ -67,14 +82,27 @@ module MCP
|
|
|
67
82
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
|
|
68
83
|
DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
|
|
69
84
|
|
|
70
|
-
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# payload); raise `max_request_bytes:` for unusually large payloads. Matches the
|
|
75
|
-
# TypeScript SDK's 4 MB default.
|
|
85
|
+
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole body into memory and parses it,
|
|
86
|
+
# so without a cap a single unauthenticated POST can allocate gigabytes and OOM the worker. 4 MiB comfortably fits
|
|
87
|
+
# a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 payload); raise `max_request_bytes:`
|
|
88
|
+
# for unusually large payloads. Matches the TypeScript SDK's 4 MB default.
|
|
76
89
|
DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
77
90
|
|
|
91
|
+
# Bound on the body of an `initialize` request in stateful mode. Its `clientInfo` and `capabilities` are
|
|
92
|
+
# kept for the life of the session, so without a bound of its own each session could retain a whole
|
|
93
|
+
# `max_request_bytes` body and the retained total would be `max_sessions` times that. An ordinary
|
|
94
|
+
# `initialize` is under 2 KiB; 64 KiB leaves room for large `experimental` capabilities. Pass
|
|
95
|
+
# `max_initialize_request_bytes: nil` to opt out. The TypeScript and Python SDKs keep the initialize
|
|
96
|
+
# data whole with no bound of their own; this is the same kind of local safety limit as `max_request_bytes`.
|
|
97
|
+
DEFAULT_MAX_INITIALIZE_REQUEST_BYTES = 64 * 1024
|
|
98
|
+
|
|
99
|
+
# Bound on the number of JSON values (objects, arrays, keys, and scalars) in the `params` of an `initialize`
|
|
100
|
+
# request in stateful mode. A byte bound alone does not tightly bound the memory a session keeps: a body of
|
|
101
|
+
# many small values parses into far more objects than its size suggests, so the parsed structure is bounded
|
|
102
|
+
# as well. Like `MAX_JSON_NESTING`, it is a structural limit applied whatever `max_initialize_request_bytes`
|
|
103
|
+
# is, including `nil`. An ordinary `initialize` holds a few dozen values.
|
|
104
|
+
MAX_INITIALIZE_PARAMS_VALUES = 1_024
|
|
105
|
+
|
|
78
106
|
# Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
|
|
79
107
|
# the stack or amplify parse cost (complements the byte cap).
|
|
80
108
|
MAX_JSON_NESTING = 64
|
|
@@ -122,9 +150,19 @@ module MCP
|
|
|
122
150
|
# ownership is not enforced.
|
|
123
151
|
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
|
|
124
152
|
# requests are rejected with HTTP 413. Defaults to 4 MiB.
|
|
153
|
+
# @param max_initialize_request_bytes [Integer, nil] upper bound in bytes on the body of an `initialize`
|
|
154
|
+
# request in stateful mode, whose `clientInfo` and `capabilities` the session keeps; a larger one is
|
|
155
|
+
# rejected with HTTP 413 before any session is created. Defaults to `DEFAULT_MAX_INITIALIZE_REQUEST_BYTES`
|
|
156
|
+
# (64 KiB), and `nil` disables this bound, leaving `max_request_bytes` as the only cap on the body; `params`
|
|
157
|
+
# holding more than `MAX_INITIALIZE_PARAMS_VALUES` JSON values are rejected the same way either way.
|
|
158
|
+
# Neither applies in stateless mode, which retains nothing.
|
|
125
159
|
# @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
|
|
126
160
|
# streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
|
|
127
161
|
# the cap.
|
|
162
|
+
# @param max_resource_subscription_bytes [Integer, nil] bound on the total byte length of the distinct
|
|
163
|
+
# resource URIs one `subscriptions/listen` request names in `resourceSubscriptions`; a request over
|
|
164
|
+
# the bound is rejected with HTTP 400 and JSON-RPC `-32602`.
|
|
165
|
+
# Defaults to `DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES` (64 KiB), and `nil` disables the bound.
|
|
128
166
|
# @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
|
|
129
167
|
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
|
|
130
168
|
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
|
|
@@ -149,7 +187,9 @@ module MCP
|
|
|
149
187
|
dns_rebinding_protection: true,
|
|
150
188
|
session_request_validator: nil,
|
|
151
189
|
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
190
|
+
max_initialize_request_bytes: DEFAULT_MAX_INITIALIZE_REQUEST_BYTES,
|
|
152
191
|
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
|
192
|
+
max_resource_subscription_bytes: DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES,
|
|
153
193
|
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
154
194
|
serve_subscriptions_listen: true,
|
|
155
195
|
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
|
|
@@ -170,8 +210,9 @@ module MCP
|
|
|
170
210
|
@pending_responses = {}
|
|
171
211
|
|
|
172
212
|
# Maps a key the transport mints for each `subscriptions/listen` stream to
|
|
173
|
-
# `{ request_id: listen_request_id, stream: stream_object, filter:
|
|
174
|
-
#
|
|
213
|
+
# `{ request_id: listen_request_id, stream: stream_object, filter: honored_list_changed_flags,
|
|
214
|
+
# resource_uris: Set_of_honored_resource_uris, active: boolean, write_mutex: Mutex,
|
|
215
|
+
# keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
|
|
175
216
|
# unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
|
|
176
217
|
# two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
|
|
177
218
|
# so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
|
|
@@ -212,12 +253,25 @@ module MCP
|
|
|
212
253
|
|
|
213
254
|
@max_request_bytes = max_request_bytes
|
|
214
255
|
|
|
256
|
+
if !max_initialize_request_bytes.nil? && !(max_initialize_request_bytes.is_a?(Integer) && max_initialize_request_bytes > 0)
|
|
257
|
+
raise ArgumentError, "max_initialize_request_bytes must be a positive Integer or nil"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The bound guards what stateful sessions retain; stateless mode keeps none.
|
|
261
|
+
@max_initialize_request_bytes = stateless ? nil : max_initialize_request_bytes
|
|
262
|
+
|
|
215
263
|
if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
|
|
216
264
|
raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
|
|
217
265
|
end
|
|
218
266
|
|
|
219
267
|
@max_listen_subscriptions = max_listen_subscriptions
|
|
220
268
|
|
|
269
|
+
if !max_resource_subscription_bytes.nil? && !(max_resource_subscription_bytes.is_a?(Integer) && max_resource_subscription_bytes > 0)
|
|
270
|
+
raise ArgumentError, "max_resource_subscription_bytes must be a positive Integer or nil"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
@max_resource_subscription_bytes = max_resource_subscription_bytes
|
|
274
|
+
|
|
221
275
|
if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
|
|
222
276
|
raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
|
|
223
277
|
end
|
|
@@ -850,6 +904,14 @@ module MCP
|
|
|
850
904
|
return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
|
|
851
905
|
end
|
|
852
906
|
|
|
907
|
+
# The id is echoed in every message of the stream, so one that cannot be written back as JSON
|
|
908
|
+
# (a String holding bytes that are not valid UTF-8 parses, but does not generate) is refused here,
|
|
909
|
+
# while the refusal can still answer with a null id, rather than surfacing as a failed write after
|
|
910
|
+
# the stream has been registered.
|
|
911
|
+
unless json_encodable?(request_id)
|
|
912
|
+
return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
|
|
913
|
+
end
|
|
914
|
+
|
|
853
915
|
begin
|
|
854
916
|
if RequestEnvelope.modern?(params)
|
|
855
917
|
RequestEnvelope.parse!(params, request: params)
|
|
@@ -876,6 +938,15 @@ module MCP
|
|
|
876
938
|
)
|
|
877
939
|
end
|
|
878
940
|
|
|
941
|
+
if (problem = resource_subscriptions_problem(filter))
|
|
942
|
+
return json_rpc_error_response(
|
|
943
|
+
status: 400,
|
|
944
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
945
|
+
message: "Invalid params: subscriptions/listen #{problem}",
|
|
946
|
+
id: request_id,
|
|
947
|
+
)
|
|
948
|
+
end
|
|
949
|
+
|
|
879
950
|
# Best-effort cap check before committing to the SSE response; the registration inside
|
|
880
951
|
# `listen_sse_body` re-checks atomically for the race between two concurrent listens
|
|
881
952
|
# crossing the cap together.
|
|
@@ -883,7 +954,43 @@ module MCP
|
|
|
883
954
|
return too_many_listen_subscriptions_response(request_id)
|
|
884
955
|
end
|
|
885
956
|
|
|
886
|
-
|
|
957
|
+
honored = honored_filter(filter)
|
|
958
|
+
|
|
959
|
+
# The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
|
|
960
|
+
# so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
|
|
961
|
+
# refused with a 400 instead of failing the first write after the stream was registered.
|
|
962
|
+
unless (acknowledgement = listen_acknowledgement_json(request_id, honored))
|
|
963
|
+
return json_rpc_error_response(
|
|
964
|
+
status: 400,
|
|
965
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
966
|
+
message: "Invalid params: subscriptions/listen `notifications` must be valid UTF-8",
|
|
967
|
+
id: request_id,
|
|
968
|
+
)
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
[200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement)]
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
|
|
975
|
+
# the request id or the honored filter holds a string JSON cannot encode.
|
|
976
|
+
def listen_acknowledgement_json(request_id, honored)
|
|
977
|
+
{
|
|
978
|
+
jsonrpc: "2.0",
|
|
979
|
+
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
980
|
+
params: {
|
|
981
|
+
notifications: honored,
|
|
982
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
983
|
+
},
|
|
984
|
+
}.to_json
|
|
985
|
+
rescue JSON::GeneratorError
|
|
986
|
+
nil
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
def json_encodable?(value)
|
|
990
|
+
value.to_json
|
|
991
|
+
true
|
|
992
|
+
rescue JSON::GeneratorError
|
|
993
|
+
false
|
|
887
994
|
end
|
|
888
995
|
|
|
889
996
|
def listen_subscriptions_full?
|
|
@@ -942,14 +1049,22 @@ module MCP
|
|
|
942
1049
|
# The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
|
|
943
1050
|
# the requesting client's own in-flight requests, and two clients that pick the same one must each get
|
|
944
1051
|
# their stream, stamped with the id they sent.
|
|
945
|
-
def listen_sse_body(request_id, honored)
|
|
1052
|
+
def listen_sse_body(request_id, honored, acknowledgement)
|
|
946
1053
|
ListenStreamBody.new do |stream|
|
|
947
1054
|
subscription_key = SecureRandom.uuid
|
|
948
1055
|
subscription = nil
|
|
1056
|
+
# The entry keeps the list-changed flags of the honored filter and the URIs as a frozen Set, both built ahead of the registry lock:
|
|
1057
|
+
# delivery looks each notification's URI up in constant time, so neither the matching cost nor the lock hold grows with
|
|
1058
|
+
# the number of URIs a stream named. The entry does not keep the Array the acknowledgement echoes; that one lives only as long as
|
|
1059
|
+
# the host keeps this response body.
|
|
1060
|
+
entry_filter = honored.reject { |name, _value| name == :resourceSubscriptions }
|
|
1061
|
+
resource_uris = Set.new(honored[:resourceSubscriptions] || []).freeze
|
|
1062
|
+
|
|
949
1063
|
@mutex.synchronize do
|
|
950
1064
|
unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
|
|
951
1065
|
subscription = {
|
|
952
|
-
request_id: request_id, stream: stream, filter:
|
|
1066
|
+
request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
|
|
1067
|
+
write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
|
|
953
1068
|
}
|
|
954
1069
|
@listen_subscriptions[subscription_key] = subscription
|
|
955
1070
|
end
|
|
@@ -958,15 +1073,6 @@ module MCP
|
|
|
958
1073
|
if subscription.nil?
|
|
959
1074
|
close_stream_safely(stream)
|
|
960
1075
|
else
|
|
961
|
-
acknowledgement = {
|
|
962
|
-
jsonrpc: "2.0",
|
|
963
|
-
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
964
|
-
params: {
|
|
965
|
-
notifications: honored,
|
|
966
|
-
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
967
|
-
},
|
|
968
|
-
}
|
|
969
|
-
|
|
970
1076
|
begin
|
|
971
1077
|
acknowledged = subscription[:write_mutex].synchronize do
|
|
972
1078
|
next false if subscription[:closed]
|
|
@@ -987,11 +1093,11 @@ module MCP
|
|
|
987
1093
|
if acknowledged
|
|
988
1094
|
start_listen_keepalive_thread(subscription_key, request_id)
|
|
989
1095
|
else
|
|
990
|
-
|
|
1096
|
+
close_removed_listen_stream(subscription)
|
|
991
1097
|
end
|
|
992
1098
|
rescue *STREAM_WRITE_ERRORS
|
|
993
1099
|
remove_listen_subscription(subscription_key)
|
|
994
|
-
|
|
1100
|
+
close_removed_listen_stream(subscription)
|
|
995
1101
|
end
|
|
996
1102
|
end
|
|
997
1103
|
end
|
|
@@ -1032,11 +1138,9 @@ module MCP
|
|
|
1032
1138
|
# removed the entry already, and the report should still name the stream.
|
|
1033
1139
|
MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
|
|
1034
1140
|
ensure
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
end
|
|
1039
|
-
close_stream_safely(stream) if stream
|
|
1141
|
+
# Through the helper, so the entry is marked closed here as it is for every other removal.
|
|
1142
|
+
subscription = remove_listen_subscription(subscription_key)
|
|
1143
|
+
close_removed_listen_stream(subscription) if subscription
|
|
1040
1144
|
end
|
|
1041
1145
|
end
|
|
1042
1146
|
|
|
@@ -1068,12 +1172,36 @@ module MCP
|
|
|
1068
1172
|
|
|
1069
1173
|
subscriptions = filter[:resourceSubscriptions]
|
|
1070
1174
|
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
|
|
1071
|
-
honored[:resourceSubscriptions] = subscriptions
|
|
1175
|
+
honored[:resourceSubscriptions] = subscriptions.uniq
|
|
1072
1176
|
end
|
|
1073
1177
|
|
|
1074
1178
|
honored
|
|
1075
1179
|
end
|
|
1076
1180
|
|
|
1181
|
+
# Checks a listen filter's `resourceSubscriptions` against the schema, which types it as an optional array of strings,
|
|
1182
|
+
# so a member that is present but `null` is refused like any other non-array; against `MAX_RESOURCE_SUBSCRIPTION_URIS`;
|
|
1183
|
+
# and against `max_resource_subscription_bytes`, returning the problem to report or `nil`. Duplicates are dropped before
|
|
1184
|
+
# the bounds are measured, since they are dropped before storage too. The checks run whether or not the server declares
|
|
1185
|
+
# the `subscribe` capability: a request that violates the schema is invalid regardless of what would be honored,
|
|
1186
|
+
# and the bounds are limits on the request itself, like `max_request_bytes`, not on what the server would retain from it.
|
|
1187
|
+
def resource_subscriptions_problem(filter)
|
|
1188
|
+
return unless filter.key?(:resourceSubscriptions)
|
|
1189
|
+
|
|
1190
|
+
subscriptions = filter[:resourceSubscriptions]
|
|
1191
|
+
unless subscriptions.is_a?(Array) && subscriptions.all? { |uri| uri.is_a?(String) }
|
|
1192
|
+
return "`resourceSubscriptions` must be an array of strings"
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
distinct = subscriptions.uniq
|
|
1196
|
+
if distinct.size > MAX_RESOURCE_SUBSCRIPTION_URIS
|
|
1197
|
+
return "`resourceSubscriptions` exceeds #{MAX_RESOURCE_SUBSCRIPTION_URIS} URIs"
|
|
1198
|
+
end
|
|
1199
|
+
return unless @max_resource_subscription_bytes
|
|
1200
|
+
return if distinct.sum(&:bytesize) <= @max_resource_subscription_bytes
|
|
1201
|
+
|
|
1202
|
+
"`resourceSubscriptions` exceeds #{@max_resource_subscription_bytes} bytes"
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1077
1205
|
# Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
|
|
1078
1206
|
# in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
|
|
1079
1207
|
# `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
|
|
@@ -1091,24 +1219,16 @@ module MCP
|
|
|
1091
1219
|
field = LISTEN_FILTER_FIELDS[method]
|
|
1092
1220
|
return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
|
|
1093
1221
|
|
|
1094
|
-
#
|
|
1095
|
-
#
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
next unless subscription[:active]
|
|
1101
|
-
|
|
1102
|
-
hit = if field
|
|
1103
|
-
subscription[:filter][field]
|
|
1104
|
-
else
|
|
1105
|
-
uris = subscription[:filter][:resourceSubscriptions]
|
|
1106
|
-
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1107
|
-
uris.is_a?(Array) && uris.include?(uri)
|
|
1108
|
-
end
|
|
1222
|
+
# Only the snapshot of active entries is taken under `@mutex`, one pass over the entries as before;
|
|
1223
|
+
# the matching and the stream writes happen outside it, so neither the number of URIs the streams named
|
|
1224
|
+
# nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
|
|
1225
|
+
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1226
|
+
# delivering to it would put a notification ahead of the acknowledgement.
|
|
1227
|
+
candidates = @mutex.synchronize { @listen_subscriptions.select { |_key, subscription| subscription[:active] } }
|
|
1109
1228
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1229
|
+
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1230
|
+
matched = candidates.select do |_key, subscription|
|
|
1231
|
+
field ? subscription[:filter][field] : subscription[:resource_uris].include?(uri)
|
|
1112
1232
|
end
|
|
1113
1233
|
|
|
1114
1234
|
matched.each do |subscription_key, subscription|
|
|
@@ -1132,7 +1252,7 @@ module MCP
|
|
|
1132
1252
|
{ subscription_id: subscription[:request_id], error: "Failed to send notification" },
|
|
1133
1253
|
)
|
|
1134
1254
|
remove_listen_subscription(subscription_key)
|
|
1135
|
-
|
|
1255
|
+
close_removed_listen_stream(subscription)
|
|
1136
1256
|
end
|
|
1137
1257
|
end
|
|
1138
1258
|
end
|
|
@@ -1140,12 +1260,27 @@ module MCP
|
|
|
1140
1260
|
def remove_listen_subscription(subscription_key)
|
|
1141
1261
|
@mutex.synchronize do
|
|
1142
1262
|
subscription = @listen_subscriptions.delete(subscription_key)
|
|
1143
|
-
|
|
1263
|
+
if subscription
|
|
1264
|
+
# Marked closed as teardown does, so a delivery or keepalive that took the entry before the removal skips it under
|
|
1265
|
+
# the write mutex instead of writing to a stream being closed. The flag is set without taking the write mutex,
|
|
1266
|
+
# which must never be taken inside `@mutex`: a write that checks the flag from here on skips, and one already
|
|
1267
|
+
# past its check lands before the stream closes, since `close_removed_listen_stream` waits for the write mutex.
|
|
1268
|
+
subscription[:closed] = true
|
|
1269
|
+
subscription[:keepalive_wakeup].signal
|
|
1270
|
+
end
|
|
1144
1271
|
|
|
1145
1272
|
subscription
|
|
1146
1273
|
end
|
|
1147
1274
|
end
|
|
1148
1275
|
|
|
1276
|
+
# Closes a subscription's stream while excluding concurrent writes. The write mutex, taken here outside
|
|
1277
|
+
# `@mutex`, makes the close wait for a write already past its `closed` check, so that write completes or fails
|
|
1278
|
+
# before the stream is closed, and a write that takes the mutex afterwards sees `closed` and skips. Teardown
|
|
1279
|
+
# gets the same ordering by setting `closed` under the write mutex itself.
|
|
1280
|
+
def close_removed_listen_stream(subscription)
|
|
1281
|
+
subscription[:write_mutex].synchronize { close_stream_safely(subscription[:stream]) }
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1149
1284
|
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
|
|
1150
1285
|
# before the stream closes.
|
|
1151
1286
|
def teardown_listen_subscriptions
|
|
@@ -1296,6 +1431,16 @@ module MCP
|
|
|
1296
1431
|
end
|
|
1297
1432
|
|
|
1298
1433
|
if initialize_request?(body)
|
|
1434
|
+
# Checked before a session exists: the body's `clientInfo` and `capabilities` are what the session would keep,
|
|
1435
|
+
# so an oversized `initialize` is refused outright instead of being stored and capped later.
|
|
1436
|
+
if @max_initialize_request_bytes && body_string.bytesize > @max_initialize_request_bytes
|
|
1437
|
+
return initialize_too_large_response(body[:id], "body exceeds #{@max_initialize_request_bytes} bytes")
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1440
|
+
if !@stateless && json_values_exceed?(body[:params], MAX_INITIALIZE_PARAMS_VALUES)
|
|
1441
|
+
return initialize_too_large_response(body[:id], "params exceed #{MAX_INITIALIZE_PARAMS_VALUES} JSON values")
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1299
1444
|
if !@stateless && session_id
|
|
1300
1445
|
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
|
|
1301
1446
|
# initialization attempt against a live session, or a retry against an unknown/expired
|
|
@@ -1524,6 +1669,45 @@ module MCP
|
|
|
1524
1669
|
)
|
|
1525
1670
|
end
|
|
1526
1671
|
|
|
1672
|
+
def initialize_too_large_response(request_id, reason)
|
|
1673
|
+
json_rpc_error_response(
|
|
1674
|
+
status: 413,
|
|
1675
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1676
|
+
message: "Payload too large: initialize request #{reason}",
|
|
1677
|
+
id: request_id,
|
|
1678
|
+
)
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
# Counts the JSON values in `value` (every object, array, object key, and scalar) and reports whether
|
|
1682
|
+
# they exceed `limit`. The walk is iterative. Every value still queued is at least one more value,
|
|
1683
|
+
# so `count + pending.size` never overstates the total, and a container is refused before its members are
|
|
1684
|
+
# queued if they would take that sum past the limit; the walk therefore stops as soon as the limit is
|
|
1685
|
+
# certain to be passed and never queues more than `limit` values, whatever the shape of the input.
|
|
1686
|
+
def json_values_exceed?(value, limit)
|
|
1687
|
+
count = 0
|
|
1688
|
+
pending = [value]
|
|
1689
|
+
|
|
1690
|
+
until pending.empty?
|
|
1691
|
+
current = pending.pop
|
|
1692
|
+
count += 1
|
|
1693
|
+
|
|
1694
|
+
case current
|
|
1695
|
+
when Hash
|
|
1696
|
+
count += current.size
|
|
1697
|
+
return true if count + pending.size + current.size > limit
|
|
1698
|
+
|
|
1699
|
+
pending.concat(current.values)
|
|
1700
|
+
when Array
|
|
1701
|
+
return true if count + pending.size + current.size > limit
|
|
1702
|
+
|
|
1703
|
+
pending.concat(current)
|
|
1704
|
+
end
|
|
1705
|
+
return true if count + pending.size > limit
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1708
|
+
false
|
|
1709
|
+
end
|
|
1710
|
+
|
|
1527
1711
|
def parse_request_body(body_string)
|
|
1528
1712
|
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
|
|
1529
1713
|
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
|
data/lib/mcp/server.rb
CHANGED
|
@@ -181,8 +181,13 @@ module MCP
|
|
|
181
181
|
Methods::RESOURCES_READ,
|
|
182
182
|
].freeze
|
|
183
183
|
|
|
184
|
-
attr_accessor :description, :
|
|
185
|
-
attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
|
|
184
|
+
attr_accessor :description, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
185
|
+
attr_reader :icons, :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
|
|
186
|
+
|
|
187
|
+
# Replaces the icons advertised in `serverInfo`; a Hash is converted through `Icon.from`.
|
|
188
|
+
def icons=(value)
|
|
189
|
+
@icons = Icon.from_list(value)
|
|
190
|
+
end
|
|
186
191
|
|
|
187
192
|
def initialize(
|
|
188
193
|
description: nil,
|
|
@@ -207,7 +212,7 @@ module MCP
|
|
|
207
212
|
transport: nil
|
|
208
213
|
)
|
|
209
214
|
@description = description
|
|
210
|
-
@icons = icons
|
|
215
|
+
@icons = Icon.from_list(icons)
|
|
211
216
|
@name = name
|
|
212
217
|
@title = title
|
|
213
218
|
@version = version
|
data/lib/mcp/tool.rb
CHANGED
data/lib/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -108,7 +108,7 @@ licenses:
|
|
|
108
108
|
- Apache-2.0
|
|
109
109
|
metadata:
|
|
110
110
|
allowed_push_host: https://rubygems.org
|
|
111
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.
|
|
111
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.7.0
|
|
112
112
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
113
113
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
114
114
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|