mcp 1.6.0 → 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.
@@ -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
@@ -169,8 +209,13 @@ module MCP
169
209
  @allowed_origins = Array(allowed_origins).map(&:downcase).freeze
170
210
  @pending_responses = {}
171
211
 
172
- # Maps a `subscriptions/listen` request id to
173
- # `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575).
212
+ # Maps a key the transport mints for each `subscriptions/listen` stream to
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,
216
+ # unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
217
+ # two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
218
+ # so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
174
219
  # In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
175
220
  # which is a follow-up.
176
221
  @listen_subscriptions = {}
@@ -208,12 +253,25 @@ module MCP
208
253
 
209
254
  @max_request_bytes = max_request_bytes
210
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
+
211
263
  if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
212
264
  raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
213
265
  end
214
266
 
215
267
  @max_listen_subscriptions = max_listen_subscriptions
216
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
+
217
275
  if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
218
276
  raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
219
277
  end
@@ -846,6 +904,14 @@ module MCP
846
904
  return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
847
905
  end
848
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
+
849
915
  begin
850
916
  if RequestEnvelope.modern?(params)
851
917
  RequestEnvelope.parse!(params, request: params)
@@ -872,6 +938,15 @@ module MCP
872
938
  )
873
939
  end
874
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
+
875
950
  # Best-effort cap check before committing to the SSE response; the registration inside
876
951
  # `listen_sse_body` re-checks atomically for the race between two concurrent listens
877
952
  # crossing the cap together.
@@ -879,7 +954,43 @@ module MCP
879
954
  return too_many_listen_subscriptions_response(request_id)
880
955
  end
881
956
 
882
- [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
883
994
  end
884
995
 
885
996
  def listen_subscriptions_full?
@@ -925,95 +1036,128 @@ module MCP
925
1036
  # the legacy GET stream (`create_sse_body`).
926
1037
  #
927
1038
  # Registration and activation are split on purpose: the entry is inserted inactive
928
- # (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
1039
+ # (reserving the cap slot atomically), the acknowledgement is written outside the registry lock,
929
1040
  # and only then does the entry become eligible for delivery. A concurrent notification between
930
1041
  # the insert and the acknowledgement write skips the inactive entry,
931
1042
  # enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
932
- def listen_sse_body(request_id, honored)
1043
+ #
1044
+ # The acknowledgement write holds the stream's write mutex, which `teardown_listen_subscriptions` also takes
1045
+ # before it marks an entry closed. The two therefore cannot interleave: the acknowledgement either lands
1046
+ # before the result, or, if the transport closed first, is not written at all and the stream just closes,
1047
+ # so no stream ever carries a result ahead of its acknowledgement.
1048
+ #
1049
+ # The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
1050
+ # the requesting client's own in-flight requests, and two clients that pick the same one must each get
1051
+ # their stream, stamped with the id they sent.
1052
+ def listen_sse_body(request_id, honored, acknowledgement)
933
1053
  ListenStreamBody.new do |stream|
934
- rejected = false
1054
+ subscription_key = SecureRandom.uuid
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
+
935
1063
  @mutex.synchronize do
936
- if @listen_subscriptions.key?(request_id) ||
937
- (@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
938
- rejected = true
939
- else
940
- @listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new }
1064
+ unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
1065
+ subscription = {
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,
1068
+ }
1069
+ @listen_subscriptions[subscription_key] = subscription
941
1070
  end
942
1071
  end
943
1072
 
944
- if rejected
1073
+ if subscription.nil?
945
1074
  close_stream_safely(stream)
946
1075
  else
947
- acknowledgement = {
948
- jsonrpc: "2.0",
949
- method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
950
- params: {
951
- notifications: honored,
952
- _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
953
- },
954
- }
955
-
956
1076
  begin
957
- send_to_stream(stream, acknowledgement)
958
- activate_listen_subscription(request_id)
959
- start_listen_keepalive_thread(request_id)
1077
+ acknowledged = subscription[:write_mutex].synchronize do
1078
+ next false if subscription[:closed]
1079
+
1080
+ send_to_stream(stream, acknowledgement)
1081
+
1082
+ # Set on the entry itself, not through the registry: a concurrent close may already have cleared the registry
1083
+ # while its result write waits on this mutex, and that write must still find the stream acknowledged.
1084
+ # Set under the registry lock as well, since that is the lock the delivery snapshot reads the flag under.
1085
+ # This is the one place a write mutex is held while the registry lock is taken; it stays deadlock-free only
1086
+ # as long as no path takes a write mutex inside `@mutex.synchronize`, so resolve entries under `@mutex`,
1087
+ # release it, then write.
1088
+ @mutex.synchronize { subscription[:active] = true }
1089
+
1090
+ true
1091
+ end
1092
+
1093
+ if acknowledged
1094
+ start_listen_keepalive_thread(subscription_key, request_id)
1095
+ else
1096
+ close_removed_listen_stream(subscription)
1097
+ end
960
1098
  rescue *STREAM_WRITE_ERRORS
961
- remove_listen_subscription(request_id)
962
- close_stream_safely(stream)
1099
+ remove_listen_subscription(subscription_key)
1100
+ close_removed_listen_stream(subscription)
963
1101
  end
964
1102
  end
965
1103
  end
966
1104
  end
967
1105
 
968
- # Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
969
- # The entry may already be gone when the transport closed concurrently.
970
- def activate_listen_subscription(request_id)
971
- @mutex.synchronize do
972
- subscription = @listen_subscriptions[request_id]
973
- subscription[:active] = true if subscription
974
- end
975
- end
976
-
977
1106
  # Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
978
1107
  # connection is detected and its slot freed, rather than held until the next fan-out write.
979
1108
  # Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
980
1109
  # cannot corrupt an interleaved notification's JSON.
981
- def start_listen_keepalive_thread(request_id)
1110
+ #
1111
+ # The wait between pings is a condition variable wait under `@mutex`, not a plain sleep:
1112
+ # the presence check and the wait happen under the same lock that removals signal from,
1113
+ # so a removal cannot slip in between them and a thread waiting out its interval wakes
1114
+ # at once when its entry goes, whichever path removed it. A thread already past the wait,
1115
+ # in a ping, finishes that write first and then finds its entry gone.
1116
+ def start_listen_keepalive_thread(subscription_key, request_id)
982
1117
  return unless @listen_keepalive_interval
983
1118
 
984
1119
  Thread.new do
985
- while listen_subscription_active?(request_id)
986
- sleep(@listen_keepalive_interval)
987
- send_listen_keepalive_ping(request_id)
1120
+ loop do
1121
+ registered = @mutex.synchronize do
1122
+ subscription = @listen_subscriptions[subscription_key]
1123
+ next false unless subscription
1124
+
1125
+ subscription[:keepalive_wakeup].wait(@mutex, @listen_keepalive_interval)
1126
+
1127
+ @listen_subscriptions.key?(subscription_key)
1128
+ end
1129
+ break unless registered
1130
+
1131
+ send_listen_keepalive_ping(subscription_key)
988
1132
  end
989
1133
  rescue *STREAM_WRITE_ERRORS
990
1134
  # The peer went away; the ensure frees the slot. A dropped listen stream is the normal
991
1135
  # way this loop ends, so it is not reported.
992
1136
  rescue StandardError => e
1137
+ # The request id is taken from the caller rather than the registry: a delivery failure may have
1138
+ # removed the entry already, and the report should still name the stream.
993
1139
  MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
994
1140
  ensure
995
- stream = @mutex.synchronize do
996
- subscription = @listen_subscriptions.delete(request_id)
997
- subscription && subscription[:stream]
998
- end
999
- 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
1000
1144
  end
1001
1145
  end
1002
1146
 
1003
- def listen_subscription_active?(request_id)
1004
- @mutex.synchronize { @listen_subscriptions.key?(request_id) }
1005
- end
1147
+ # Resolves the entry under the registry lock, then writes outside it so a stalled reader cannot
1148
+ # block every other subscription on `@mutex`. The write itself holds the stream's write mutex,
1149
+ # like notification delivery and the closing result: the comment frame then cannot land between
1150
+ # the bytes of a notification or after the closing result, and once teardown has marked
1151
+ # the entry closed the ping is skipped. A write error propagates to end the keepalive loop.
1152
+ def send_listen_keepalive_ping(subscription_key)
1153
+ subscription = @mutex.synchronize { @listen_subscriptions[subscription_key] }
1154
+ return unless subscription
1006
1155
 
1007
- # Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
1008
- # every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
1009
- def send_listen_keepalive_ping(request_id)
1010
- stream = @mutex.synchronize do
1011
- subscription = @listen_subscriptions[request_id]
1012
- subscription && subscription[:stream]
1013
- end
1014
- return unless stream
1156
+ subscription[:write_mutex].synchronize do
1157
+ next if subscription[:closed]
1015
1158
 
1016
- send_ping_to_stream(stream)
1159
+ send_ping_to_stream(subscription[:stream])
1160
+ end
1017
1161
  end
1018
1162
 
1019
1163
  # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
@@ -1028,12 +1172,36 @@ module MCP
1028
1172
 
1029
1173
  subscriptions = filter[:resourceSubscriptions]
1030
1174
  if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
1031
- honored[:resourceSubscriptions] = subscriptions
1175
+ honored[:resourceSubscriptions] = subscriptions.uniq
1032
1176
  end
1033
1177
 
1034
1178
  honored
1035
1179
  end
1036
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
+
1037
1205
  # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
1038
1206
  # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
1039
1207
  # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
@@ -1051,28 +1219,20 @@ module MCP
1051
1219
  field = LISTEN_FILTER_FIELDS[method]
1052
1220
  return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
1053
1221
 
1054
- # The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
1055
- # a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
1056
- matched = @mutex.synchronize do
1057
- @listen_subscriptions.filter_map do |request_id, subscription|
1058
- # An inactive entry has not finished writing its acknowledgement yet;
1059
- # delivering to it would put a notification ahead of the acknowledgement.
1060
- next unless subscription[:active]
1061
-
1062
- hit = if field
1063
- subscription[:filter][field]
1064
- else
1065
- uris = subscription[:filter][:resourceSubscriptions]
1066
- uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1067
- uris.is_a?(Array) && uris.include?(uri)
1068
- 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] } }
1069
1228
 
1070
- [request_id, subscription] if hit
1071
- 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)
1072
1232
  end
1073
1233
 
1074
- matched.each do |request_id, subscription|
1075
- meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
1234
+ matched.each do |subscription_key, subscription|
1235
+ meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] }
1076
1236
  notification_params = (params || {}).merge(_meta: meta)
1077
1237
  notification = { jsonrpc: "2.0", method: method, params: notification_params }
1078
1238
 
@@ -1089,16 +1249,36 @@ module MCP
1089
1249
  rescue *STREAM_WRITE_ERRORS => e
1090
1250
  MCP.configuration.exception_reporter.call(
1091
1251
  e,
1092
- { subscription_id: request_id, error: "Failed to send notification" },
1252
+ { subscription_id: subscription[:request_id], error: "Failed to send notification" },
1093
1253
  )
1094
- remove_listen_subscription(request_id)
1095
- close_stream_safely(subscription[:stream])
1254
+ remove_listen_subscription(subscription_key)
1255
+ close_removed_listen_stream(subscription)
1096
1256
  end
1097
1257
  end
1098
1258
  end
1099
1259
 
1100
- def remove_listen_subscription(request_id)
1101
- @mutex.synchronize { @listen_subscriptions.delete(request_id) }
1260
+ def remove_listen_subscription(subscription_key)
1261
+ @mutex.synchronize do
1262
+ subscription = @listen_subscriptions.delete(subscription_key)
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
1271
+
1272
+ subscription
1273
+ end
1274
+ end
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]) }
1102
1282
  end
1103
1283
 
1104
1284
  # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
@@ -1107,26 +1287,35 @@ module MCP
1107
1287
  removed = @mutex.synchronize do
1108
1288
  subscriptions = @listen_subscriptions.dup
1109
1289
  @listen_subscriptions.clear
1290
+
1291
+ subscriptions.each_value do |subscription|
1292
+ subscription[:keepalive_wakeup].signal
1293
+ end
1294
+
1110
1295
  subscriptions
1111
1296
  end
1112
1297
 
1113
- removed.each do |request_id, subscription|
1298
+ removed.each_value do |subscription|
1114
1299
  # Marking the entry closed and writing the result under the stream's write mutex orders
1115
- # this against in-flight deliveries: each one either lands before the result or observes
1116
- # `closed` and skips, keeping the graceful result the stream's final message.
1300
+ # this against in-flight deliveries and against the acknowledgement write: each one either lands
1301
+ # before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
1117
1302
  subscription[:write_mutex].synchronize do
1118
1303
  subscription[:closed] = true
1119
1304
 
1305
+ # A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
1306
+ # the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
1307
+ next unless subscription[:active]
1308
+
1120
1309
  begin
1121
1310
  send_to_stream(subscription[:stream], {
1122
1311
  jsonrpc: "2.0",
1123
- id: request_id,
1312
+ id: subscription[:request_id],
1124
1313
  result: {
1125
1314
  # `SubscriptionsListenResult` is served at the transport layer and never
1126
1315
  # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1127
1316
  # stamped at its construction site.
1128
1317
  resultType: ResultType::COMPLETE,
1129
- _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1318
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1130
1319
  },
1131
1320
  })
1132
1321
  rescue *STREAM_WRITE_ERRORS
@@ -1242,6 +1431,16 @@ module MCP
1242
1431
  end
1243
1432
 
1244
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
+
1245
1444
  if !@stateless && session_id
1246
1445
  # An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
1247
1446
  # initialization attempt against a live session, or a retry against an unknown/expired
@@ -1470,6 +1669,45 @@ module MCP
1470
1669
  )
1471
1670
  end
1472
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
+
1473
1711
  def parse_request_body(body_string)
1474
1712
  # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
1475
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.0"
4
+ VERSION = "1.7.0"
5
5
  end