mcp 1.6.1 → 1.8.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
@@ -93,6 +121,18 @@ module MCP
93
121
  # 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream.
94
122
  DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15
95
123
 
124
+ # The shortest wait the listen keepalive makes when a stream's deadline is nearer than the interval.
125
+ LISTEN_DEADLINE_WAIT_FLOOR = 0.01
126
+
127
+ # How long an authenticated SSE stream may stay open before it is closed and the client has to present its token again.
128
+ # Bearer enforcement is per HTTP request, and a stream is one request, so without this an open stream outlives
129
+ # every later token check. The token's own expiry usually arrives first; the cap is what bounds a stream whose token reports
130
+ # no expiry at all (`exp` is optional in an RFC 7662 introspection response). Neither the specification nor the reference SDKs
131
+ # put a ceiling on a stream, so the value comes from this transport instead: it matches `DEFAULT_SESSION_IDLE_TIMEOUT`,
132
+ # because a handshake session already ends after that long without a request, which would leave a longer cap inert,
133
+ # and a sessionless `subscriptions/listen` stream has no other bound at all.
134
+ DEFAULT_MAX_STREAM_LIFETIME = DEFAULT_SESSION_IDLE_TIMEOUT
135
+
96
136
  # Creates a Streamable HTTP transport that can be mounted as a Rack app.
97
137
  #
98
138
  # @param server [MCP::Server] the server whose requests this transport dispatches.
@@ -122,9 +162,19 @@ module MCP
122
162
  # ownership is not enforced.
123
163
  # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
124
164
  # requests are rejected with HTTP 413. Defaults to 4 MiB.
165
+ # @param max_initialize_request_bytes [Integer, nil] upper bound in bytes on the body of an `initialize`
166
+ # request in stateful mode, whose `clientInfo` and `capabilities` the session keeps; a larger one is
167
+ # rejected with HTTP 413 before any session is created. Defaults to `DEFAULT_MAX_INITIALIZE_REQUEST_BYTES`
168
+ # (64 KiB), and `nil` disables this bound, leaving `max_request_bytes` as the only cap on the body; `params`
169
+ # holding more than `MAX_INITIALIZE_PARAMS_VALUES` JSON values are rejected the same way either way.
170
+ # Neither applies in stateless mode, which retains nothing.
125
171
  # @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
126
172
  # streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
127
173
  # the cap.
174
+ # @param max_resource_subscription_bytes [Integer, nil] bound on the total byte length of the distinct
175
+ # resource URIs one `subscriptions/listen` request names in `resourceSubscriptions`; a request over
176
+ # the bound is rejected with HTTP 400 and JSON-RPC `-32602`.
177
+ # Defaults to `DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES` (64 KiB), and `nil` disables the bound.
128
178
  # @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
129
179
  # on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
130
180
  # has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
@@ -138,6 +188,28 @@ module MCP
138
188
  # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
139
189
  # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
140
190
  # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
191
+ # @param token_verifier [#verify, nil] enables built-in OAuth 2.1 bearer enforcement: anything responding to
192
+ # `verify(token) -> MCP::Server::OAuth::AccessToken | nil` (see `MCP::Server::OAuth::TokenVerifier` for
193
+ # the contract and the built-in JWT/introspection implementations). Every POST, GET, and DELETE is verified
194
+ # per HTTP request; SSE streams are verified when opened, matching the per-request model of the Python and
195
+ # TypeScript SDKs. The verified token reaches handlers as `server_context.auth_info`. Without a verifier
196
+ # the transport still honors a token placed in `env["mcp.auth_info"]` by `MCP::Server::OAuth::Middleware`
197
+ # or a custom integration.
198
+ # @param required_scopes [Array<String>] scopes the token must include, all of them; a shortfall is
199
+ # rejected with HTTP 403 `insufficient_scope` (step-up). Requires `token_verifier`.
200
+ # @param resource_metadata [MCP::Server::OAuth::ProtectedResourceMetadata, nil] the RFC 9728 document describing
201
+ # this resource; its well-known URL and `scopes_supported` feed the `WWW-Authenticate` challenges.
202
+ # The document itself is served above the transport by `MCP::Server::OAuth::ProtectedResourceMetadataMiddleware`.
203
+ # Requires `token_verifier`.
204
+ # @param resource_metadata_url [String, nil] explicit challenge URL when the metadata document is served elsewhere;
205
+ # wins over `resource_metadata.well_known_url`. Requires `token_verifier`.
206
+ # @param scope_matcher [#call, nil] optional `(required_scope, granted_scopes) -> Boolean` for hierarchical scope schemes;
207
+ # defaults to exact membership. Requires `token_verifier`.
208
+ # @param max_stream_lifetime [Numeric, nil] seconds an authenticated SSE stream may stay open before it is closed
209
+ # and the client must present its token again, `DEFAULT_MAX_STREAM_LIFETIME` (1800) by default. The token's own
210
+ # expiry closes the stream earlier when it comes first; the cap is what bounds a stream whose token reports no
211
+ # expiry. `nil` removes the cap, leaving such a stream open until either side disconnects. Streams opened without
212
+ # a token are never capped.
141
213
  def initialize(
142
214
  server,
143
215
  stateless: false,
@@ -149,10 +221,18 @@ module MCP
149
221
  dns_rebinding_protection: true,
150
222
  session_request_validator: nil,
151
223
  max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
224
+ max_initialize_request_bytes: DEFAULT_MAX_INITIALIZE_REQUEST_BYTES,
152
225
  max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
226
+ max_resource_subscription_bytes: DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES,
153
227
  listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
154
228
  serve_subscriptions_listen: true,
155
- server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
229
+ server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT,
230
+ token_verifier: nil,
231
+ required_scopes: [],
232
+ resource_metadata: nil,
233
+ resource_metadata_url: nil,
234
+ scope_matcher: nil,
235
+ max_stream_lifetime: DEFAULT_MAX_STREAM_LIFETIME
156
236
  )
157
237
  super(server)
158
238
  # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
@@ -170,8 +250,9 @@ module MCP
170
250
  @pending_responses = {}
171
251
 
172
252
  # 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,
253
+ # `{ request_id: listen_request_id, stream: stream_object, filter: honored_list_changed_flags,
254
+ # resource_uris: Set_of_honored_resource_uris, active: boolean, write_mutex: Mutex,
255
+ # keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
175
256
  # unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
176
257
  # two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
177
258
  # so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
@@ -212,12 +293,25 @@ module MCP
212
293
 
213
294
  @max_request_bytes = max_request_bytes
214
295
 
296
+ if !max_initialize_request_bytes.nil? && !(max_initialize_request_bytes.is_a?(Integer) && max_initialize_request_bytes > 0)
297
+ raise ArgumentError, "max_initialize_request_bytes must be a positive Integer or nil"
298
+ end
299
+
300
+ # The bound guards what stateful sessions retain; stateless mode keeps none.
301
+ @max_initialize_request_bytes = stateless ? nil : max_initialize_request_bytes
302
+
215
303
  if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
216
304
  raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
217
305
  end
218
306
 
219
307
  @max_listen_subscriptions = max_listen_subscriptions
220
308
 
309
+ if !max_resource_subscription_bytes.nil? && !(max_resource_subscription_bytes.is_a?(Integer) && max_resource_subscription_bytes > 0)
310
+ raise ArgumentError, "max_resource_subscription_bytes must be a positive Integer or nil"
311
+ end
312
+
313
+ @max_resource_subscription_bytes = max_resource_subscription_bytes
314
+
221
315
  if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
222
316
  raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
223
317
  end
@@ -231,6 +325,32 @@ module MCP
231
325
 
232
326
  @server_to_client_request_timeout = server_to_client_request_timeout
233
327
 
328
+ if token_verifier
329
+ @oauth_authenticator = OAuth::Authenticator.new(
330
+ token_verifier: token_verifier,
331
+ required_scopes: required_scopes,
332
+ resource_metadata: resource_metadata,
333
+ resource_metadata_url: resource_metadata_url,
334
+ scope_matcher: scope_matcher,
335
+ )
336
+ elsif !Array(required_scopes).empty? || resource_metadata || resource_metadata_url || scope_matcher
337
+ # OAuth options without a verifier would look protected while enforcing nothing.
338
+ raise ArgumentError,
339
+ "required_scopes, resource_metadata, resource_metadata_url, and scope_matcher require token_verifier"
340
+ else
341
+ @oauth_authenticator = nil
342
+ end
343
+
344
+ unless max_stream_lifetime.nil? || (max_stream_lifetime.is_a?(Numeric) && max_stream_lifetime.positive?)
345
+ raise ArgumentError, "max_stream_lifetime must be a positive number or nil"
346
+ end
347
+
348
+ @max_stream_lifetime = max_stream_lifetime
349
+
350
+ # `close` sets the flag and signals the reaper under `@mutex`, so the reaper leaves its wait at once instead of
351
+ # being killed in the middle of a reap.
352
+ @closing = false
353
+ @reaper_wakeup = ConditionVariable.new
234
354
  start_reaper_thread if @session_idle_timeout
235
355
  end
236
356
 
@@ -239,6 +359,11 @@ module MCP
239
359
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
240
360
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
241
361
  SESSION_REAP_INTERVAL = 60
362
+ # Seconds `close` waits for a reap in progress to finish closing its streams before it kills the reaper.
363
+ REAPER_JOIN_TIMEOUT = 5
364
+ # How long `close` waits in all for the closing results of the open `subscriptions/listen` streams
365
+ # before closing the streams still open without one.
366
+ LISTEN_TEARDOWN_TIMEOUT = 5
242
367
 
243
368
  # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
244
369
  # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
@@ -285,6 +410,11 @@ module MCP
285
410
  rebinding_error = validate_dns_rebinding(request)
286
411
  return rebinding_error if rebinding_error
287
412
 
413
+ # Bearer enforcement runs after the DNS-rebinding rejection (which must stay the cheapest gate and
414
+ # never trigger verifier work) and before any body read, so an unauthenticated request costs no parsing.
415
+ auth_error = authenticate_request(request)
416
+ return auth_error if auth_error
417
+
288
418
  # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
289
419
  # every supported list routes to the sessionless modern path, so an unknown future version receives
290
420
  # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
@@ -332,8 +462,28 @@ module MCP
332
462
  end
333
463
  end
334
464
 
465
+ # The reaper is asked to stop and joined before it is killed: `reap_expired_sessions` removes expired sessions
466
+ # under `@mutex` and closes their streams after releasing it, and a kill landing between the two would leave
467
+ # those streams open with no session left to find them by. Waking the reaper ends its wait at once, and the join
468
+ # lets a reap already past its removal finish its closes before the rest of the transport is torn down.
469
+ # The join is bounded, so a stream whose close never returns cannot hold the shutdown, and a reaper that died
470
+ # with a `StandardError` does not stop the teardown below: `join` re-raises the exception a thread died with,
471
+ # which is not this method's to raise. A reaper still alive after the wait is killed as before, leaving
472
+ # the streams of that reap it had not closed yet as they were.
335
473
  def close
336
- @reaper_thread&.kill
474
+ reaper = @mutex.synchronize do
475
+ @closing = true
476
+ @reaper_wakeup.signal
477
+ @reaper_thread
478
+ end
479
+
480
+ begin
481
+ reaper&.join(REAPER_JOIN_TIMEOUT)
482
+ rescue StandardError
483
+ # The reaper is gone either way; the exception that ended it is not this method's to report.
484
+ end
485
+
486
+ reaper.kill if reaper&.alive?
337
487
  @reaper_thread = nil
338
488
 
339
489
  teardown_listen_subscriptions
@@ -405,7 +555,7 @@ module MCP
405
555
  next
406
556
  end
407
557
 
408
- active_stream(session, related_request_id: related_request_id)
558
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
409
559
  end
410
560
 
411
561
  close_streams(streams_to_close)
@@ -422,14 +572,16 @@ module MCP
422
572
  expired_session_ids = []
423
573
 
424
574
  collected = @sessions.filter_map do |session_id, session|
425
- next unless (stream = session[:get_sse_stream])
575
+ next unless session[:get_sse_stream]
426
576
 
427
577
  if session_expired?(session)
428
578
  expired_session_ids << session_id
429
579
  next
430
580
  end
431
581
 
432
- [session_id, stream]
582
+ stream = active_stream(session, streams_to_close)
583
+
584
+ [session_id, stream] if stream
433
585
  end
434
586
 
435
587
  expired_session_ids.each do |session_id|
@@ -460,6 +612,11 @@ module MCP
460
612
  # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
461
613
  # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
462
614
  # under `@mutex`, and the affected streams are closed outside it.
615
+ #
616
+ # Only the GET stream the session still holds takes the session down with it, judged under the same hold of
617
+ # the lock that removes the session: the failed write may have been picked for a stream that was detached since,
618
+ # because its token expired and the client reopened, and the stream opened in its place must stay. A failed
619
+ # stream the session no longer holds was closed by whichever path detached it, so there is nothing left to do.
463
620
  def drop_broken_stream(session_id, stream, related_request_id)
464
621
  streams_to_close = []
465
622
 
@@ -473,7 +630,7 @@ module MCP
473
630
  session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
474
631
 
475
632
  streams_to_close << stream
476
- else
633
+ elsif session && session[:get_sse_stream].equal?(stream)
477
634
  cleanup_and_collect_stream(session_id, streams_to_close)
478
635
  end
479
636
  end
@@ -531,6 +688,7 @@ module MCP
531
688
 
532
689
  # Register the pending response and resolve the stream under the lock, but perform
533
690
  # the write outside it so a stalled reader cannot block every other session on `@mutex`.
691
+ streams_to_close = []
534
692
  stream = @mutex.synchronize do
535
693
  unless (session = @sessions[session_id])
536
694
  raise "Session not found: #{session_id}."
@@ -538,9 +696,11 @@ module MCP
538
696
 
539
697
  @pending_responses[request_id] = { queue: pending_response, session_id: session_id }
540
698
 
541
- active_stream(session, related_request_id: related_request_id)
699
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
542
700
  end
543
701
 
702
+ close_streams(streams_to_close)
703
+
544
704
  sent = false
545
705
  if stream
546
706
  begin
@@ -641,10 +801,18 @@ module MCP
641
801
 
642
802
  private
643
803
 
804
+ # The wait between reaps is a condition variable wait under `@mutex` rather than a sleep, so `close` can end it
805
+ # by signalling; a spurious wakeup only runs a reap early.
644
806
  def start_reaper_thread
645
807
  @reaper_thread = Thread.new do
646
808
  loop do
647
- sleep(SESSION_REAP_INTERVAL)
809
+ closing = @mutex.synchronize do
810
+ @reaper_wakeup.wait(@mutex, SESSION_REAP_INTERVAL) unless @closing
811
+ @closing
812
+ end
813
+
814
+ break if closing
815
+
648
816
  reap_expired_sessions
649
817
  rescue StandardError => e
650
818
  MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
@@ -743,13 +911,13 @@ module MCP
743
911
  # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
744
912
  # serve an open SSE stream needs, instead of a `Proc` body it can never call.
745
913
  if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
746
- return handle_subscriptions_listen(body)
914
+ return handle_subscriptions_listen(body, auth_info: request.env[OAuth::ENV_KEY])
747
915
  end
748
916
 
749
917
  session = modern_session
750
918
  notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
751
919
  begin
752
- response = @server.handle(body, session: session)
920
+ response = @server.handle(body, session: session, auth_info: request.env[OAuth::ENV_KEY])
753
921
  ensure
754
922
  @mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
755
923
  end
@@ -841,7 +1009,7 @@ module MCP
841
1009
  # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
842
1010
  # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
843
1011
  # `listen_keepalive_interval` seconds so a dropped connection frees its slot.
844
- def handle_subscriptions_listen(body)
1012
+ def handle_subscriptions_listen(body, auth_info: nil)
845
1013
  request_id = body[:id]
846
1014
  params = body[:params]
847
1015
 
@@ -850,6 +1018,14 @@ module MCP
850
1018
  return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
851
1019
  end
852
1020
 
1021
+ # The id is echoed in every message of the stream, so one that cannot be written back as JSON
1022
+ # (a String holding bytes that are not valid UTF-8 parses, but does not generate) is refused here,
1023
+ # while the refusal can still answer with a null id, rather than surfacing as a failed write after
1024
+ # the stream has been registered.
1025
+ unless json_encodable?(request_id)
1026
+ return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
1027
+ end
1028
+
853
1029
  begin
854
1030
  if RequestEnvelope.modern?(params)
855
1031
  RequestEnvelope.parse!(params, request: params)
@@ -876,6 +1052,15 @@ module MCP
876
1052
  )
877
1053
  end
878
1054
 
1055
+ if (problem = resource_subscriptions_problem(filter))
1056
+ return json_rpc_error_response(
1057
+ status: 400,
1058
+ code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
1059
+ message: "Invalid params: subscriptions/listen #{problem}",
1060
+ id: request_id,
1061
+ )
1062
+ end
1063
+
879
1064
  # Best-effort cap check before committing to the SSE response; the registration inside
880
1065
  # `listen_sse_body` re-checks atomically for the race between two concurrent listens
881
1066
  # crossing the cap together.
@@ -883,7 +1068,43 @@ module MCP
883
1068
  return too_many_listen_subscriptions_response(request_id)
884
1069
  end
885
1070
 
886
- [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))]
1071
+ honored = honored_filter(filter)
1072
+
1073
+ # The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
1074
+ # so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
1075
+ # refused with a 400 instead of failing the first write after the stream was registered.
1076
+ unless (acknowledgement = listen_acknowledgement_json(request_id, honored))
1077
+ return json_rpc_error_response(
1078
+ status: 400,
1079
+ code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
1080
+ message: "Invalid params: subscriptions/listen `notifications` must be valid UTF-8",
1081
+ id: request_id,
1082
+ )
1083
+ end
1084
+
1085
+ [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement, auth_info)]
1086
+ end
1087
+
1088
+ # The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
1089
+ # the request id or the honored filter holds a string JSON cannot encode.
1090
+ def listen_acknowledgement_json(request_id, honored)
1091
+ {
1092
+ jsonrpc: "2.0",
1093
+ method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
1094
+ params: {
1095
+ notifications: honored,
1096
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
1097
+ },
1098
+ }.to_json
1099
+ rescue JSON::GeneratorError
1100
+ nil
1101
+ end
1102
+
1103
+ def json_encodable?(value)
1104
+ value.to_json
1105
+ true
1106
+ rescue JSON::GeneratorError
1107
+ false
887
1108
  end
888
1109
 
889
1110
  def listen_subscriptions_full?
@@ -942,14 +1163,25 @@ module MCP
942
1163
  # The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
943
1164
  # the requesting client's own in-flight requests, and two clients that pick the same one must each get
944
1165
  # their stream, stamped with the id they sent.
945
- def listen_sse_body(request_id, honored)
1166
+ def listen_sse_body(request_id, honored, acknowledgement, auth_info = nil)
946
1167
  ListenStreamBody.new do |stream|
947
1168
  subscription_key = SecureRandom.uuid
948
1169
  subscription = nil
1170
+ # 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:
1171
+ # delivery looks each notification's URI up in constant time, so neither the matching cost nor the lock hold grows with
1172
+ # the number of URIs a stream named. The entry does not keep the Array the acknowledgement echoes; that one lives only as long as
1173
+ # the host keeps this response body.
1174
+ entry_filter = honored.reject { |name, _value| name == :resourceSubscriptions }
1175
+ resource_uris = Set.new(honored[:resourceSubscriptions] || []).freeze
1176
+
949
1177
  @mutex.synchronize do
950
1178
  unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
1179
+ # The expiry of the token that authenticated the listen request is kept with the stream
1180
+ # so the keepalive loop and the delivery path can close the stream once that token expires.
951
1181
  subscription = {
952
- request_id: request_id, stream: stream, filter: honored, active: false, write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new
1182
+ request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
1183
+ write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
1184
+ expires_at: stream_token_expiry(auth_info),
953
1185
  }
954
1186
  @listen_subscriptions[subscription_key] = subscription
955
1187
  end
@@ -958,15 +1190,6 @@ module MCP
958
1190
  if subscription.nil?
959
1191
  close_stream_safely(stream)
960
1192
  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
1193
  begin
971
1194
  acknowledged = subscription[:write_mutex].synchronize do
972
1195
  next false if subscription[:closed]
@@ -984,14 +1207,11 @@ module MCP
984
1207
  true
985
1208
  end
986
1209
 
987
- if acknowledged
988
- start_listen_keepalive_thread(subscription_key, request_id)
989
- else
990
- close_stream_safely(stream)
991
- end
1210
+ # An acknowledgement skipped because the entry was marked closed leaves the stream to the path that marked
1211
+ # it, the teardown, which closes the stream itself once it holds the write mutex.
1212
+ start_listen_keepalive_thread(subscription_key, request_id) if acknowledged
992
1213
  rescue *STREAM_WRITE_ERRORS
993
- remove_listen_subscription(subscription_key)
994
- close_stream_safely(stream)
1214
+ close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
995
1215
  end
996
1216
  end
997
1217
  end
@@ -1015,10 +1235,15 @@ module MCP
1015
1235
  registered = @mutex.synchronize do
1016
1236
  subscription = @listen_subscriptions[subscription_key]
1017
1237
  next false unless subscription
1238
+ # Checked ahead of each tick so the stream does not outlive the token that opened it.
1239
+ # Revocation is not re-checked: the reference SDKs verify a stream at open only.
1240
+ next false if token_expiry_passed?(subscription[:expires_at])
1018
1241
 
1019
- subscription[:keepalive_wakeup].wait(@mutex, @listen_keepalive_interval)
1242
+ # The wait is cut short at the stream's deadline, so the stream ends at that deadline rather than
1243
+ # up to one interval after it, and a deadline that passed during the wait ends the loop before the ping.
1244
+ subscription[:keepalive_wakeup].wait(@mutex, listen_keepalive_wait(subscription[:expires_at]))
1020
1245
 
1021
- @listen_subscriptions.key?(subscription_key)
1246
+ @listen_subscriptions.key?(subscription_key) && !token_expiry_passed?(subscription[:expires_at])
1022
1247
  end
1023
1248
  break unless registered
1024
1249
 
@@ -1032,11 +1257,9 @@ module MCP
1032
1257
  # removed the entry already, and the report should still name the stream.
1033
1258
  MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
1034
1259
  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
1260
+ # Through the helper, so the entry is marked closed here as it is for every other removal.
1261
+ subscription = remove_listen_subscription(subscription_key)
1262
+ close_removed_listen_stream(subscription) if subscription
1040
1263
  end
1041
1264
  end
1042
1265
 
@@ -1056,6 +1279,40 @@ module MCP
1056
1279
  end
1057
1280
  end
1058
1281
 
1282
+ # The deadline at which a stream authenticated by `auth_info` is closed: whichever comes first of the token's own
1283
+ # expiry and `max_stream_lifetime:` seconds from now. Only the deadline is retained with the stream,
1284
+ # never the `AccessToken` itself, which would park the raw bearer credential in `@sessions` or `@listen_subscriptions`
1285
+ # for the life of the stream. The cap is what bounds a stream whose token reports no expiry (`exp` is optional in
1286
+ # an RFC 7662 introspection response), so "a stream does not outlive its credential" holds even when the credential
1287
+ # never says when it ends. A stream opened without a token is not capped: there is no credential to bound,
1288
+ # and capping it would change a transport used without `token_verifier:`.
1289
+ def stream_token_expiry(auth_info)
1290
+ return unless auth_info
1291
+
1292
+ deadlines = []
1293
+ token_expiry = auth_info.expires_at if auth_info.respond_to?(:expires_at)
1294
+ # A custom verifier that breaks the `AccessToken` contract with an expiry that is not a number must not fail
1295
+ # the stream while it is being set up: the value is ignored and only the cap applies.
1296
+ deadlines << token_expiry if token_expiry.is_a?(Numeric)
1297
+ deadlines << Time.now.to_i + @max_stream_lifetime if @max_stream_lifetime
1298
+
1299
+ deadlines.min
1300
+ end
1301
+
1302
+ # Same rule as `AccessToken#expired?`: no expiry means the token never expires here.
1303
+ def token_expiry_passed?(expires_at)
1304
+ !expires_at.nil? && expires_at <= Time.now.to_i
1305
+ end
1306
+
1307
+ # Seconds the listen keepalive thread waits before its next tick: the interval, or the time left until
1308
+ # the stream's deadline when that comes first. The floor keeps a deadline a few microseconds away from
1309
+ # turning into a zero-length wait.
1310
+ def listen_keepalive_wait(expires_at)
1311
+ return @listen_keepalive_interval if expires_at.nil?
1312
+
1313
+ [[expires_at - Time.now.to_f, LISTEN_DEADLINE_WAIT_FLOOR].max, @listen_keepalive_interval].min
1314
+ end
1315
+
1059
1316
  # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
1060
1317
  # and the acknowledgement only includes types the server actually supports
1061
1318
  # (derived from its declared capabilities).
@@ -1068,12 +1325,36 @@ module MCP
1068
1325
 
1069
1326
  subscriptions = filter[:resourceSubscriptions]
1070
1327
  if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
1071
- honored[:resourceSubscriptions] = subscriptions
1328
+ honored[:resourceSubscriptions] = subscriptions.uniq
1072
1329
  end
1073
1330
 
1074
1331
  honored
1075
1332
  end
1076
1333
 
1334
+ # Checks a listen filter's `resourceSubscriptions` against the schema, which types it as an optional array of strings,
1335
+ # so a member that is present but `null` is refused like any other non-array; against `MAX_RESOURCE_SUBSCRIPTION_URIS`;
1336
+ # and against `max_resource_subscription_bytes`, returning the problem to report or `nil`. Duplicates are dropped before
1337
+ # the bounds are measured, since they are dropped before storage too. The checks run whether or not the server declares
1338
+ # the `subscribe` capability: a request that violates the schema is invalid regardless of what would be honored,
1339
+ # and the bounds are limits on the request itself, like `max_request_bytes`, not on what the server would retain from it.
1340
+ def resource_subscriptions_problem(filter)
1341
+ return unless filter.key?(:resourceSubscriptions)
1342
+
1343
+ subscriptions = filter[:resourceSubscriptions]
1344
+ unless subscriptions.is_a?(Array) && subscriptions.all? { |uri| uri.is_a?(String) }
1345
+ return "`resourceSubscriptions` must be an array of strings"
1346
+ end
1347
+
1348
+ distinct = subscriptions.uniq
1349
+ if distinct.size > MAX_RESOURCE_SUBSCRIPTION_URIS
1350
+ return "`resourceSubscriptions` exceeds #{MAX_RESOURCE_SUBSCRIPTION_URIS} URIs"
1351
+ end
1352
+ return unless @max_resource_subscription_bytes
1353
+ return if distinct.sum(&:bytesize) <= @max_resource_subscription_bytes
1354
+
1355
+ "`resourceSubscriptions` exceeds #{@max_resource_subscription_bytes} bytes"
1356
+ end
1357
+
1077
1358
  # Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
1078
1359
  # in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
1079
1360
  # `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
@@ -1091,24 +1372,34 @@ module MCP
1091
1372
  field = LISTEN_FILTER_FIELDS[method]
1092
1373
  return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
1093
1374
 
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]
1375
+ # Only the snapshot of active entries is taken under `@mutex`, one pass over the entries as before;
1376
+ # the matching and the stream writes happen outside it, so neither the number of URIs the streams named
1377
+ # nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
1378
+ # An inactive entry has not finished writing its acknowledgement yet;
1379
+ # delivering to it would put a notification ahead of the acknowledgement.
1380
+ # An active entry whose token deadline has passed is closed here instead of delivered to, so the deadline
1381
+ # holds on a transport whose keepalive is disabled as well. An expired entry still inactive is skipped
1382
+ # like any inactive one and caught by the next delivery once its acknowledgement is written.
1383
+ expired_keys = []
1384
+ candidates = @mutex.synchronize do
1385
+ @listen_subscriptions.select do |key, subscription|
1386
+ next false unless subscription[:active]
1387
+ next true unless token_expiry_passed?(subscription[:expires_at])
1388
+
1389
+ expired_keys << key
1390
+ false
1391
+ end
1392
+ end
1101
1393
 
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
1394
+ expired_keys.each do |key|
1395
+ subscription = remove_listen_subscription(key)
1109
1396
 
1110
- [subscription_key, subscription] if hit
1111
- end
1397
+ close_removed_listen_stream(subscription) if subscription
1398
+ end
1399
+
1400
+ uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1401
+ matched = candidates.select do |_key, subscription|
1402
+ field ? subscription[:filter][field] : subscription[:resource_uris].include?(uri)
1112
1403
  end
1113
1404
 
1114
1405
  matched.each do |subscription_key, subscription|
@@ -1131,8 +1422,9 @@ module MCP
1131
1422
  e,
1132
1423
  { subscription_id: subscription[:request_id], error: "Failed to send notification" },
1133
1424
  )
1134
- remove_listen_subscription(subscription_key)
1135
- close_stream_safely(subscription[:stream])
1425
+ # Closed only when the removal is this path's own, as every other removal path does: the keepalive loop,
1426
+ # a token expiry, or the teardown that removed the entry first closes the stream itself.
1427
+ close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
1136
1428
  end
1137
1429
  end
1138
1430
  end
@@ -1140,15 +1432,36 @@ module MCP
1140
1432
  def remove_listen_subscription(subscription_key)
1141
1433
  @mutex.synchronize do
1142
1434
  subscription = @listen_subscriptions.delete(subscription_key)
1143
- subscription[:keepalive_wakeup].signal if subscription
1435
+ if subscription
1436
+ # Marked closed as teardown does, so a delivery or keepalive that took the entry before the removal skips it under
1437
+ # the write mutex instead of writing to a stream being closed. The flag is set without taking the write mutex,
1438
+ # which must never be taken inside `@mutex`: a write that checks the flag from here on skips, and one already
1439
+ # past its check lands before the stream closes, since `close_removed_listen_stream` waits for the write mutex.
1440
+ subscription[:closed] = true
1441
+ subscription[:keepalive_wakeup].signal
1442
+ end
1144
1443
 
1145
1444
  subscription
1146
1445
  end
1147
1446
  end
1148
1447
 
1448
+ # Closes a subscription's stream while excluding concurrent writes. The write mutex, taken here outside
1449
+ # `@mutex`, makes the close wait for a write already past its `closed` check, so that write completes or fails
1450
+ # before the stream is closed, and a write that takes the mutex afterwards sees `closed` and skips. Teardown
1451
+ # gets the same ordering by setting `closed` under the write mutex itself.
1452
+ def close_removed_listen_stream(subscription)
1453
+ subscription[:write_mutex].synchronize { close_stream_safely(subscription[:stream]) }
1454
+ end
1455
+
1149
1456
  # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1150
- # before the stream closes.
1151
- def teardown_listen_subscriptions
1457
+ # before the stream closes. The results are written on a helper thread that is waited for at most `timeout`
1458
+ # seconds in all, because a peer that stopped reading stalls the write of its result, or a delivery already
1459
+ # holding the stream's write mutex, for as long as its socket buffers stay full. Once the time is up, every stream
1460
+ # still open is closed from here: closing a Ruby IO ends a write stalled on it with an `IOError` the helper rescues,
1461
+ # while a stream object of another kind may keep the helper blocked, which no longer holds `close`. Either way
1462
+ # the stream goes without its result, like one whose acknowledgement was never written. The helper is left
1463
+ # to finish on its own rather than killed, so a write mutex is never dropped in the middle of a write.
1464
+ def teardown_listen_subscriptions(timeout: LISTEN_TEARDOWN_TIMEOUT)
1152
1465
  removed = @mutex.synchronize do
1153
1466
  subscriptions = @listen_subscriptions.dup
1154
1467
  @listen_subscriptions.clear
@@ -1159,35 +1472,72 @@ module MCP
1159
1472
 
1160
1473
  subscriptions
1161
1474
  end
1475
+ return if removed.empty?
1162
1476
 
1163
- removed.each_value do |subscription|
1164
- # Marking the entry closed and writing the result under the stream's write mutex orders
1165
- # this against in-flight deliveries and against the acknowledgement write: each one either lands
1166
- # before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
1167
- subscription[:write_mutex].synchronize do
1168
- subscription[:closed] = true
1477
+ # Whichever side takes an entry out of `pending` closes its stream: the helper after the write, or `close` once
1478
+ # the time is up. Claiming before closing keeps a stream from being closed by both in either order.
1479
+ pending = removed.dup
1480
+ pending_mutex = Mutex.new
1481
+ writer = Thread.new do
1482
+ removed.each do |subscription_key, subscription|
1483
+ write_listen_closing_result(subscription)
1484
+ close_stream_safely(subscription[:stream]) if pending_mutex.synchronize { pending.delete(subscription_key) }
1485
+ end
1486
+ end
1169
1487
 
1170
- # A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
1171
- # the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
1172
- next unless subscription[:active]
1488
+ begin
1489
+ writer.join(timeout)
1490
+ rescue StandardError
1491
+ # A helper that died with an exception has stopped writing; the streams it did not finish are closed below.
1492
+ end
1173
1493
 
1174
- begin
1175
- send_to_stream(subscription[:stream], {
1176
- jsonrpc: "2.0",
1177
- id: subscription[:request_id],
1178
- result: {
1179
- # `SubscriptionsListenResult` is served at the transport layer and never
1180
- # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1181
- # stamped at its construction site.
1182
- resultType: ResultType::COMPLETE,
1183
- _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1184
- },
1185
- })
1186
- rescue *STREAM_WRITE_ERRORS
1187
- nil
1188
- end
1494
+ left = pending_mutex.synchronize do
1495
+ subscriptions = pending.values
1496
+ pending.clear
1497
+ subscriptions
1498
+ end
1499
+ left.each { |subscription| close_stream_safely(subscription[:stream]) }
1500
+ end
1501
+
1502
+ # Marking the entry closed and writing the result under the stream's write mutex orders
1503
+ # this against in-flight deliveries and against the acknowledgement write: each one either lands
1504
+ # before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
1505
+ # The stream is left open for the caller to close.
1506
+ def write_listen_closing_result(subscription)
1507
+ subscription[:write_mutex].synchronize do
1508
+ subscription[:closed] = true
1509
+
1510
+ # A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
1511
+ # the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
1512
+ next unless subscription[:active]
1513
+
1514
+ begin
1515
+ send_to_stream(subscription[:stream], {
1516
+ jsonrpc: "2.0",
1517
+ id: subscription[:request_id],
1518
+ result: {
1519
+ # `SubscriptionsListenResult` is served at the transport layer and never
1520
+ # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1521
+ # stamped at its construction site.
1522
+ resultType: ResultType::COMPLETE,
1523
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1524
+ },
1525
+ })
1526
+ rescue *STREAM_WRITE_ERRORS
1527
+ nil
1189
1528
  end
1190
- close_stream_safely(subscription[:stream])
1529
+ end
1530
+ rescue StandardError => e
1531
+ # Reported rather than raised: on the helper thread an error would end the teardown of every stream after this one,
1532
+ # and `close` is past waiting for it by the time a stalled write fails. A reporter that raises is dropped for
1533
+ # the same reason.
1534
+ begin
1535
+ MCP.configuration.exception_reporter.call(
1536
+ e,
1537
+ { subscription_id: subscription[:request_id], error: "Failed to close listen stream" },
1538
+ )
1539
+ rescue StandardError
1540
+ nil
1191
1541
  end
1192
1542
  end
1193
1543
 
@@ -1296,13 +1646,31 @@ module MCP
1296
1646
  end
1297
1647
 
1298
1648
  if initialize_request?(body)
1649
+ # Checked before a session exists: the body's `clientInfo` and `capabilities` are what the session would keep,
1650
+ # so an oversized `initialize` is refused outright instead of being stored and capped later.
1651
+ if @max_initialize_request_bytes && body_string.bytesize > @max_initialize_request_bytes
1652
+ return initialize_too_large_response(body[:id], "body exceeds #{@max_initialize_request_bytes} bytes")
1653
+ end
1654
+
1655
+ if !@stateless && json_values_exceed?(body[:params], MAX_INITIALIZE_PARAMS_VALUES)
1656
+ return initialize_too_large_response(body[:id], "params exceed #{MAX_INITIALIZE_PARAMS_VALUES} JSON values")
1657
+ end
1658
+
1299
1659
  if !@stateless && session_id
1300
1660
  # An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
1301
1661
  # initialization attempt against a live session, or a retry against an unknown/expired
1302
1662
  # one. In the live case, reject with `-32600` so the original session is not abandoned.
1303
1663
  # In the unknown/expired case, return 404 so the client retries from scratch instead
1304
1664
  # of silently inheriting a fresh session under the old ID.
1305
- return already_initialized_response(body[:id]) if session_active?(session_id)
1665
+ # The live case is told only to the principal the session is bound to: for any other token the answer
1666
+ # is the 404 an unknown session gets, as on every other route, so a guessed session id is not confirmed
1667
+ # to exist by an `initialize` either.
1668
+ if session_active?(session_id)
1669
+ session = @mutex.synchronize { @sessions[session_id] }
1670
+ return session_not_found_response unless session && session_principal_matches?(session, request)
1671
+
1672
+ return already_initialized_response(body[:id])
1673
+ end
1306
1674
 
1307
1675
  return session_not_found_response
1308
1676
  end
@@ -1312,8 +1680,10 @@ module MCP
1312
1680
  # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
1313
1681
  # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
1314
1682
  # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
1315
- if !@stateless && session_id && !validate_session_request(request, session_id)
1316
- return forbidden_response
1683
+ if !@stateless && session_id
1684
+ rejection = session_request_rejection(request, session_id)
1685
+
1686
+ return rejection if rejection
1317
1687
  end
1318
1688
 
1319
1689
  if notification?(body)
@@ -1322,14 +1692,14 @@ module MCP
1322
1692
  # branches; without it a custom notification handler could run without a live session.
1323
1693
  return session_not_found_response if !@stateless && !session_active?(session_id)
1324
1694
 
1325
- dispatch_notification(body_string, session_id)
1695
+ dispatch_notification(body_string, session_id, auth_info: request.env[OAuth::ENV_KEY])
1326
1696
  handle_accepted
1327
1697
  elsif response?(body)
1328
1698
  return session_not_found_response if !@stateless && !session_exists?(session_id)
1329
1699
 
1330
1700
  handle_response(body, session_id: session_id)
1331
1701
  else
1332
- handle_regular_request(body_string, session_id, related_request_id: body[:id])
1702
+ handle_regular_request(body_string, session_id, related_request_id: body[:id], auth_info: request.env[OAuth::ENV_KEY])
1333
1703
  end
1334
1704
  end
1335
1705
  rescue StandardError => e
@@ -1353,16 +1723,20 @@ module MCP
1353
1723
 
1354
1724
  return missing_session_id_response unless session_id
1355
1725
 
1726
+ # The ownership gate runs before the session is touched, so a rejected request cannot refresh the idle timer of a session it does not own.
1727
+ # The visible outcome is unchanged: an unknown session passes the gate and fails the lookup, and an expired one fails it, both with the same 404.
1728
+ rejection = session_request_rejection(request, session_id)
1729
+ return rejection if rejection
1730
+
1356
1731
  error_response = validate_and_touch_session(session_id)
1357
1732
  return error_response if error_response
1358
- return forbidden_response unless validate_session_request(request, session_id)
1359
1733
 
1360
1734
  protocol_version_error = validate_protocol_version_header(request)
1361
1735
  return protocol_version_error if protocol_version_error
1362
1736
 
1363
- return session_already_connected_response if get_session_stream(session_id)
1737
+ return session_already_connected_response if live_get_stream?(session_id)
1364
1738
 
1365
- setup_sse_stream(session_id)
1739
+ setup_sse_stream(session_id, request.env[OAuth::ENV_KEY])
1366
1740
  end
1367
1741
 
1368
1742
  def handle_delete(request)
@@ -1378,16 +1752,22 @@ module MCP
1378
1752
 
1379
1753
  return missing_session_id_response unless (session_id = extract_session_id(request))
1380
1754
  return session_not_found_response unless session_exists?(session_id)
1381
- return forbidden_response unless validate_session_request(request, session_id)
1755
+ rejection = session_request_rejection(request, session_id)
1756
+ return rejection if rejection
1382
1757
 
1383
1758
  protocol_version_error = validate_protocol_version_header(request)
1384
1759
  return protocol_version_error if protocol_version_error
1385
1760
 
1386
- cleanup_session(session_id)
1761
+ # The session may be gone by now, removed by the reaper or by the owner's own DELETE since the checks above:
1762
+ # a request that terminated nothing answers like one naming an unknown session, so a removal it did not cause
1763
+ # confirms nothing about the session having existed.
1764
+ return session_not_found_response unless cleanup_session(session_id)
1387
1765
 
1388
1766
  success_response
1389
1767
  end
1390
1768
 
1769
+ # Removes the session, closes its streams outside the lock, and returns the removed session, or nil when
1770
+ # there was none to remove.
1391
1771
  def cleanup_session(session_id)
1392
1772
  session = @mutex.synchronize do
1393
1773
  cleanup_session_unsafe(session_id)
@@ -1397,6 +1777,8 @@ module MCP
1397
1777
  close_stream_safely(session[:get_sse_stream])
1398
1778
  close_post_request_streams(session)
1399
1779
  end
1780
+
1781
+ session
1400
1782
  end
1401
1783
 
1402
1784
  # Removes a session from `@sessions` and returns it. Does not close the stream.
@@ -1442,23 +1824,48 @@ module MCP
1442
1824
 
1443
1825
  # Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
1444
1826
  # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
1445
- # Two layers, both returning `false` to trigger a 403:
1827
+ # Three layers, each answering with the rejection response it returns. The built-in layers run before
1828
+ # the custom validator so a permissive `session_request_validator` cannot bypass them, and the principal
1829
+ # binding runs first of all, so that another principal gets the 404 whatever else its request carries:
1446
1830
  #
1831
+ # - Built-in principal binding (active when bearer authentication is in play): a session initialized under
1832
+ # one token identity refuses requests verified as a different one. The answer is the same 404 an unknown session gets,
1833
+ # so a guessed session ID is not confirmed to exist; it carries no `WWW-Authenticate` challenge either,
1834
+ # because the token itself is valid and re-authorization would not help.
1447
1835
  # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
1448
- # at `initialize` and this request carries a different one, reject. Both must be present to compare,
1836
+ # at `initialize` and this request carries a different one, reject with 403. Both must be present to compare,
1449
1837
  # so non-browser clients that send no `Origin` are unaffected.
1450
- # - The application-supplied `session_request_validator`, which can enforce true ownership when it has
1451
- # an authenticated principal.
1452
- def validate_session_request(request, session_id)
1838
+ # - The application-supplied `session_request_validator`, which can enforce ownership policy beyond the built-in layers;
1839
+ # a falsy return rejects with 403.
1840
+ #
1841
+ # Returns nil when the request may proceed.
1842
+ def session_request_rejection(request, session_id)
1453
1843
  session = @mutex.synchronize { @sessions[session_id] }
1454
- return true unless session
1844
+ return unless session
1845
+ return session_not_found_response unless session_principal_matches?(session, request)
1455
1846
 
1456
1847
  session_origin = session[:origin]
1457
1848
  request_origin = request.env["HTTP_ORIGIN"]
1458
- return false if session_origin && request_origin && session_origin != request_origin
1459
- return @session_request_validator.call(request, session_id) if @session_request_validator
1849
+ return forbidden_response if session_origin && request_origin && session_origin != request_origin
1460
1850
 
1461
- true
1851
+ if @session_request_validator && !@session_request_validator.call(request, session_id)
1852
+ return forbidden_response
1853
+ end
1854
+
1855
+ nil
1856
+ end
1857
+
1858
+ # A session bound to a principal at `initialize` only accepts requests verified as the same `issuer`, `subject`,
1859
+ # and `client_id` triple (client-credentials tokens without a `sub` bind on `client_id` alone; nil members compare equal).
1860
+ # The issuer takes part so a subject and client id pair repeated across identity providers behind a custom verifier
1861
+ # does not collide. A token whose subject and client id are both nil records no binding, because there is no identity to compare.
1862
+ def session_principal_matches?(session, request)
1863
+ return true if session[:auth_subject].nil? && session[:auth_client_id].nil?
1864
+
1865
+ token = request.env[OAuth::ENV_KEY]
1866
+ return false if token.nil?
1867
+
1868
+ token.issuer == session[:auth_issuer] && token.subject == session[:auth_subject] && token.client_id == session[:auth_client_id]
1462
1869
  end
1463
1870
 
1464
1871
  def validate_accept_header(request, required_types)
@@ -1524,6 +1931,45 @@ module MCP
1524
1931
  )
1525
1932
  end
1526
1933
 
1934
+ def initialize_too_large_response(request_id, reason)
1935
+ json_rpc_error_response(
1936
+ status: 413,
1937
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1938
+ message: "Payload too large: initialize request #{reason}",
1939
+ id: request_id,
1940
+ )
1941
+ end
1942
+
1943
+ # Counts the JSON values in `value` (every object, array, object key, and scalar) and reports whether
1944
+ # they exceed `limit`. The walk is iterative. Every value still queued is at least one more value,
1945
+ # so `count + pending.size` never overstates the total, and a container is refused before its members are
1946
+ # queued if they would take that sum past the limit; the walk therefore stops as soon as the limit is
1947
+ # certain to be passed and never queues more than `limit` values, whatever the shape of the input.
1948
+ def json_values_exceed?(value, limit)
1949
+ count = 0
1950
+ pending = [value]
1951
+
1952
+ until pending.empty?
1953
+ current = pending.pop
1954
+ count += 1
1955
+
1956
+ case current
1957
+ when Hash
1958
+ count += current.size
1959
+ return true if count + pending.size + current.size > limit
1960
+
1961
+ pending.concat(current.values)
1962
+ when Array
1963
+ return true if count + pending.size + current.size > limit
1964
+
1965
+ pending.concat(current)
1966
+ end
1967
+ return true if count + pending.size > limit
1968
+ end
1969
+
1970
+ false
1971
+ end
1972
+
1527
1973
  def parse_request_body(body_string)
1528
1974
  # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
1529
1975
  # a subclass of `JSON::ParserError`, so it is caught below as a parse error.
@@ -1595,7 +2041,7 @@ module MCP
1595
2041
 
1596
2042
  # Dispatches a client-originated notification (e.g. `notifications/cancelled`,
1597
2043
  # `notifications/initialized`) through the server so it can update session state.
1598
- def dispatch_notification(body_string, session_id)
2044
+ def dispatch_notification(body_string, session_id, auth_info: nil)
1599
2045
  server_session = nil
1600
2046
  if @stateless
1601
2047
  server_session = ephemeral_session
@@ -1606,7 +2052,7 @@ module MCP
1606
2052
  end
1607
2053
  end
1608
2054
 
1609
- dispatch_handle_json(body_string, server_session)
2055
+ dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1610
2056
  rescue => e
1611
2057
  MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
1612
2058
  end
@@ -1635,6 +2081,7 @@ module MCP
1635
2081
 
1636
2082
  def handle_initialization(request, body_string, body)
1637
2083
  session_id = nil
2084
+ auth_info = request.env[OAuth::ENV_KEY]
1638
2085
 
1639
2086
  if @stateless
1640
2087
  server_session = ephemeral_session
@@ -1660,9 +2107,15 @@ module MCP
1660
2107
  get_sse_stream: nil,
1661
2108
  server_session: server_session,
1662
2109
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1663
- # Captured for the built-in Origin-consistency defense in `validate_session_request`.
2110
+ # Captured for the built-in Origin-consistency defense in `session_request_rejection`.
1664
2111
  # Not authentication.
1665
2112
  origin: request.env["HTTP_ORIGIN"],
2113
+ # Principal binding per the spec's session-binding guidance: the session is bound to the token identity that initialized it,
2114
+ # and `session_request_rejection` rejects later requests presenting a token for a different principal. All three fields are nil
2115
+ # when the request was not bearer-authenticated, which disables the check.
2116
+ auth_issuer: auth_info&.issuer,
2117
+ auth_subject: auth_info&.subject,
2118
+ auth_client_id: auth_info&.client_id,
1666
2119
  }
1667
2120
  true
1668
2121
  end
@@ -1671,7 +2124,7 @@ module MCP
1671
2124
  return too_many_sessions_response unless inserted
1672
2125
  end
1673
2126
 
1674
- response = server_session.handle_json(body_string)
2127
+ response = server_session.handle_json(body_string, auth_info: auth_info)
1675
2128
 
1676
2129
  # `initialize_request?` matches on the method alone, so an `initialize` sent without
1677
2130
  # an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
@@ -1714,7 +2167,7 @@ module MCP
1714
2167
  )
1715
2168
  end
1716
2169
 
1717
- def handle_regular_request(body_string, session_id, related_request_id: nil)
2170
+ def handle_regular_request(body_string, session_id, related_request_id: nil, auth_info: nil)
1718
2171
  server_session = nil
1719
2172
 
1720
2173
  if @stateless
@@ -1738,9 +2191,9 @@ module MCP
1738
2191
  end
1739
2192
 
1740
2193
  if session_id && !@stateless && !@enable_json_response
1741
- handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
2194
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id, auth_info: auth_info)
1742
2195
  else
1743
- response = dispatch_handle_json(body_string, server_session)
2196
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1744
2197
 
1745
2198
  # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
1746
2199
  # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
@@ -1754,7 +2207,7 @@ module MCP
1754
2207
  # Returns the POST response as an SSE stream so the server can send
1755
2208
  # JSON-RPC requests and notifications during request processing.
1756
2209
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
1757
- def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
2210
+ def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil, auth_info: nil)
1758
2211
  body = proc do |stream|
1759
2212
  @mutex.synchronize do
1760
2213
  session = @sessions[session_id]
@@ -1769,7 +2222,7 @@ module MCP
1769
2222
  end
1770
2223
 
1771
2224
  begin
1772
- response = dispatch_handle_json(body_string, server_session)
2225
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1773
2226
 
1774
2227
  send_to_stream(stream, response) if response
1775
2228
  ensure
@@ -1799,20 +2252,25 @@ module MCP
1799
2252
  # When `related_request_id` is given, returns only the POST response
1800
2253
  # stream for that request (no fallback to GET SSE). This prevents
1801
2254
  # request-scoped messages from leaking to the wrong stream.
1802
- # When `related_request_id` is nil, returns the GET SSE stream.
1803
- def active_stream(session, related_request_id: nil)
1804
- if related_request_id
1805
- session.dig(:post_request_streams, related_request_id)
1806
- else
1807
- session[:get_sse_stream]
1808
- end
2255
+ # When `related_request_id` is nil, returns the GET SSE stream, or nil once the token that opened it
2256
+ # has expired: that stream is detached from the session and added to `streams_to_close` instead, so its
2257
+ # deadline holds at the next write and not only at the keepalive tick. Called under `@mutex`; the caller
2258
+ # closes the collected streams outside it.
2259
+ def active_stream(session, streams_to_close, related_request_id: nil)
2260
+ return session.dig(:post_request_streams, related_request_id) if related_request_id
2261
+
2262
+ stream = session[:get_sse_stream]
2263
+ return stream unless stream && token_expiry_passed?(session[:get_sse_stream_expires_at])
2264
+
2265
+ streams_to_close << detach_get_stream(session)
2266
+ nil
1809
2267
  end
1810
2268
 
1811
- def dispatch_handle_json(body_string, server_session)
2269
+ def dispatch_handle_json(body_string, server_session, auth_info: nil)
1812
2270
  if server_session
1813
- server_session.handle_json(body_string)
2271
+ server_session.handle_json(body_string, auth_info: auth_info)
1814
2272
  else
1815
- @server.handle_json(body_string)
2273
+ @server.handle_json(body_string, auth_info: auth_info)
1816
2274
  end
1817
2275
  end
1818
2276
 
@@ -1843,8 +2301,26 @@ module MCP
1843
2301
  response
1844
2302
  end
1845
2303
 
1846
- def get_session_stream(session_id)
1847
- @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
2304
+ # Whether the session holds a GET stream whose deadline has not passed. A stream whose token expired may still
2305
+ # be attached between its last delivery and its next keepalive tick; it is detached and closed here, so a client
2306
+ # reopening the stream with a fresh token is not turned away until one of those noticed.
2307
+ def live_get_stream?(session_id)
2308
+ detached = nil
2309
+ live = @mutex.synchronize do
2310
+ session = @sessions[session_id]
2311
+ stream = session && session[:get_sse_stream]
2312
+ if stream.nil?
2313
+ false
2314
+ elsif token_expiry_passed?(session[:get_sse_stream_expires_at])
2315
+ detached = detach_get_stream(session)
2316
+ false
2317
+ else
2318
+ true
2319
+ end
2320
+ end
2321
+
2322
+ close_stream_safely(detached) if detached
2323
+ live
1848
2324
  end
1849
2325
 
1850
2326
  def session_exists?(session_id)
@@ -1883,6 +2359,27 @@ module MCP
1883
2359
  active
1884
2360
  end
1885
2361
 
2362
+ # Built-in OAuth 2.1 bearer enforcement (active when `token_verifier:` is configured).
2363
+ # Returns nil on success, storing the verified `AccessToken` in `env["mcp.auth_info"]` where the dispatch paths
2364
+ # and `session_request_rejection` read it; returns the RFC 6750 challenge response on failure. OPTIONS is exempt:
2365
+ # CORS preflights never carry credentials, and rejecting them here would fail CORS closed for browser clients
2366
+ # while the transport still answers the preflight itself with 405 (an upstream CORS middleware normally intercepts it).
2367
+ def authenticate_request(request)
2368
+ return if @oauth_authenticator.nil?
2369
+ return if request.env["REQUEST_METHOD"] == "OPTIONS"
2370
+
2371
+ request.env[OAuth::ENV_KEY] = @oauth_authenticator.authenticate(request.env)
2372
+ nil
2373
+ rescue OAuth::Error => e
2374
+ @oauth_authenticator.challenge_response(e)
2375
+ rescue => e
2376
+ # A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure:
2377
+ # report it and answer 500 without a challenge, so the client does not discard a perfectly good token.
2378
+ MCP.configuration.exception_reporter.call(e, { transport: self.class.name })
2379
+
2380
+ [500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]]
2381
+ end
2382
+
1886
2383
  # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
1887
2384
  # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
1888
2385
  # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
@@ -2009,59 +2506,114 @@ module MCP
2009
2506
  )
2010
2507
  end
2011
2508
 
2012
- def setup_sse_stream(session_id)
2013
- body = create_sse_body(session_id)
2509
+ def setup_sse_stream(session_id, auth_info = nil)
2510
+ body = create_sse_body(session_id, auth_info)
2014
2511
 
2015
2512
  [200, SSE_HEADERS.dup, body]
2016
2513
  end
2017
2514
 
2018
- def create_sse_body(session_id)
2515
+ def create_sse_body(session_id, auth_info = nil)
2019
2516
  proc do |stream|
2020
- stored = store_stream_for_session(session_id, stream)
2021
- start_keepalive_thread(session_id) if stored
2517
+ stored = store_stream_for_session(session_id, stream, auth_info)
2518
+ start_keepalive_thread(session_id, stream) if stored
2022
2519
  end
2023
2520
  end
2024
2521
 
2025
- def store_stream_for_session(session_id, stream)
2026
- @mutex.synchronize do
2522
+ # The expiry of the token that authenticated the GET is kept with the stream so the keepalive loop and
2523
+ # the delivery paths can close the stream once that token expires.
2524
+ def store_stream_for_session(session_id, stream, auth_info = nil)
2525
+ stored = @mutex.synchronize do
2027
2526
  session = @sessions[session_id]
2028
- if session && !session[:get_sse_stream]
2029
- session[:get_sse_stream] = stream
2030
- else
2031
- # Either session was removed, or another request already established a stream.
2032
- stream.close
2033
- # `stream.close` may return a truthy value depending on the stream class.
2034
- # Explicitly return nil to guarantee a falsy return for callers.
2035
- nil
2036
- end
2527
+ next false unless session && !session[:get_sse_stream]
2528
+
2529
+ session[:get_sse_stream] = stream
2530
+ session[:get_sse_stream_expires_at] = stream_token_expiry(auth_info)
2531
+ true
2037
2532
  end
2533
+
2534
+ # The expiry is nil for a stream opened without a token, and callers read a falsy return as "not stored",
2535
+ # so the stream itself is the return value.
2536
+ return stream if stored
2537
+
2538
+ # Either the session was removed, or another request already established a stream. The refused stream is
2539
+ # closed outside the lock like every other stream this transport closes, so a close that blocks on the peer
2540
+ # cannot hold every other session on `@mutex`.
2541
+ close_stream_safely(stream)
2542
+ nil
2038
2543
  end
2039
2544
 
2040
- def start_keepalive_thread(session_id)
2545
+ # The thread acts on the stream it was started for and on no other: the session may have detached that
2546
+ # stream at a delivery once its token expired and the client may have opened a new one since, which gets
2547
+ # a keepalive thread of its own. The loop therefore ends as soon as the session no longer holds this stream,
2548
+ # and a loop that did not run to its end (a failed ping, or any other abnormal exit) drops the stream through
2549
+ # `drop_broken_stream`, which tears the session down only while it still holds that stream, as before.
2550
+ def start_keepalive_thread(session_id, stream)
2041
2551
  Thread.new do
2042
- while session_active_with_stream?(session_id)
2552
+ ended = false
2553
+
2554
+ while session_holds_stream?(session_id, stream)
2555
+ # Checked ahead of each tick so the stream does not outlive the token that opened it.
2556
+ # Revocation is not re-checked: the reference SDKs verify a stream at open only.
2557
+ # An expired token ends the stream alone: every other request of the session is verified on its own,
2558
+ # and the client may reopen the stream with a fresh token.
2559
+ if get_stream_token_expired?(session_id)
2560
+ close_get_stream(session_id, stream)
2561
+ break
2562
+ end
2563
+
2043
2564
  sleep(30)
2044
- send_keepalive_ping(session_id)
2565
+
2566
+ send_keepalive_ping(session_id, stream)
2045
2567
  end
2568
+
2569
+ ended = true
2046
2570
  rescue StandardError => e
2047
2571
  MCP.configuration.exception_reporter.call(e, { session_id: session_id })
2048
2572
  ensure
2049
- cleanup_session(session_id)
2573
+ drop_broken_stream(session_id, stream, nil) unless ended
2050
2574
  end
2051
2575
  end
2052
2576
 
2053
- def session_active_with_stream?(session_id)
2054
- @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
2577
+ def session_holds_stream?(session_id, stream)
2578
+ @mutex.synchronize do
2579
+ session = @sessions[session_id]
2580
+ !session.nil? && session[:get_sse_stream].equal?(stream)
2581
+ end
2055
2582
  end
2056
2583
 
2057
- def send_keepalive_ping(session_id)
2058
- # Resolve the stream under the lock, then write outside it so a stalled reader
2059
- # cannot block every other session on `@mutex`.
2060
- stream = @mutex.synchronize do
2584
+ def get_stream_token_expired?(session_id)
2585
+ expires_at = @mutex.synchronize do
2586
+ session = @sessions[session_id]
2587
+ session && session[:get_sse_stream_expires_at]
2588
+ end
2589
+
2590
+ token_expiry_passed?(expires_at)
2591
+ end
2592
+
2593
+ # Detaches and closes the session's GET stream when it is still `stream`, leaving the session in place.
2594
+ def close_get_stream(session_id, stream)
2595
+ detached = @mutex.synchronize do
2061
2596
  session = @sessions[session_id]
2062
- session && session[:get_sse_stream]
2597
+ next unless session && session[:get_sse_stream].equal?(stream)
2598
+
2599
+ detach_get_stream(session)
2063
2600
  end
2064
- return unless stream
2601
+
2602
+ close_stream_safely(detached) if detached
2603
+ end
2604
+
2605
+ # Removes the GET stream and its deadline from `session` under `@mutex` and returns the stream for closing
2606
+ # outside the lock.
2607
+ def detach_get_stream(session)
2608
+ session.delete(:get_sse_stream_expires_at)
2609
+ session.delete(:get_sse_stream)
2610
+ end
2611
+
2612
+ # Pings `stream` only while the session still holds it, checked under the lock and written outside it so
2613
+ # a stalled reader cannot block every other session on `@mutex`: a thread whose stream was detached during
2614
+ # its sleep must not write to the one the client opened since, which has a keepalive thread of its own.
2615
+ def send_keepalive_ping(session_id, stream)
2616
+ return unless session_holds_stream?(session_id, stream)
2065
2617
 
2066
2618
  send_ping_to_stream(stream)
2067
2619
  rescue *STREAM_WRITE_ERRORS => e