mcp 1.7.0 → 1.8.1

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:`.
@@ -335,10 +406,21 @@ module MCP
335
406
  @serve_subscriptions_listen
336
407
  end
337
408
 
409
+ # Stateless mode has no session streams to deliver notifications on (`send_notification` reports non-delivery),
410
+ # so `Server#init` advertises no `listChanged`/`subscribe` flags there.
411
+ def delivers_notifications?
412
+ !@stateless
413
+ end
414
+
338
415
  def handle_request(request)
339
416
  rebinding_error = validate_dns_rebinding(request)
340
417
  return rebinding_error if rebinding_error
341
418
 
419
+ # Bearer enforcement runs after the DNS-rebinding rejection (which must stay the cheapest gate and
420
+ # never trigger verifier work) and before any body read, so an unauthenticated request costs no parsing.
421
+ auth_error = authenticate_request(request)
422
+ return auth_error if auth_error
423
+
342
424
  # Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
343
425
  # every supported list routes to the sessionless modern path, so an unknown future version receives
344
426
  # the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
@@ -386,8 +468,28 @@ module MCP
386
468
  end
387
469
  end
388
470
 
471
+ # The reaper is asked to stop and joined before it is killed: `reap_expired_sessions` removes expired sessions
472
+ # under `@mutex` and closes their streams after releasing it, and a kill landing between the two would leave
473
+ # those streams open with no session left to find them by. Waking the reaper ends its wait at once, and the join
474
+ # lets a reap already past its removal finish its closes before the rest of the transport is torn down.
475
+ # The join is bounded, so a stream whose close never returns cannot hold the shutdown, and a reaper that died
476
+ # with a `StandardError` does not stop the teardown below: `join` re-raises the exception a thread died with,
477
+ # which is not this method's to raise. A reaper still alive after the wait is killed as before, leaving
478
+ # the streams of that reap it had not closed yet as they were.
389
479
  def close
390
- @reaper_thread&.kill
480
+ reaper = @mutex.synchronize do
481
+ @closing = true
482
+ @reaper_wakeup.signal
483
+ @reaper_thread
484
+ end
485
+
486
+ begin
487
+ reaper&.join(REAPER_JOIN_TIMEOUT)
488
+ rescue StandardError
489
+ # The reaper is gone either way; the exception that ended it is not this method's to report.
490
+ end
491
+
492
+ reaper.kill if reaper&.alive?
391
493
  @reaper_thread = nil
392
494
 
393
495
  teardown_listen_subscriptions
@@ -459,7 +561,7 @@ module MCP
459
561
  next
460
562
  end
461
563
 
462
- active_stream(session, related_request_id: related_request_id)
564
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
463
565
  end
464
566
 
465
567
  close_streams(streams_to_close)
@@ -476,14 +578,16 @@ module MCP
476
578
  expired_session_ids = []
477
579
 
478
580
  collected = @sessions.filter_map do |session_id, session|
479
- next unless (stream = session[:get_sse_stream])
581
+ next unless session[:get_sse_stream]
480
582
 
481
583
  if session_expired?(session)
482
584
  expired_session_ids << session_id
483
585
  next
484
586
  end
485
587
 
486
- [session_id, stream]
588
+ stream = active_stream(session, streams_to_close)
589
+
590
+ [session_id, stream] if stream
487
591
  end
488
592
 
489
593
  expired_session_ids.each do |session_id|
@@ -514,6 +618,11 @@ module MCP
514
618
  # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
515
619
  # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
516
620
  # under `@mutex`, and the affected streams are closed outside it.
621
+ #
622
+ # Only the GET stream the session still holds takes the session down with it, judged under the same hold of
623
+ # the lock that removes the session: the failed write may have been picked for a stream that was detached since,
624
+ # because its token expired and the client reopened, and the stream opened in its place must stay. A failed
625
+ # stream the session no longer holds was closed by whichever path detached it, so there is nothing left to do.
517
626
  def drop_broken_stream(session_id, stream, related_request_id)
518
627
  streams_to_close = []
519
628
 
@@ -527,7 +636,7 @@ module MCP
527
636
  session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
528
637
 
529
638
  streams_to_close << stream
530
- else
639
+ elsif session && session[:get_sse_stream].equal?(stream)
531
640
  cleanup_and_collect_stream(session_id, streams_to_close)
532
641
  end
533
642
  end
@@ -585,6 +694,7 @@ module MCP
585
694
 
586
695
  # Register the pending response and resolve the stream under the lock, but perform
587
696
  # the write outside it so a stalled reader cannot block every other session on `@mutex`.
697
+ streams_to_close = []
588
698
  stream = @mutex.synchronize do
589
699
  unless (session = @sessions[session_id])
590
700
  raise "Session not found: #{session_id}."
@@ -592,9 +702,11 @@ module MCP
592
702
 
593
703
  @pending_responses[request_id] = { queue: pending_response, session_id: session_id }
594
704
 
595
- active_stream(session, related_request_id: related_request_id)
705
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
596
706
  end
597
707
 
708
+ close_streams(streams_to_close)
709
+
598
710
  sent = false
599
711
  if stream
600
712
  begin
@@ -695,10 +807,18 @@ module MCP
695
807
 
696
808
  private
697
809
 
810
+ # The wait between reaps is a condition variable wait under `@mutex` rather than a sleep, so `close` can end it
811
+ # by signalling; a spurious wakeup only runs a reap early.
698
812
  def start_reaper_thread
699
813
  @reaper_thread = Thread.new do
700
814
  loop do
701
- sleep(SESSION_REAP_INTERVAL)
815
+ closing = @mutex.synchronize do
816
+ @reaper_wakeup.wait(@mutex, SESSION_REAP_INTERVAL) unless @closing
817
+ @closing
818
+ end
819
+
820
+ break if closing
821
+
702
822
  reap_expired_sessions
703
823
  rescue StandardError => e
704
824
  MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
@@ -797,13 +917,13 @@ module MCP
797
917
  # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
798
918
  # serve an open SSE stream needs, instead of a `Proc` body it can never call.
799
919
  if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
800
- return handle_subscriptions_listen(body)
920
+ return handle_subscriptions_listen(body, auth_info: request.env[OAuth::ENV_KEY])
801
921
  end
802
922
 
803
923
  session = modern_session
804
924
  notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
805
925
  begin
806
- response = @server.handle(body, session: session)
926
+ response = @server.handle(body, session: session, auth_info: request.env[OAuth::ENV_KEY])
807
927
  ensure
808
928
  @mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
809
929
  end
@@ -895,7 +1015,7 @@ module MCP
895
1015
  # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
896
1016
  # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
897
1017
  # `listen_keepalive_interval` seconds so a dropped connection frees its slot.
898
- def handle_subscriptions_listen(body)
1018
+ def handle_subscriptions_listen(body, auth_info: nil)
899
1019
  request_id = body[:id]
900
1020
  params = body[:params]
901
1021
 
@@ -906,7 +1026,7 @@ module MCP
906
1026
 
907
1027
  # The id is echoed in every message of the stream, so one that cannot be written back as JSON
908
1028
  # (a String holding bytes that are not valid UTF-8 parses, but does not generate) is refused here,
909
- # while the refusal can still answer with a null id, rather than surfacing as a failed write after
1029
+ # while the refusal can still answer without an id, rather than surfacing as a failed write after
910
1030
  # the stream has been registered.
911
1031
  unless json_encodable?(request_id)
912
1032
  return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
@@ -968,7 +1088,7 @@ module MCP
968
1088
  )
969
1089
  end
970
1090
 
971
- [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement)]
1091
+ [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement, auth_info)]
972
1092
  end
973
1093
 
974
1094
  # The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
@@ -1049,7 +1169,7 @@ module MCP
1049
1169
  # The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
1050
1170
  # the requesting client's own in-flight requests, and two clients that pick the same one must each get
1051
1171
  # their stream, stamped with the id they sent.
1052
- def listen_sse_body(request_id, honored, acknowledgement)
1172
+ def listen_sse_body(request_id, honored, acknowledgement, auth_info = nil)
1053
1173
  ListenStreamBody.new do |stream|
1054
1174
  subscription_key = SecureRandom.uuid
1055
1175
  subscription = nil
@@ -1062,9 +1182,12 @@ module MCP
1062
1182
 
1063
1183
  @mutex.synchronize do
1064
1184
  unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
1185
+ # The expiry of the token that authenticated the listen request is kept with the stream
1186
+ # so the keepalive loop and the delivery path can close the stream once that token expires.
1065
1187
  subscription = {
1066
1188
  request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
1067
1189
  write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
1190
+ expires_at: stream_token_expiry(auth_info),
1068
1191
  }
1069
1192
  @listen_subscriptions[subscription_key] = subscription
1070
1193
  end
@@ -1090,14 +1213,11 @@ module MCP
1090
1213
  true
1091
1214
  end
1092
1215
 
1093
- if acknowledged
1094
- start_listen_keepalive_thread(subscription_key, request_id)
1095
- else
1096
- close_removed_listen_stream(subscription)
1097
- end
1216
+ # An acknowledgement skipped because the entry was marked closed leaves the stream to the path that marked
1217
+ # it, the teardown, which closes the stream itself once it holds the write mutex.
1218
+ start_listen_keepalive_thread(subscription_key, request_id) if acknowledged
1098
1219
  rescue *STREAM_WRITE_ERRORS
1099
- remove_listen_subscription(subscription_key)
1100
- close_removed_listen_stream(subscription)
1220
+ close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
1101
1221
  end
1102
1222
  end
1103
1223
  end
@@ -1121,10 +1241,15 @@ module MCP
1121
1241
  registered = @mutex.synchronize do
1122
1242
  subscription = @listen_subscriptions[subscription_key]
1123
1243
  next false unless subscription
1244
+ # Checked ahead of each tick so the stream does not outlive the token that opened it.
1245
+ # Revocation is not re-checked: the reference SDKs verify a stream at open only.
1246
+ next false if token_expiry_passed?(subscription[:expires_at])
1124
1247
 
1125
- subscription[:keepalive_wakeup].wait(@mutex, @listen_keepalive_interval)
1248
+ # The wait is cut short at the stream's deadline, so the stream ends at that deadline rather than
1249
+ # up to one interval after it, and a deadline that passed during the wait ends the loop before the ping.
1250
+ subscription[:keepalive_wakeup].wait(@mutex, listen_keepalive_wait(subscription[:expires_at]))
1126
1251
 
1127
- @listen_subscriptions.key?(subscription_key)
1252
+ @listen_subscriptions.key?(subscription_key) && !token_expiry_passed?(subscription[:expires_at])
1128
1253
  end
1129
1254
  break unless registered
1130
1255
 
@@ -1160,6 +1285,40 @@ module MCP
1160
1285
  end
1161
1286
  end
1162
1287
 
1288
+ # The deadline at which a stream authenticated by `auth_info` is closed: whichever comes first of the token's own
1289
+ # expiry and `max_stream_lifetime:` seconds from now. Only the deadline is retained with the stream,
1290
+ # never the `AccessToken` itself, which would park the raw bearer credential in `@sessions` or `@listen_subscriptions`
1291
+ # for the life of the stream. The cap is what bounds a stream whose token reports no expiry (`exp` is optional in
1292
+ # an RFC 7662 introspection response), so "a stream does not outlive its credential" holds even when the credential
1293
+ # never says when it ends. A stream opened without a token is not capped: there is no credential to bound,
1294
+ # and capping it would change a transport used without `token_verifier:`.
1295
+ def stream_token_expiry(auth_info)
1296
+ return unless auth_info
1297
+
1298
+ deadlines = []
1299
+ token_expiry = auth_info.expires_at if auth_info.respond_to?(:expires_at)
1300
+ # A custom verifier that breaks the `AccessToken` contract with an expiry that is not a number must not fail
1301
+ # the stream while it is being set up: the value is ignored and only the cap applies.
1302
+ deadlines << token_expiry if token_expiry.is_a?(Numeric)
1303
+ deadlines << Time.now.to_i + @max_stream_lifetime if @max_stream_lifetime
1304
+
1305
+ deadlines.min
1306
+ end
1307
+
1308
+ # Same rule as `AccessToken#expired?`: no expiry means the token never expires here.
1309
+ def token_expiry_passed?(expires_at)
1310
+ !expires_at.nil? && expires_at <= Time.now.to_i
1311
+ end
1312
+
1313
+ # Seconds the listen keepalive thread waits before its next tick: the interval, or the time left until
1314
+ # the stream's deadline when that comes first. The floor keeps a deadline a few microseconds away from
1315
+ # turning into a zero-length wait.
1316
+ def listen_keepalive_wait(expires_at)
1317
+ return @listen_keepalive_interval if expires_at.nil?
1318
+
1319
+ [[expires_at - Time.now.to_f, LISTEN_DEADLINE_WAIT_FLOOR].max, @listen_keepalive_interval].min
1320
+ end
1321
+
1163
1322
  # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
1164
1323
  # and the acknowledgement only includes types the server actually supports
1165
1324
  # (derived from its declared capabilities).
@@ -1224,7 +1383,25 @@ module MCP
1224
1383
  # nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
1225
1384
  # An inactive entry has not finished writing its acknowledgement yet;
1226
1385
  # delivering to it would put a notification ahead of the acknowledgement.
1227
- candidates = @mutex.synchronize { @listen_subscriptions.select { |_key, subscription| subscription[:active] } }
1386
+ # An active entry whose token deadline has passed is closed here instead of delivered to, so the deadline
1387
+ # holds on a transport whose keepalive is disabled as well. An expired entry still inactive is skipped
1388
+ # like any inactive one and caught by the next delivery once its acknowledgement is written.
1389
+ expired_keys = []
1390
+ candidates = @mutex.synchronize do
1391
+ @listen_subscriptions.select do |key, subscription|
1392
+ next false unless subscription[:active]
1393
+ next true unless token_expiry_passed?(subscription[:expires_at])
1394
+
1395
+ expired_keys << key
1396
+ false
1397
+ end
1398
+ end
1399
+
1400
+ expired_keys.each do |key|
1401
+ subscription = remove_listen_subscription(key)
1402
+
1403
+ close_removed_listen_stream(subscription) if subscription
1404
+ end
1228
1405
 
1229
1406
  uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1230
1407
  matched = candidates.select do |_key, subscription|
@@ -1251,8 +1428,9 @@ module MCP
1251
1428
  e,
1252
1429
  { subscription_id: subscription[:request_id], error: "Failed to send notification" },
1253
1430
  )
1254
- remove_listen_subscription(subscription_key)
1255
- close_removed_listen_stream(subscription)
1431
+ # Closed only when the removal is this path's own, as every other removal path does: the keepalive loop,
1432
+ # a token expiry, or the teardown that removed the entry first closes the stream itself.
1433
+ close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
1256
1434
  end
1257
1435
  end
1258
1436
  end
@@ -1282,8 +1460,14 @@ module MCP
1282
1460
  end
1283
1461
 
1284
1462
  # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1285
- # before the stream closes.
1286
- def teardown_listen_subscriptions
1463
+ # before the stream closes. The results are written on a helper thread that is waited for at most `timeout`
1464
+ # seconds in all, because a peer that stopped reading stalls the write of its result, or a delivery already
1465
+ # holding the stream's write mutex, for as long as its socket buffers stay full. Once the time is up, every stream
1466
+ # still open is closed from here: closing a Ruby IO ends a write stalled on it with an `IOError` the helper rescues,
1467
+ # while a stream object of another kind may keep the helper blocked, which no longer holds `close`. Either way
1468
+ # the stream goes without its result, like one whose acknowledgement was never written. The helper is left
1469
+ # to finish on its own rather than killed, so a write mutex is never dropped in the middle of a write.
1470
+ def teardown_listen_subscriptions(timeout: LISTEN_TEARDOWN_TIMEOUT)
1287
1471
  removed = @mutex.synchronize do
1288
1472
  subscriptions = @listen_subscriptions.dup
1289
1473
  @listen_subscriptions.clear
@@ -1294,35 +1478,72 @@ module MCP
1294
1478
 
1295
1479
  subscriptions
1296
1480
  end
1481
+ return if removed.empty?
1297
1482
 
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
1483
+ # Whichever side takes an entry out of `pending` closes its stream: the helper after the write, or `close` once
1484
+ # the time is up. Claiming before closing keeps a stream from being closed by both in either order.
1485
+ pending = removed.dup
1486
+ pending_mutex = Mutex.new
1487
+ writer = Thread.new do
1488
+ removed.each do |subscription_key, subscription|
1489
+ write_listen_closing_result(subscription)
1490
+ close_stream_safely(subscription[:stream]) if pending_mutex.synchronize { pending.delete(subscription_key) }
1491
+ end
1492
+ end
1304
1493
 
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]
1494
+ begin
1495
+ writer.join(timeout)
1496
+ rescue StandardError
1497
+ # A helper that died with an exception has stopped writing; the streams it did not finish are closed below.
1498
+ end
1308
1499
 
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
1500
+ left = pending_mutex.synchronize do
1501
+ subscriptions = pending.values
1502
+ pending.clear
1503
+ subscriptions
1504
+ end
1505
+ left.each { |subscription| close_stream_safely(subscription[:stream]) }
1506
+ end
1507
+
1508
+ # Marking the entry closed and writing the result under the stream's write mutex orders
1509
+ # this against in-flight deliveries and against the acknowledgement write: each one either lands
1510
+ # before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
1511
+ # The stream is left open for the caller to close.
1512
+ def write_listen_closing_result(subscription)
1513
+ subscription[:write_mutex].synchronize do
1514
+ subscription[:closed] = true
1515
+
1516
+ # A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
1517
+ # the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
1518
+ next unless subscription[:active]
1519
+
1520
+ begin
1521
+ send_to_stream(subscription[:stream], {
1522
+ jsonrpc: "2.0",
1523
+ id: subscription[:request_id],
1524
+ result: {
1525
+ # `SubscriptionsListenResult` is served at the transport layer and never
1526
+ # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1527
+ # stamped at its construction site.
1528
+ resultType: ResultType::COMPLETE,
1529
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1530
+ },
1531
+ })
1532
+ rescue *STREAM_WRITE_ERRORS
1533
+ nil
1324
1534
  end
1325
- close_stream_safely(subscription[:stream])
1535
+ end
1536
+ rescue StandardError => e
1537
+ # Reported rather than raised: on the helper thread an error would end the teardown of every stream after this one,
1538
+ # and `close` is past waiting for it by the time a stalled write fails. A reporter that raises is dropped for
1539
+ # the same reason.
1540
+ begin
1541
+ MCP.configuration.exception_reporter.call(
1542
+ e,
1543
+ { subscription_id: subscription[:request_id], error: "Failed to close listen stream" },
1544
+ )
1545
+ rescue StandardError
1546
+ nil
1326
1547
  end
1327
1548
  end
1328
1549
 
@@ -1447,7 +1668,15 @@ module MCP
1447
1668
  # one. In the live case, reject with `-32600` so the original session is not abandoned.
1448
1669
  # In the unknown/expired case, return 404 so the client retries from scratch instead
1449
1670
  # of silently inheriting a fresh session under the old ID.
1450
- return already_initialized_response(body[:id]) if session_active?(session_id)
1671
+ # The live case is told only to the principal the session is bound to: for any other token the answer
1672
+ # is the 404 an unknown session gets, as on every other route, so a guessed session id is not confirmed
1673
+ # to exist by an `initialize` either.
1674
+ if session_active?(session_id)
1675
+ session = @mutex.synchronize { @sessions[session_id] }
1676
+ return session_not_found_response unless session && session_principal_matches?(session, request)
1677
+
1678
+ return already_initialized_response(body[:id])
1679
+ end
1451
1680
 
1452
1681
  return session_not_found_response
1453
1682
  end
@@ -1457,8 +1686,10 @@ module MCP
1457
1686
  # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
1458
1687
  # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
1459
1688
  # 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
1689
+ if !@stateless && session_id
1690
+ rejection = session_request_rejection(request, session_id)
1691
+
1692
+ return rejection if rejection
1462
1693
  end
1463
1694
 
1464
1695
  if notification?(body)
@@ -1467,14 +1698,14 @@ module MCP
1467
1698
  # branches; without it a custom notification handler could run without a live session.
1468
1699
  return session_not_found_response if !@stateless && !session_active?(session_id)
1469
1700
 
1470
- dispatch_notification(body_string, session_id)
1701
+ dispatch_notification(body_string, session_id, auth_info: request.env[OAuth::ENV_KEY])
1471
1702
  handle_accepted
1472
1703
  elsif response?(body)
1473
1704
  return session_not_found_response if !@stateless && !session_exists?(session_id)
1474
1705
 
1475
1706
  handle_response(body, session_id: session_id)
1476
1707
  else
1477
- handle_regular_request(body_string, session_id, related_request_id: body[:id])
1708
+ handle_regular_request(body_string, session_id, related_request_id: body[:id], auth_info: request.env[OAuth::ENV_KEY])
1478
1709
  end
1479
1710
  end
1480
1711
  rescue StandardError => e
@@ -1498,16 +1729,20 @@ module MCP
1498
1729
 
1499
1730
  return missing_session_id_response unless session_id
1500
1731
 
1732
+ # 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.
1733
+ # 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.
1734
+ rejection = session_request_rejection(request, session_id)
1735
+ return rejection if rejection
1736
+
1501
1737
  error_response = validate_and_touch_session(session_id)
1502
1738
  return error_response if error_response
1503
- return forbidden_response unless validate_session_request(request, session_id)
1504
1739
 
1505
1740
  protocol_version_error = validate_protocol_version_header(request)
1506
1741
  return protocol_version_error if protocol_version_error
1507
1742
 
1508
- return session_already_connected_response if get_session_stream(session_id)
1743
+ return session_already_connected_response if live_get_stream?(session_id)
1509
1744
 
1510
- setup_sse_stream(session_id)
1745
+ setup_sse_stream(session_id, request.env[OAuth::ENV_KEY])
1511
1746
  end
1512
1747
 
1513
1748
  def handle_delete(request)
@@ -1523,16 +1758,22 @@ module MCP
1523
1758
 
1524
1759
  return missing_session_id_response unless (session_id = extract_session_id(request))
1525
1760
  return session_not_found_response unless session_exists?(session_id)
1526
- return forbidden_response unless validate_session_request(request, session_id)
1761
+ rejection = session_request_rejection(request, session_id)
1762
+ return rejection if rejection
1527
1763
 
1528
1764
  protocol_version_error = validate_protocol_version_header(request)
1529
1765
  return protocol_version_error if protocol_version_error
1530
1766
 
1531
- cleanup_session(session_id)
1767
+ # The session may be gone by now, removed by the reaper or by the owner's own DELETE since the checks above:
1768
+ # a request that terminated nothing answers like one naming an unknown session, so a removal it did not cause
1769
+ # confirms nothing about the session having existed.
1770
+ return session_not_found_response unless cleanup_session(session_id)
1532
1771
 
1533
1772
  success_response
1534
1773
  end
1535
1774
 
1775
+ # Removes the session, closes its streams outside the lock, and returns the removed session, or nil when
1776
+ # there was none to remove.
1536
1777
  def cleanup_session(session_id)
1537
1778
  session = @mutex.synchronize do
1538
1779
  cleanup_session_unsafe(session_id)
@@ -1542,6 +1783,8 @@ module MCP
1542
1783
  close_stream_safely(session[:get_sse_stream])
1543
1784
  close_post_request_streams(session)
1544
1785
  end
1786
+
1787
+ session
1545
1788
  end
1546
1789
 
1547
1790
  # Removes a session from `@sessions` and returns it. Does not close the stream.
@@ -1587,23 +1830,48 @@ module MCP
1587
1830
 
1588
1831
  # Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
1589
1832
  # 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:
1833
+ # Three layers, each answering with the rejection response it returns. The built-in layers run before
1834
+ # the custom validator so a permissive `session_request_validator` cannot bypass them, and the principal
1835
+ # binding runs first of all, so that another principal gets the 404 whatever else its request carries:
1591
1836
  #
1837
+ # - Built-in principal binding (active when bearer authentication is in play): a session initialized under
1838
+ # one token identity refuses requests verified as a different one. The answer is the same 404 an unknown session gets,
1839
+ # so a guessed session ID is not confirmed to exist; it carries no `WWW-Authenticate` challenge either,
1840
+ # because the token itself is valid and re-authorization would not help.
1592
1841
  # - 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,
1842
+ # at `initialize` and this request carries a different one, reject with 403. Both must be present to compare,
1594
1843
  # 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)
1844
+ # - The application-supplied `session_request_validator`, which can enforce ownership policy beyond the built-in layers;
1845
+ # a falsy return rejects with 403.
1846
+ #
1847
+ # Returns nil when the request may proceed.
1848
+ def session_request_rejection(request, session_id)
1598
1849
  session = @mutex.synchronize { @sessions[session_id] }
1599
- return true unless session
1850
+ return unless session
1851
+ return session_not_found_response unless session_principal_matches?(session, request)
1600
1852
 
1601
1853
  session_origin = session[:origin]
1602
1854
  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
1855
+ return forbidden_response if session_origin && request_origin && session_origin != request_origin
1605
1856
 
1606
- true
1857
+ if @session_request_validator && !@session_request_validator.call(request, session_id)
1858
+ return forbidden_response
1859
+ end
1860
+
1861
+ nil
1862
+ end
1863
+
1864
+ # A session bound to a principal at `initialize` only accepts requests verified as the same `issuer`, `subject`,
1865
+ # and `client_id` triple (client-credentials tokens without a `sub` bind on `client_id` alone; nil members compare equal).
1866
+ # The issuer takes part so a subject and client id pair repeated across identity providers behind a custom verifier
1867
+ # does not collide. A token whose subject and client id are both nil records no binding, because there is no identity to compare.
1868
+ def session_principal_matches?(session, request)
1869
+ return true if session[:auth_subject].nil? && session[:auth_client_id].nil?
1870
+
1871
+ token = request.env[OAuth::ENV_KEY]
1872
+ return false if token.nil?
1873
+
1874
+ token.issuer == session[:auth_issuer] && token.subject == session[:auth_subject] && token.client_id == session[:auth_client_id]
1607
1875
  end
1608
1876
 
1609
1877
  def validate_accept_header(request, required_types)
@@ -1619,10 +1887,10 @@ module MCP
1619
1887
  nil
1620
1888
  end
1621
1889
 
1890
+ # Empty list members (`application/json,,text/event-stream`), which RFC 9110 section 5.6.1.2 has
1891
+ # a recipient parse and ignore, are skipped.
1622
1892
  def parse_accept_header(header)
1623
- header.split(",").map do |part|
1624
- part.split(";").first.strip.downcase
1625
- end
1893
+ header.split(",").map { |part| part.split(";").first.to_s.strip.downcase }.reject(&:empty?)
1626
1894
  end
1627
1895
 
1628
1896
  def validate_content_type(request)
@@ -1712,7 +1980,8 @@ module MCP
1712
1980
  # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
1713
1981
  # a subclass of `JSON::ParserError`, so it is caught below as a parse error.
1714
1982
  JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
1715
- rescue JSON::ParserError, TypeError
1983
+ # `symbolize_names` raises `EncodingError`, not `JSON::ParserError`, for a key that is not valid UTF-8.
1984
+ rescue JSON::ParserError, TypeError, EncodingError
1716
1985
  raise InvalidJsonError
1717
1986
  end
1718
1987
 
@@ -1766,20 +2035,26 @@ module MCP
1766
2035
  )
1767
2036
  end
1768
2037
 
2038
+ # The body leaves the `id` member out when there is no request id to echo, as the MCP schema allows
2039
+ # only a string or an integer there.
1769
2040
  def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
1770
2041
  error = { code: code, message: message }
1771
2042
  error[:data] = data if data
1772
- body = { jsonrpc: "2.0", id: id, error: error }
2043
+ body = { jsonrpc: "2.0" }
2044
+ body[:id] = id unless id.nil?
2045
+ body[:error] = error
1773
2046
  [status, { "content-type" => "application/json" }, [body.to_json]]
1774
2047
  end
1775
2048
 
2049
+ # A notification carries no id member at all; a message with `"id": null` is a malformed request,
2050
+ # which the server answers with Invalid Request instead of running it unanswered.
1776
2051
  def notification?(body)
1777
- !body[:id] && !!body[:method]
2052
+ !body.key?(:id) && !!body[:method]
1778
2053
  end
1779
2054
 
1780
2055
  # Dispatches a client-originated notification (e.g. `notifications/cancelled`,
1781
2056
  # `notifications/initialized`) through the server so it can update session state.
1782
- def dispatch_notification(body_string, session_id)
2057
+ def dispatch_notification(body_string, session_id, auth_info: nil)
1783
2058
  server_session = nil
1784
2059
  if @stateless
1785
2060
  server_session = ephemeral_session
@@ -1790,7 +2065,7 @@ module MCP
1790
2065
  end
1791
2066
  end
1792
2067
 
1793
- dispatch_handle_json(body_string, server_session)
2068
+ dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1794
2069
  rescue => e
1795
2070
  MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
1796
2071
  end
@@ -1819,6 +2094,7 @@ module MCP
1819
2094
 
1820
2095
  def handle_initialization(request, body_string, body)
1821
2096
  session_id = nil
2097
+ auth_info = request.env[OAuth::ENV_KEY]
1822
2098
 
1823
2099
  if @stateless
1824
2100
  server_session = ephemeral_session
@@ -1844,9 +2120,15 @@ module MCP
1844
2120
  get_sse_stream: nil,
1845
2121
  server_session: server_session,
1846
2122
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1847
- # Captured for the built-in Origin-consistency defense in `validate_session_request`.
2123
+ # Captured for the built-in Origin-consistency defense in `session_request_rejection`.
1848
2124
  # Not authentication.
1849
2125
  origin: request.env["HTTP_ORIGIN"],
2126
+ # Principal binding per the spec's session-binding guidance: the session is bound to the token identity that initialized it,
2127
+ # and `session_request_rejection` rejects later requests presenting a token for a different principal. All three fields are nil
2128
+ # when the request was not bearer-authenticated, which disables the check.
2129
+ auth_issuer: auth_info&.issuer,
2130
+ auth_subject: auth_info&.subject,
2131
+ auth_client_id: auth_info&.client_id,
1850
2132
  }
1851
2133
  true
1852
2134
  end
@@ -1855,13 +2137,13 @@ module MCP
1855
2137
  return too_many_sessions_response unless inserted
1856
2138
  end
1857
2139
 
1858
- response = server_session.handle_json(body_string)
2140
+ response = server_session.handle_json(body_string, auth_info: auth_info)
1859
2141
 
1860
2142
  # `initialize_request?` matches on the method alone, so an `initialize` sent without
1861
- # an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
1862
- # but JSON-RPC emits no response for an id-less request, so `response` is nil.
1863
- # Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize),
1864
- # and the session would be retained since it is marked initialized. Discard the orphaned session
2143
+ # an id (framed as a notification) reaches here. `Server#init` does not run for it, as for
2144
+ # any request method without an id, so `response` is nil and the session created above
2145
+ # is never initialized. Returning `[200, ..., [nil]]` would place nil in the Rack body (which
2146
+ # the web server cannot serialize), and the session would linger uninitialized. Discard it
1865
2147
  # and ack with 202, mirroring the nil-response handling in a regular request.
1866
2148
  if response.nil?
1867
2149
  cleanup_session(session_id) if session_id
@@ -1898,7 +2180,7 @@ module MCP
1898
2180
  )
1899
2181
  end
1900
2182
 
1901
- def handle_regular_request(body_string, session_id, related_request_id: nil)
2183
+ def handle_regular_request(body_string, session_id, related_request_id: nil, auth_info: nil)
1902
2184
  server_session = nil
1903
2185
 
1904
2186
  if @stateless
@@ -1922,9 +2204,9 @@ module MCP
1922
2204
  end
1923
2205
 
1924
2206
  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)
2207
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id, auth_info: auth_info)
1926
2208
  else
1927
- response = dispatch_handle_json(body_string, server_session)
2209
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1928
2210
 
1929
2211
  # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
1930
2212
  # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
@@ -1938,7 +2220,7 @@ module MCP
1938
2220
  # Returns the POST response as an SSE stream so the server can send
1939
2221
  # JSON-RPC requests and notifications during request processing.
1940
2222
  # 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)
2223
+ def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil, auth_info: nil)
1942
2224
  body = proc do |stream|
1943
2225
  @mutex.synchronize do
1944
2226
  session = @sessions[session_id]
@@ -1953,7 +2235,7 @@ module MCP
1953
2235
  end
1954
2236
 
1955
2237
  begin
1956
- response = dispatch_handle_json(body_string, server_session)
2238
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1957
2239
 
1958
2240
  send_to_stream(stream, response) if response
1959
2241
  ensure
@@ -1983,20 +2265,25 @@ module MCP
1983
2265
  # When `related_request_id` is given, returns only the POST response
1984
2266
  # stream for that request (no fallback to GET SSE). This prevents
1985
2267
  # 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
2268
+ # When `related_request_id` is nil, returns the GET SSE stream, or nil once the token that opened it
2269
+ # has expired: that stream is detached from the session and added to `streams_to_close` instead, so its
2270
+ # deadline holds at the next write and not only at the keepalive tick. Called under `@mutex`; the caller
2271
+ # closes the collected streams outside it.
2272
+ def active_stream(session, streams_to_close, related_request_id: nil)
2273
+ return session.dig(:post_request_streams, related_request_id) if related_request_id
2274
+
2275
+ stream = session[:get_sse_stream]
2276
+ return stream unless stream && token_expiry_passed?(session[:get_sse_stream_expires_at])
2277
+
2278
+ streams_to_close << detach_get_stream(session)
2279
+ nil
1993
2280
  end
1994
2281
 
1995
- def dispatch_handle_json(body_string, server_session)
2282
+ def dispatch_handle_json(body_string, server_session, auth_info: nil)
1996
2283
  if server_session
1997
- server_session.handle_json(body_string)
2284
+ server_session.handle_json(body_string, auth_info: auth_info)
1998
2285
  else
1999
- @server.handle_json(body_string)
2286
+ @server.handle_json(body_string, auth_info: auth_info)
2000
2287
  end
2001
2288
  end
2002
2289
 
@@ -2027,8 +2314,26 @@ module MCP
2027
2314
  response
2028
2315
  end
2029
2316
 
2030
- def get_session_stream(session_id)
2031
- @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
2317
+ # Whether the session holds a GET stream whose deadline has not passed. A stream whose token expired may still
2318
+ # be attached between its last delivery and its next keepalive tick; it is detached and closed here, so a client
2319
+ # reopening the stream with a fresh token is not turned away until one of those noticed.
2320
+ def live_get_stream?(session_id)
2321
+ detached = nil
2322
+ live = @mutex.synchronize do
2323
+ session = @sessions[session_id]
2324
+ stream = session && session[:get_sse_stream]
2325
+ if stream.nil?
2326
+ false
2327
+ elsif token_expiry_passed?(session[:get_sse_stream_expires_at])
2328
+ detached = detach_get_stream(session)
2329
+ false
2330
+ else
2331
+ true
2332
+ end
2333
+ end
2334
+
2335
+ close_stream_safely(detached) if detached
2336
+ live
2032
2337
  end
2033
2338
 
2034
2339
  def session_exists?(session_id)
@@ -2067,6 +2372,27 @@ module MCP
2067
2372
  active
2068
2373
  end
2069
2374
 
2375
+ # Built-in OAuth 2.1 bearer enforcement (active when `token_verifier:` is configured).
2376
+ # Returns nil on success, storing the verified `AccessToken` in `env["mcp.auth_info"]` where the dispatch paths
2377
+ # and `session_request_rejection` read it; returns the RFC 6750 challenge response on failure. OPTIONS is exempt:
2378
+ # CORS preflights never carry credentials, and rejecting them here would fail CORS closed for browser clients
2379
+ # while the transport still answers the preflight itself with 405 (an upstream CORS middleware normally intercepts it).
2380
+ def authenticate_request(request)
2381
+ return if @oauth_authenticator.nil?
2382
+ return if request.env["REQUEST_METHOD"] == "OPTIONS"
2383
+
2384
+ request.env[OAuth::ENV_KEY] = @oauth_authenticator.authenticate(request.env)
2385
+ nil
2386
+ rescue OAuth::Error => e
2387
+ @oauth_authenticator.challenge_response(e)
2388
+ rescue => e
2389
+ # A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure:
2390
+ # report it and answer 500 without a challenge, so the client does not discard a perfectly good token.
2391
+ MCP.configuration.exception_reporter.call(e, { transport: self.class.name })
2392
+
2393
+ [500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]]
2394
+ end
2395
+
2070
2396
  # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
2071
2397
  # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
2072
2398
  # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
@@ -2164,15 +2490,12 @@ module MCP
2164
2490
  end
2165
2491
 
2166
2492
  def invalid_request_response(message, request_id: nil)
2167
- body = {
2168
- jsonrpc: "2.0",
2493
+ json_rpc_error_response(
2494
+ status: 400,
2495
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
2496
+ message: message,
2169
2497
  id: request_id,
2170
- error: {
2171
- code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
2172
- message: message,
2173
- },
2174
- }
2175
- [400, { "content-type" => "application/json" }, [body.to_json]]
2498
+ )
2176
2499
  end
2177
2500
 
2178
2501
  def session_already_connected_response
@@ -2193,59 +2516,114 @@ module MCP
2193
2516
  )
2194
2517
  end
2195
2518
 
2196
- def setup_sse_stream(session_id)
2197
- body = create_sse_body(session_id)
2519
+ def setup_sse_stream(session_id, auth_info = nil)
2520
+ body = create_sse_body(session_id, auth_info)
2198
2521
 
2199
2522
  [200, SSE_HEADERS.dup, body]
2200
2523
  end
2201
2524
 
2202
- def create_sse_body(session_id)
2525
+ def create_sse_body(session_id, auth_info = nil)
2203
2526
  proc do |stream|
2204
- stored = store_stream_for_session(session_id, stream)
2205
- start_keepalive_thread(session_id) if stored
2527
+ stored = store_stream_for_session(session_id, stream, auth_info)
2528
+ start_keepalive_thread(session_id, stream) if stored
2206
2529
  end
2207
2530
  end
2208
2531
 
2209
- def store_stream_for_session(session_id, stream)
2210
- @mutex.synchronize do
2532
+ # The expiry of the token that authenticated the GET is kept with the stream so the keepalive loop and
2533
+ # the delivery paths can close the stream once that token expires.
2534
+ def store_stream_for_session(session_id, stream, auth_info = nil)
2535
+ stored = @mutex.synchronize do
2211
2536
  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
2537
+ next false unless session && !session[:get_sse_stream]
2538
+
2539
+ session[:get_sse_stream] = stream
2540
+ session[:get_sse_stream_expires_at] = stream_token_expiry(auth_info)
2541
+ true
2221
2542
  end
2543
+
2544
+ # The expiry is nil for a stream opened without a token, and callers read a falsy return as "not stored",
2545
+ # so the stream itself is the return value.
2546
+ return stream if stored
2547
+
2548
+ # Either the session was removed, or another request already established a stream. The refused stream is
2549
+ # closed outside the lock like every other stream this transport closes, so a close that blocks on the peer
2550
+ # cannot hold every other session on `@mutex`.
2551
+ close_stream_safely(stream)
2552
+ nil
2222
2553
  end
2223
2554
 
2224
- def start_keepalive_thread(session_id)
2555
+ # The thread acts on the stream it was started for and on no other: the session may have detached that
2556
+ # stream at a delivery once its token expired and the client may have opened a new one since, which gets
2557
+ # a keepalive thread of its own. The loop therefore ends as soon as the session no longer holds this stream,
2558
+ # and a loop that did not run to its end (a failed ping, or any other abnormal exit) drops the stream through
2559
+ # `drop_broken_stream`, which tears the session down only while it still holds that stream, as before.
2560
+ def start_keepalive_thread(session_id, stream)
2225
2561
  Thread.new do
2226
- while session_active_with_stream?(session_id)
2562
+ ended = false
2563
+
2564
+ while session_holds_stream?(session_id, stream)
2565
+ # Checked ahead of each tick so the stream does not outlive the token that opened it.
2566
+ # Revocation is not re-checked: the reference SDKs verify a stream at open only.
2567
+ # An expired token ends the stream alone: every other request of the session is verified on its own,
2568
+ # and the client may reopen the stream with a fresh token.
2569
+ if get_stream_token_expired?(session_id)
2570
+ close_get_stream(session_id, stream)
2571
+ break
2572
+ end
2573
+
2227
2574
  sleep(30)
2228
- send_keepalive_ping(session_id)
2575
+
2576
+ send_keepalive_ping(session_id, stream)
2229
2577
  end
2578
+
2579
+ ended = true
2230
2580
  rescue StandardError => e
2231
2581
  MCP.configuration.exception_reporter.call(e, { session_id: session_id })
2232
2582
  ensure
2233
- cleanup_session(session_id)
2583
+ drop_broken_stream(session_id, stream, nil) unless ended
2234
2584
  end
2235
2585
  end
2236
2586
 
2237
- def session_active_with_stream?(session_id)
2238
- @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
2587
+ def session_holds_stream?(session_id, stream)
2588
+ @mutex.synchronize do
2589
+ session = @sessions[session_id]
2590
+ !session.nil? && session[:get_sse_stream].equal?(stream)
2591
+ end
2239
2592
  end
2240
2593
 
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
2594
+ def get_stream_token_expired?(session_id)
2595
+ expires_at = @mutex.synchronize do
2596
+ session = @sessions[session_id]
2597
+ session && session[:get_sse_stream_expires_at]
2598
+ end
2599
+
2600
+ token_expiry_passed?(expires_at)
2601
+ end
2602
+
2603
+ # Detaches and closes the session's GET stream when it is still `stream`, leaving the session in place.
2604
+ def close_get_stream(session_id, stream)
2605
+ detached = @mutex.synchronize do
2245
2606
  session = @sessions[session_id]
2246
- session && session[:get_sse_stream]
2607
+ next unless session && session[:get_sse_stream].equal?(stream)
2608
+
2609
+ detach_get_stream(session)
2247
2610
  end
2248
- return unless stream
2611
+
2612
+ close_stream_safely(detached) if detached
2613
+ end
2614
+
2615
+ # Removes the GET stream and its deadline from `session` under `@mutex` and returns the stream for closing
2616
+ # outside the lock.
2617
+ def detach_get_stream(session)
2618
+ session.delete(:get_sse_stream_expires_at)
2619
+ session.delete(:get_sse_stream)
2620
+ end
2621
+
2622
+ # Pings `stream` only while the session still holds it, checked under the lock and written outside it so
2623
+ # a stalled reader cannot block every other session on `@mutex`: a thread whose stream was detached during
2624
+ # its sleep must not write to the one the client opened since, which has a keepalive thread of its own.
2625
+ def send_keepalive_ping(session_id, stream)
2626
+ return unless session_holds_stream?(session_id, stream)
2249
2627
 
2250
2628
  send_ping_to_stream(stream)
2251
2629
  rescue *STREAM_WRITE_ERRORS => e