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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08c2de444c55244db435a10ce7b9f9026d24158d47cadd6f89b917f42e09b534'
4
- data.tar.gz: 6126a02c9adf92df07f22b323a0337f2abae9eb23c7bf014f1b7ef1ccbbf3a72
3
+ metadata.gz: 87f6233cbd083a14b47183b21558ee995c06953ef5bb8d9ea568eca8e69103de
4
+ data.tar.gz: 11697a1b6e71297211fac5ecf411ac381a064e66274ac9c9a9640087284859ad
5
5
  SHA512:
6
- metadata.gz: 7318acb46d46e9c958115f95171f21b20c186ab32608770d443440e0202d29319b9384b11099ed69f136c3ba9146be39998e1a9a47a839fe506e07cddca0ad89
7
- data.tar.gz: 3008f7f6061e0a2cd514d377ade38a03be1ed88d863fb268e50c9820ece43f585b7fcc801bc6c0ffcf78fab3a80af3397c20e497722a6821c55d6128d391d4fc
6
+ metadata.gz: ec9899cc307eb88706aded076b0e7be9bb2bdbf2fbf52c3d8f8f7a7ebe05dbb1da802ebfc8ea9bc1d5ecc18c361a2249488815c60175650203815a084aad9833
7
+ data.tar.gz: c43d95b541108f64ac594567e44f3de9e103f4f52c2c122ef0f0d90dcaa52b391b419c536cf4653c784299324a71c23c9bb7da9a7c4f9f69db87c2ab1d730871
@@ -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" => @provider.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
- def initialize
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
- # Optional keyword arguments:
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 || InMemoryStorage.new
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
@@ -72,7 +72,7 @@ module MCP
72
72
  if value == NOT_SET
73
73
  @icons_value
74
74
  else
75
- @icons_value = value
75
+ @icons_value = Icon.from_list(value)
76
76
  end
77
77
  end
78
78
 
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
- # body into memory and parses it, so without a cap a single unauthenticated POST
72
- # can allocate gigabytes and OOM the worker. 4 MiB comfortably
73
- # fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64
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: honored_subscription_filter, active: boolean,
174
- # write_mutex: Mutex, keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
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
- [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))]
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: honored, active: false, write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new
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
- close_stream_safely(stream)
1096
+ close_removed_listen_stream(subscription)
991
1097
  end
992
1098
  rescue *STREAM_WRITE_ERRORS
993
1099
  remove_listen_subscription(subscription_key)
994
- close_stream_safely(stream)
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
- stream = @mutex.synchronize do
1036
- subscription = @listen_subscriptions.delete(subscription_key)
1037
- subscription && subscription[:stream]
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
- # The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
1095
- # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1096
- matched = @mutex.synchronize do
1097
- @listen_subscriptions.filter_map do |subscription_key, subscription|
1098
- # An inactive entry has not finished writing its acknowledgement yet;
1099
- # delivering to it would put a notification ahead of the acknowledgement.
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
- [subscription_key, subscription] if hit
1111
- end
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
- close_stream_safely(subscription[:stream])
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
- subscription[:keepalive_wakeup].signal if subscription
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, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
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
@@ -86,7 +86,7 @@ module MCP
86
86
  if value == NOT_SET
87
87
  @icons_value
88
88
  else
89
- @icons_value = value
89
+ @icons_value = Icon.from_list(value)
90
90
  end
91
91
  end
92
92
 
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.6.1"
4
+ VERSION = "1.7.0"
5
5
  end
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.6.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.6.1
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