mcp 1.7.0 → 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.
@@ -121,6 +121,18 @@ module MCP
121
121
  # 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream.
122
122
  DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15
123
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
+
124
136
  # Creates a Streamable HTTP transport that can be mounted as a Rack app.
125
137
  #
126
138
  # @param server [MCP::Server] the server whose requests this transport dispatches.
@@ -176,6 +188,28 @@ module MCP
176
188
  # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
177
189
  # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
178
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.
179
213
  def initialize(
180
214
  server,
181
215
  stateless: false,
@@ -192,7 +226,13 @@ module MCP
192
226
  max_resource_subscription_bytes: DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES,
193
227
  listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
194
228
  serve_subscriptions_listen: true,
195
- 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
196
236
  )
197
237
  super(server)
198
238
  # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
@@ -285,6 +325,32 @@ module MCP
285
325
 
286
326
  @server_to_client_request_timeout = server_to_client_request_timeout
287
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
288
354
  start_reaper_thread if @session_idle_timeout
289
355
  end
290
356
 
@@ -293,6 +359,11 @@ module MCP
293
359
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
294
360
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
295
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
296
367
 
297
368
  # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
298
369
  # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
@@ -339,6 +410,11 @@ module MCP
339
410
  rebinding_error = validate_dns_rebinding(request)
340
411
  return rebinding_error if rebinding_error
341
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
+
342
418
  # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
343
419
  # every supported list routes to the sessionless modern path, so an unknown future version receives
344
420
  # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
@@ -386,8 +462,28 @@ module MCP
386
462
  end
387
463
  end
388
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.
389
473
  def close
390
- @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?
391
487
  @reaper_thread = nil
392
488
 
393
489
  teardown_listen_subscriptions
@@ -459,7 +555,7 @@ module MCP
459
555
  next
460
556
  end
461
557
 
462
- active_stream(session, related_request_id: related_request_id)
558
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
463
559
  end
464
560
 
465
561
  close_streams(streams_to_close)
@@ -476,14 +572,16 @@ module MCP
476
572
  expired_session_ids = []
477
573
 
478
574
  collected = @sessions.filter_map do |session_id, session|
479
- next unless (stream = session[:get_sse_stream])
575
+ next unless session[:get_sse_stream]
480
576
 
481
577
  if session_expired?(session)
482
578
  expired_session_ids << session_id
483
579
  next
484
580
  end
485
581
 
486
- [session_id, stream]
582
+ stream = active_stream(session, streams_to_close)
583
+
584
+ [session_id, stream] if stream
487
585
  end
488
586
 
489
587
  expired_session_ids.each do |session_id|
@@ -514,6 +612,11 @@ module MCP
514
612
  # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
515
613
  # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
516
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.
517
620
  def drop_broken_stream(session_id, stream, related_request_id)
518
621
  streams_to_close = []
519
622
 
@@ -527,7 +630,7 @@ module MCP
527
630
  session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
528
631
 
529
632
  streams_to_close << stream
530
- else
633
+ elsif session && session[:get_sse_stream].equal?(stream)
531
634
  cleanup_and_collect_stream(session_id, streams_to_close)
532
635
  end
533
636
  end
@@ -585,6 +688,7 @@ module MCP
585
688
 
586
689
  # Register the pending response and resolve the stream under the lock, but perform
587
690
  # the write outside it so a stalled reader cannot block every other session on `@mutex`.
691
+ streams_to_close = []
588
692
  stream = @mutex.synchronize do
589
693
  unless (session = @sessions[session_id])
590
694
  raise "Session not found: #{session_id}."
@@ -592,9 +696,11 @@ module MCP
592
696
 
593
697
  @pending_responses[request_id] = { queue: pending_response, session_id: session_id }
594
698
 
595
- active_stream(session, related_request_id: related_request_id)
699
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
596
700
  end
597
701
 
702
+ close_streams(streams_to_close)
703
+
598
704
  sent = false
599
705
  if stream
600
706
  begin
@@ -695,10 +801,18 @@ module MCP
695
801
 
696
802
  private
697
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.
698
806
  def start_reaper_thread
699
807
  @reaper_thread = Thread.new do
700
808
  loop do
701
- 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
+
702
816
  reap_expired_sessions
703
817
  rescue StandardError => e
704
818
  MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
@@ -797,13 +911,13 @@ module MCP
797
911
  # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
798
912
  # serve an open SSE stream needs, instead of a `Proc` body it can never call.
799
913
  if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
800
- return handle_subscriptions_listen(body)
914
+ return handle_subscriptions_listen(body, auth_info: request.env[OAuth::ENV_KEY])
801
915
  end
802
916
 
803
917
  session = modern_session
804
918
  notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
805
919
  begin
806
- response = @server.handle(body, session: session)
920
+ response = @server.handle(body, session: session, auth_info: request.env[OAuth::ENV_KEY])
807
921
  ensure
808
922
  @mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
809
923
  end
@@ -895,7 +1009,7 @@ module MCP
895
1009
  # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
896
1010
  # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
897
1011
  # `listen_keepalive_interval` seconds so a dropped connection frees its slot.
898
- def handle_subscriptions_listen(body)
1012
+ def handle_subscriptions_listen(body, auth_info: nil)
899
1013
  request_id = body[:id]
900
1014
  params = body[:params]
901
1015
 
@@ -968,7 +1082,7 @@ module MCP
968
1082
  )
969
1083
  end
970
1084
 
971
- [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement)]
1085
+ [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement, auth_info)]
972
1086
  end
973
1087
 
974
1088
  # The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
@@ -1049,7 +1163,7 @@ module MCP
1049
1163
  # The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
1050
1164
  # the requesting client's own in-flight requests, and two clients that pick the same one must each get
1051
1165
  # their stream, stamped with the id they sent.
1052
- def listen_sse_body(request_id, honored, acknowledgement)
1166
+ def listen_sse_body(request_id, honored, acknowledgement, auth_info = nil)
1053
1167
  ListenStreamBody.new do |stream|
1054
1168
  subscription_key = SecureRandom.uuid
1055
1169
  subscription = nil
@@ -1062,9 +1176,12 @@ module MCP
1062
1176
 
1063
1177
  @mutex.synchronize do
1064
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.
1065
1181
  subscription = {
1066
1182
  request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
1067
1183
  write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
1184
+ expires_at: stream_token_expiry(auth_info),
1068
1185
  }
1069
1186
  @listen_subscriptions[subscription_key] = subscription
1070
1187
  end
@@ -1090,14 +1207,11 @@ module MCP
1090
1207
  true
1091
1208
  end
1092
1209
 
1093
- if acknowledged
1094
- start_listen_keepalive_thread(subscription_key, request_id)
1095
- else
1096
- close_removed_listen_stream(subscription)
1097
- 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
1098
1213
  rescue *STREAM_WRITE_ERRORS
1099
- remove_listen_subscription(subscription_key)
1100
- close_removed_listen_stream(subscription)
1214
+ close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
1101
1215
  end
1102
1216
  end
1103
1217
  end
@@ -1121,10 +1235,15 @@ module MCP
1121
1235
  registered = @mutex.synchronize do
1122
1236
  subscription = @listen_subscriptions[subscription_key]
1123
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])
1124
1241
 
1125
- 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]))
1126
1245
 
1127
- @listen_subscriptions.key?(subscription_key)
1246
+ @listen_subscriptions.key?(subscription_key) && !token_expiry_passed?(subscription[:expires_at])
1128
1247
  end
1129
1248
  break unless registered
1130
1249
 
@@ -1160,6 +1279,40 @@ module MCP
1160
1279
  end
1161
1280
  end
1162
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
+
1163
1316
  # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
1164
1317
  # and the acknowledgement only includes types the server actually supports
1165
1318
  # (derived from its declared capabilities).
@@ -1224,7 +1377,25 @@ module MCP
1224
1377
  # nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
1225
1378
  # An inactive entry has not finished writing its acknowledgement yet;
1226
1379
  # delivering to it would put a notification ahead of the acknowledgement.
1227
- candidates = @mutex.synchronize { @listen_subscriptions.select { |_key, subscription| subscription[:active] } }
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
1393
+
1394
+ expired_keys.each do |key|
1395
+ subscription = remove_listen_subscription(key)
1396
+
1397
+ close_removed_listen_stream(subscription) if subscription
1398
+ end
1228
1399
 
1229
1400
  uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1230
1401
  matched = candidates.select do |_key, subscription|
@@ -1251,8 +1422,9 @@ module MCP
1251
1422
  e,
1252
1423
  { subscription_id: subscription[:request_id], error: "Failed to send notification" },
1253
1424
  )
1254
- remove_listen_subscription(subscription_key)
1255
- close_removed_listen_stream(subscription)
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)
1256
1428
  end
1257
1429
  end
1258
1430
  end
@@ -1282,8 +1454,14 @@ module MCP
1282
1454
  end
1283
1455
 
1284
1456
  # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1285
- # before the stream closes.
1286
- 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)
1287
1465
  removed = @mutex.synchronize do
1288
1466
  subscriptions = @listen_subscriptions.dup
1289
1467
  @listen_subscriptions.clear
@@ -1294,35 +1472,72 @@ module MCP
1294
1472
 
1295
1473
  subscriptions
1296
1474
  end
1475
+ return if removed.empty?
1297
1476
 
1298
- removed.each_value do |subscription|
1299
- # Marking the entry closed and writing the result under the stream's write mutex orders
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.
1302
- subscription[:write_mutex].synchronize do
1303
- 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
1304
1487
 
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]
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
1308
1493
 
1309
- begin
1310
- send_to_stream(subscription[:stream], {
1311
- jsonrpc: "2.0",
1312
- id: subscription[:request_id],
1313
- result: {
1314
- # `SubscriptionsListenResult` is served at the transport layer and never
1315
- # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1316
- # stamped at its construction site.
1317
- resultType: ResultType::COMPLETE,
1318
- _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1319
- },
1320
- })
1321
- rescue *STREAM_WRITE_ERRORS
1322
- nil
1323
- 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
1324
1528
  end
1325
- 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
1326
1541
  end
1327
1542
  end
1328
1543
 
@@ -1447,7 +1662,15 @@ module MCP
1447
1662
  # one. In the live case, reject with `-32600` so the original session is not abandoned.
1448
1663
  # In the unknown/expired case, return 404 so the client retries from scratch instead
1449
1664
  # of silently inheriting a fresh session under the old ID.
1450
- 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
1451
1674
 
1452
1675
  return session_not_found_response
1453
1676
  end
@@ -1457,8 +1680,10 @@ module MCP
1457
1680
  # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
1458
1681
  # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
1459
1682
  # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
1460
- if !@stateless && session_id && !validate_session_request(request, session_id)
1461
- return forbidden_response
1683
+ if !@stateless && session_id
1684
+ rejection = session_request_rejection(request, session_id)
1685
+
1686
+ return rejection if rejection
1462
1687
  end
1463
1688
 
1464
1689
  if notification?(body)
@@ -1467,14 +1692,14 @@ module MCP
1467
1692
  # branches; without it a custom notification handler could run without a live session.
1468
1693
  return session_not_found_response if !@stateless && !session_active?(session_id)
1469
1694
 
1470
- dispatch_notification(body_string, session_id)
1695
+ dispatch_notification(body_string, session_id, auth_info: request.env[OAuth::ENV_KEY])
1471
1696
  handle_accepted
1472
1697
  elsif response?(body)
1473
1698
  return session_not_found_response if !@stateless && !session_exists?(session_id)
1474
1699
 
1475
1700
  handle_response(body, session_id: session_id)
1476
1701
  else
1477
- 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])
1478
1703
  end
1479
1704
  end
1480
1705
  rescue StandardError => e
@@ -1498,16 +1723,20 @@ module MCP
1498
1723
 
1499
1724
  return missing_session_id_response unless session_id
1500
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
+
1501
1731
  error_response = validate_and_touch_session(session_id)
1502
1732
  return error_response if error_response
1503
- return forbidden_response unless validate_session_request(request, session_id)
1504
1733
 
1505
1734
  protocol_version_error = validate_protocol_version_header(request)
1506
1735
  return protocol_version_error if protocol_version_error
1507
1736
 
1508
- return session_already_connected_response if get_session_stream(session_id)
1737
+ return session_already_connected_response if live_get_stream?(session_id)
1509
1738
 
1510
- setup_sse_stream(session_id)
1739
+ setup_sse_stream(session_id, request.env[OAuth::ENV_KEY])
1511
1740
  end
1512
1741
 
1513
1742
  def handle_delete(request)
@@ -1523,16 +1752,22 @@ module MCP
1523
1752
 
1524
1753
  return missing_session_id_response unless (session_id = extract_session_id(request))
1525
1754
  return session_not_found_response unless session_exists?(session_id)
1526
- return forbidden_response unless validate_session_request(request, session_id)
1755
+ rejection = session_request_rejection(request, session_id)
1756
+ return rejection if rejection
1527
1757
 
1528
1758
  protocol_version_error = validate_protocol_version_header(request)
1529
1759
  return protocol_version_error if protocol_version_error
1530
1760
 
1531
- 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)
1532
1765
 
1533
1766
  success_response
1534
1767
  end
1535
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.
1536
1771
  def cleanup_session(session_id)
1537
1772
  session = @mutex.synchronize do
1538
1773
  cleanup_session_unsafe(session_id)
@@ -1542,6 +1777,8 @@ module MCP
1542
1777
  close_stream_safely(session[:get_sse_stream])
1543
1778
  close_post_request_streams(session)
1544
1779
  end
1780
+
1781
+ session
1545
1782
  end
1546
1783
 
1547
1784
  # Removes a session from `@sessions` and returns it. Does not close the stream.
@@ -1587,23 +1824,48 @@ module MCP
1587
1824
 
1588
1825
  # Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
1589
1826
  # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
1590
- # 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:
1591
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.
1592
1835
  # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
1593
- # 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,
1594
1837
  # so non-browser clients that send no `Origin` are unaffected.
1595
- # - The application-supplied `session_request_validator`, which can enforce true ownership when it has
1596
- # an authenticated principal.
1597
- 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)
1598
1843
  session = @mutex.synchronize { @sessions[session_id] }
1599
- return true unless session
1844
+ return unless session
1845
+ return session_not_found_response unless session_principal_matches?(session, request)
1600
1846
 
1601
1847
  session_origin = session[:origin]
1602
1848
  request_origin = request.env["HTTP_ORIGIN"]
1603
- return false if session_origin && request_origin && session_origin != request_origin
1604
- 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
1605
1850
 
1606
- 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]
1607
1869
  end
1608
1870
 
1609
1871
  def validate_accept_header(request, required_types)
@@ -1779,7 +2041,7 @@ module MCP
1779
2041
 
1780
2042
  # Dispatches a client-originated notification (e.g. `notifications/cancelled`,
1781
2043
  # `notifications/initialized`) through the server so it can update session state.
1782
- def dispatch_notification(body_string, session_id)
2044
+ def dispatch_notification(body_string, session_id, auth_info: nil)
1783
2045
  server_session = nil
1784
2046
  if @stateless
1785
2047
  server_session = ephemeral_session
@@ -1790,7 +2052,7 @@ module MCP
1790
2052
  end
1791
2053
  end
1792
2054
 
1793
- dispatch_handle_json(body_string, server_session)
2055
+ dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1794
2056
  rescue => e
1795
2057
  MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
1796
2058
  end
@@ -1819,6 +2081,7 @@ module MCP
1819
2081
 
1820
2082
  def handle_initialization(request, body_string, body)
1821
2083
  session_id = nil
2084
+ auth_info = request.env[OAuth::ENV_KEY]
1822
2085
 
1823
2086
  if @stateless
1824
2087
  server_session = ephemeral_session
@@ -1844,9 +2107,15 @@ module MCP
1844
2107
  get_sse_stream: nil,
1845
2108
  server_session: server_session,
1846
2109
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1847
- # 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`.
1848
2111
  # Not authentication.
1849
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,
1850
2119
  }
1851
2120
  true
1852
2121
  end
@@ -1855,7 +2124,7 @@ module MCP
1855
2124
  return too_many_sessions_response unless inserted
1856
2125
  end
1857
2126
 
1858
- response = server_session.handle_json(body_string)
2127
+ response = server_session.handle_json(body_string, auth_info: auth_info)
1859
2128
 
1860
2129
  # `initialize_request?` matches on the method alone, so an `initialize` sent without
1861
2130
  # an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
@@ -1898,7 +2167,7 @@ module MCP
1898
2167
  )
1899
2168
  end
1900
2169
 
1901
- 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)
1902
2171
  server_session = nil
1903
2172
 
1904
2173
  if @stateless
@@ -1922,9 +2191,9 @@ module MCP
1922
2191
  end
1923
2192
 
1924
2193
  if session_id && !@stateless && !@enable_json_response
1925
- 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)
1926
2195
  else
1927
- response = dispatch_handle_json(body_string, server_session)
2196
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1928
2197
 
1929
2198
  # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
1930
2199
  # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
@@ -1938,7 +2207,7 @@ module MCP
1938
2207
  # Returns the POST response as an SSE stream so the server can send
1939
2208
  # JSON-RPC requests and notifications during request processing.
1940
2209
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
1941
- 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)
1942
2211
  body = proc do |stream|
1943
2212
  @mutex.synchronize do
1944
2213
  session = @sessions[session_id]
@@ -1953,7 +2222,7 @@ module MCP
1953
2222
  end
1954
2223
 
1955
2224
  begin
1956
- response = dispatch_handle_json(body_string, server_session)
2225
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1957
2226
 
1958
2227
  send_to_stream(stream, response) if response
1959
2228
  ensure
@@ -1983,20 +2252,25 @@ module MCP
1983
2252
  # When `related_request_id` is given, returns only the POST response
1984
2253
  # stream for that request (no fallback to GET SSE). This prevents
1985
2254
  # request-scoped messages from leaking to the wrong stream.
1986
- # When `related_request_id` is nil, returns the GET SSE stream.
1987
- def active_stream(session, related_request_id: nil)
1988
- if related_request_id
1989
- session.dig(:post_request_streams, related_request_id)
1990
- else
1991
- session[:get_sse_stream]
1992
- 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
1993
2267
  end
1994
2268
 
1995
- def dispatch_handle_json(body_string, server_session)
2269
+ def dispatch_handle_json(body_string, server_session, auth_info: nil)
1996
2270
  if server_session
1997
- server_session.handle_json(body_string)
2271
+ server_session.handle_json(body_string, auth_info: auth_info)
1998
2272
  else
1999
- @server.handle_json(body_string)
2273
+ @server.handle_json(body_string, auth_info: auth_info)
2000
2274
  end
2001
2275
  end
2002
2276
 
@@ -2027,8 +2301,26 @@ module MCP
2027
2301
  response
2028
2302
  end
2029
2303
 
2030
- def get_session_stream(session_id)
2031
- @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
2032
2324
  end
2033
2325
 
2034
2326
  def session_exists?(session_id)
@@ -2067,6 +2359,27 @@ module MCP
2067
2359
  active
2068
2360
  end
2069
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
+
2070
2383
  # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
2071
2384
  # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
2072
2385
  # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
@@ -2193,59 +2506,114 @@ module MCP
2193
2506
  )
2194
2507
  end
2195
2508
 
2196
- def setup_sse_stream(session_id)
2197
- 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)
2198
2511
 
2199
2512
  [200, SSE_HEADERS.dup, body]
2200
2513
  end
2201
2514
 
2202
- def create_sse_body(session_id)
2515
+ def create_sse_body(session_id, auth_info = nil)
2203
2516
  proc do |stream|
2204
- stored = store_stream_for_session(session_id, stream)
2205
- 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
2206
2519
  end
2207
2520
  end
2208
2521
 
2209
- def store_stream_for_session(session_id, stream)
2210
- @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
2211
2526
  session = @sessions[session_id]
2212
- if session && !session[:get_sse_stream]
2213
- session[:get_sse_stream] = stream
2214
- else
2215
- # Either session was removed, or another request already established a stream.
2216
- stream.close
2217
- # `stream.close` may return a truthy value depending on the stream class.
2218
- # Explicitly return nil to guarantee a falsy return for callers.
2219
- nil
2220
- 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
2221
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
2222
2543
  end
2223
2544
 
2224
- 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)
2225
2551
  Thread.new do
2226
- 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
+
2227
2564
  sleep(30)
2228
- send_keepalive_ping(session_id)
2565
+
2566
+ send_keepalive_ping(session_id, stream)
2229
2567
  end
2568
+
2569
+ ended = true
2230
2570
  rescue StandardError => e
2231
2571
  MCP.configuration.exception_reporter.call(e, { session_id: session_id })
2232
2572
  ensure
2233
- cleanup_session(session_id)
2573
+ drop_broken_stream(session_id, stream, nil) unless ended
2234
2574
  end
2235
2575
  end
2236
2576
 
2237
- def session_active_with_stream?(session_id)
2238
- @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
2239
2582
  end
2240
2583
 
2241
- def send_keepalive_ping(session_id)
2242
- # Resolve the stream under the lock, then write outside it so a stalled reader
2243
- # cannot block every other session on `@mutex`.
2244
- stream = @mutex.synchronize do
2584
+ def get_stream_token_expired?(session_id)
2585
+ expires_at = @mutex.synchronize do
2245
2586
  session = @sessions[session_id]
2246
- session && session[:get_sse_stream]
2587
+ session && session[:get_sse_stream_expires_at]
2247
2588
  end
2248
- return unless stream
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
2596
+ session = @sessions[session_id]
2597
+ next unless session && session[:get_sse_stream].equal?(stream)
2598
+
2599
+ detach_get_stream(session)
2600
+ end
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)
2249
2617
 
2250
2618
  send_ping_to_stream(stream)
2251
2619
  rescue *STREAM_WRITE_ERRORS => e