mcp 1.7.0 → 1.8.2

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
@@ -419,12 +521,19 @@ module MCP
419
521
  # a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
420
522
  # Checked before the stateless guard, since modern requests are served in stateless deployments too.
421
523
  # The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
422
- # non-delivery (`false`), never handed to the legacy paths below.
524
+ # non-delivery (`false`), never handed to the legacy paths below. It holds each notification as JSON,
525
+ # generated here so that one that cannot be generated is dropped on its own instead of failing
526
+ # the whole response when the sink is flushed.
423
527
  sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
424
528
  if sink
425
529
  return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
426
530
 
427
- sink << notification
531
+ begin
532
+ sink << notification.to_json
533
+ rescue JSON::GeneratorError => e
534
+ MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to generate notification" })
535
+ return false
536
+ end
428
537
  return true
429
538
  end
430
539
 
@@ -459,7 +568,7 @@ module MCP
459
568
  next
460
569
  end
461
570
 
462
- active_stream(session, related_request_id: related_request_id)
571
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
463
572
  end
464
573
 
465
574
  close_streams(streams_to_close)
@@ -476,14 +585,16 @@ module MCP
476
585
  expired_session_ids = []
477
586
 
478
587
  collected = @sessions.filter_map do |session_id, session|
479
- next unless (stream = session[:get_sse_stream])
588
+ next unless session[:get_sse_stream]
480
589
 
481
590
  if session_expired?(session)
482
591
  expired_session_ids << session_id
483
592
  next
484
593
  end
485
594
 
486
- [session_id, stream]
595
+ stream = active_stream(session, streams_to_close)
596
+
597
+ [session_id, stream] if stream
487
598
  end
488
599
 
489
600
  expired_session_ids.each do |session_id|
@@ -514,6 +625,11 @@ module MCP
514
625
  # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
515
626
  # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
516
627
  # under `@mutex`, and the affected streams are closed outside it.
628
+ #
629
+ # Only the GET stream the session still holds takes the session down with it, judged under the same hold of
630
+ # the lock that removes the session: the failed write may have been picked for a stream that was detached since,
631
+ # because its token expired and the client reopened, and the stream opened in its place must stay. A failed
632
+ # stream the session no longer holds was closed by whichever path detached it, so there is nothing left to do.
517
633
  def drop_broken_stream(session_id, stream, related_request_id)
518
634
  streams_to_close = []
519
635
 
@@ -527,7 +643,7 @@ module MCP
527
643
  session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
528
644
 
529
645
  streams_to_close << stream
530
- else
646
+ elsif session && session[:get_sse_stream].equal?(stream)
531
647
  cleanup_and_collect_stream(session_id, streams_to_close)
532
648
  end
533
649
  end
@@ -585,6 +701,7 @@ module MCP
585
701
 
586
702
  # Register the pending response and resolve the stream under the lock, but perform
587
703
  # the write outside it so a stalled reader cannot block every other session on `@mutex`.
704
+ streams_to_close = []
588
705
  stream = @mutex.synchronize do
589
706
  unless (session = @sessions[session_id])
590
707
  raise "Session not found: #{session_id}."
@@ -592,9 +709,11 @@ module MCP
592
709
 
593
710
  @pending_responses[request_id] = { queue: pending_response, session_id: session_id }
594
711
 
595
- active_stream(session, related_request_id: related_request_id)
712
+ active_stream(session, streams_to_close, related_request_id: related_request_id)
596
713
  end
597
714
 
715
+ close_streams(streams_to_close)
716
+
598
717
  sent = false
599
718
  if stream
600
719
  begin
@@ -695,10 +814,18 @@ module MCP
695
814
 
696
815
  private
697
816
 
817
+ # The wait between reaps is a condition variable wait under `@mutex` rather than a sleep, so `close` can end it
818
+ # by signalling; a spurious wakeup only runs a reap early.
698
819
  def start_reaper_thread
699
820
  @reaper_thread = Thread.new do
700
821
  loop do
701
- sleep(SESSION_REAP_INTERVAL)
822
+ closing = @mutex.synchronize do
823
+ @reaper_wakeup.wait(@mutex, SESSION_REAP_INTERVAL) unless @closing
824
+ @closing
825
+ end
826
+
827
+ break if closing
828
+
702
829
  reap_expired_sessions
703
830
  rescue StandardError => e
704
831
  MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
@@ -797,13 +924,13 @@ module MCP
797
924
  # to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
798
925
  # serve an open SSE stream needs, instead of a `Proc` body it can never call.
799
926
  if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
800
- return handle_subscriptions_listen(body)
927
+ return handle_subscriptions_listen(body, auth_info: request.env[OAuth::ENV_KEY])
801
928
  end
802
929
 
803
930
  session = modern_session
804
931
  notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
805
932
  begin
806
- response = @server.handle(body, session: session)
933
+ response = @server.handle(body, session: session, auth_info: request.env[OAuth::ENV_KEY])
807
934
  ensure
808
935
  @mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
809
936
  end
@@ -811,16 +938,26 @@ module MCP
811
938
  # `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
812
939
  return handle_accepted if response.nil?
813
940
 
941
+ # A response that cannot be generated answers its request with Internal error, as `JsonRpcHandler.handle_json`
942
+ # does on the legacy paths, instead of failing the whole exchange with a 500.
943
+ response_json = begin
944
+ response.to_json
945
+ rescue JSON::GeneratorError => e
946
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to generate response" })
947
+ response = JsonRpcHandler.encodable_response(response)
948
+ response.to_json
949
+ end
950
+
814
951
  # Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
815
952
  # the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
816
953
  # Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
817
954
  # (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
818
955
  # Live streaming can follow without changing the wire shape.
819
956
  if notifications.empty?
820
- [modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
957
+ [modern_http_status(response), { "content-type" => "application/json" }, [response_json]]
821
958
  else
822
- frames = notifications + [response]
823
- sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
959
+ frames = notifications + [response_json]
960
+ sse_body = frames.map { |frame| "event: message\ndata: #{frame}\n\n" }.join
824
961
  [200, SSE_HEADERS.dup, [sse_body]]
825
962
  end
826
963
  rescue StandardError => e
@@ -895,7 +1032,7 @@ module MCP
895
1032
  # (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
896
1033
  # response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
897
1034
  # `listen_keepalive_interval` seconds so a dropped connection frees its slot.
898
- def handle_subscriptions_listen(body)
1035
+ def handle_subscriptions_listen(body, auth_info: nil)
899
1036
  request_id = body[:id]
900
1037
  params = body[:params]
901
1038
 
@@ -906,7 +1043,7 @@ module MCP
906
1043
 
907
1044
  # The id is echoed in every message of the stream, so one that cannot be written back as JSON
908
1045
  # (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
1046
+ # while the refusal can still answer without an id, rather than surfacing as a failed write after
910
1047
  # the stream has been registered.
911
1048
  unless json_encodable?(request_id)
912
1049
  return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
@@ -914,7 +1051,7 @@ module MCP
914
1051
 
915
1052
  begin
916
1053
  if RequestEnvelope.modern?(params)
917
- RequestEnvelope.parse!(params, request: params)
1054
+ envelope = RequestEnvelope.parse!(params, request: params)
918
1055
  else
919
1056
  return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
920
1057
  end
@@ -954,7 +1091,7 @@ module MCP
954
1091
  return too_many_listen_subscriptions_response(request_id)
955
1092
  end
956
1093
 
957
- honored = honored_filter(filter)
1094
+ honored = honored_filter(filter, meta: params[:_meta], envelope: envelope, auth_info: auth_info)
958
1095
 
959
1096
  # The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
960
1097
  # so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
@@ -968,7 +1105,7 @@ module MCP
968
1105
  )
969
1106
  end
970
1107
 
971
- [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement)]
1108
+ [200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement, auth_info)]
972
1109
  end
973
1110
 
974
1111
  # The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
@@ -1049,7 +1186,7 @@ module MCP
1049
1186
  # The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
1050
1187
  # the requesting client's own in-flight requests, and two clients that pick the same one must each get
1051
1188
  # their stream, stamped with the id they sent.
1052
- def listen_sse_body(request_id, honored, acknowledgement)
1189
+ def listen_sse_body(request_id, honored, acknowledgement, auth_info = nil)
1053
1190
  ListenStreamBody.new do |stream|
1054
1191
  subscription_key = SecureRandom.uuid
1055
1192
  subscription = nil
@@ -1062,9 +1199,12 @@ module MCP
1062
1199
 
1063
1200
  @mutex.synchronize do
1064
1201
  unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
1202
+ # The expiry of the token that authenticated the listen request is kept with the stream
1203
+ # so the keepalive loop and the delivery path can close the stream once that token expires.
1065
1204
  subscription = {
1066
1205
  request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
1067
1206
  write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
1207
+ expires_at: stream_token_expiry(auth_info),
1068
1208
  }
1069
1209
  @listen_subscriptions[subscription_key] = subscription
1070
1210
  end
@@ -1090,14 +1230,11 @@ module MCP
1090
1230
  true
1091
1231
  end
1092
1232
 
1093
- if acknowledged
1094
- start_listen_keepalive_thread(subscription_key, request_id)
1095
- else
1096
- close_removed_listen_stream(subscription)
1097
- end
1233
+ # An acknowledgement skipped because the entry was marked closed leaves the stream to the path that marked
1234
+ # it, the teardown, which closes the stream itself once it holds the write mutex.
1235
+ start_listen_keepalive_thread(subscription_key, request_id) if acknowledged
1098
1236
  rescue *STREAM_WRITE_ERRORS
1099
- remove_listen_subscription(subscription_key)
1100
- close_removed_listen_stream(subscription)
1237
+ close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
1101
1238
  end
1102
1239
  end
1103
1240
  end
@@ -1121,10 +1258,15 @@ module MCP
1121
1258
  registered = @mutex.synchronize do
1122
1259
  subscription = @listen_subscriptions[subscription_key]
1123
1260
  next false unless subscription
1261
+ # Checked ahead of each tick so the stream does not outlive the token that opened it.
1262
+ # Revocation is not re-checked: the reference SDKs verify a stream at open only.
1263
+ next false if token_expiry_passed?(subscription[:expires_at])
1124
1264
 
1125
- subscription[:keepalive_wakeup].wait(@mutex, @listen_keepalive_interval)
1265
+ # The wait is cut short at the stream's deadline, so the stream ends at that deadline rather than
1266
+ # up to one interval after it, and a deadline that passed during the wait ends the loop before the ping.
1267
+ subscription[:keepalive_wakeup].wait(@mutex, listen_keepalive_wait(subscription[:expires_at]))
1126
1268
 
1127
- @listen_subscriptions.key?(subscription_key)
1269
+ @listen_subscriptions.key?(subscription_key) && !token_expiry_passed?(subscription[:expires_at])
1128
1270
  end
1129
1271
  break unless registered
1130
1272
 
@@ -1160,10 +1302,45 @@ module MCP
1160
1302
  end
1161
1303
  end
1162
1304
 
1305
+ # The deadline at which a stream authenticated by `auth_info` is closed: whichever comes first of the token's own
1306
+ # expiry and `max_stream_lifetime:` seconds from now. Only the deadline is retained with the stream,
1307
+ # never the `AccessToken` itself, which would park the raw bearer credential in `@sessions` or `@listen_subscriptions`
1308
+ # for the life of the stream. The cap is what bounds a stream whose token reports no expiry (`exp` is optional in
1309
+ # an RFC 7662 introspection response), so "a stream does not outlive its credential" holds even when the credential
1310
+ # never says when it ends. A stream opened without a token is not capped: there is no credential to bound,
1311
+ # and capping it would change a transport used without `token_verifier:`.
1312
+ def stream_token_expiry(auth_info)
1313
+ return unless auth_info
1314
+
1315
+ deadlines = []
1316
+ token_expiry = auth_info.expires_at if auth_info.respond_to?(:expires_at)
1317
+ # A custom verifier that breaks the `AccessToken` contract with an expiry that is not a number must not fail
1318
+ # the stream while it is being set up: the value is ignored and only the cap applies.
1319
+ deadlines << token_expiry if token_expiry.is_a?(Numeric)
1320
+ deadlines << Time.now.to_i + @max_stream_lifetime if @max_stream_lifetime
1321
+
1322
+ deadlines.min
1323
+ end
1324
+
1325
+ # Same rule as `AccessToken#expired?`: no expiry means the token never expires here.
1326
+ def token_expiry_passed?(expires_at)
1327
+ !expires_at.nil? && expires_at <= Time.now.to_i
1328
+ end
1329
+
1330
+ # Seconds the listen keepalive thread waits before its next tick: the interval, or the time left until
1331
+ # the stream's deadline when that comes first. The floor keeps a deadline a few microseconds away from
1332
+ # turning into a zero-length wait.
1333
+ def listen_keepalive_wait(expires_at)
1334
+ return @listen_keepalive_interval if expires_at.nil?
1335
+
1336
+ [[expires_at - Time.now.to_f, LISTEN_DEADLINE_WAIT_FLOOR].max, @listen_keepalive_interval].min
1337
+ end
1338
+
1163
1339
  # Per SEP-2575, the server MUST NOT send notification types the client has not requested,
1164
1340
  # and the acknowledgement only includes types the server actually supports
1165
- # (derived from its declared capabilities).
1166
- def honored_filter(filter)
1341
+ # (derived from its declared capabilities). Resource URIs are further narrowed to those
1342
+ # the server's `resources_subscribe_handler` accepts; the field is omitted when none remain.
1343
+ def honored_filter(filter, meta: nil, envelope: nil, auth_info: nil)
1167
1344
  capabilities = @server.capabilities
1168
1345
  honored = {}
1169
1346
  honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
@@ -1172,7 +1349,8 @@ module MCP
1172
1349
 
1173
1350
  subscriptions = filter[:resourceSubscriptions]
1174
1351
  if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
1175
- honored[:resourceSubscriptions] = subscriptions.uniq
1352
+ uris = @server.subscribable_resource_uris(subscriptions, meta: meta, envelope: envelope, auth_info: auth_info)
1353
+ honored[:resourceSubscriptions] = uris unless uris.empty?
1176
1354
  end
1177
1355
 
1178
1356
  honored
@@ -1224,7 +1402,25 @@ module MCP
1224
1402
  # nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
1225
1403
  # An inactive entry has not finished writing its acknowledgement yet;
1226
1404
  # delivering to it would put a notification ahead of the acknowledgement.
1227
- candidates = @mutex.synchronize { @listen_subscriptions.select { |_key, subscription| subscription[:active] } }
1405
+ # An active entry whose token deadline has passed is closed here instead of delivered to, so the deadline
1406
+ # holds on a transport whose keepalive is disabled as well. An expired entry still inactive is skipped
1407
+ # like any inactive one and caught by the next delivery once its acknowledgement is written.
1408
+ expired_keys = []
1409
+ candidates = @mutex.synchronize do
1410
+ @listen_subscriptions.select do |key, subscription|
1411
+ next false unless subscription[:active]
1412
+ next true unless token_expiry_passed?(subscription[:expires_at])
1413
+
1414
+ expired_keys << key
1415
+ false
1416
+ end
1417
+ end
1418
+
1419
+ expired_keys.each do |key|
1420
+ subscription = remove_listen_subscription(key)
1421
+
1422
+ close_removed_listen_stream(subscription) if subscription
1423
+ end
1228
1424
 
1229
1425
  uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
1230
1426
  matched = candidates.select do |_key, subscription|
@@ -1251,8 +1447,9 @@ module MCP
1251
1447
  e,
1252
1448
  { subscription_id: subscription[:request_id], error: "Failed to send notification" },
1253
1449
  )
1254
- remove_listen_subscription(subscription_key)
1255
- close_removed_listen_stream(subscription)
1450
+ # Closed only when the removal is this path's own, as every other removal path does: the keepalive loop,
1451
+ # a token expiry, or the teardown that removed the entry first closes the stream itself.
1452
+ close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
1256
1453
  end
1257
1454
  end
1258
1455
  end
@@ -1282,8 +1479,14 @@ module MCP
1282
1479
  end
1283
1480
 
1284
1481
  # Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
1285
- # before the stream closes.
1286
- def teardown_listen_subscriptions
1482
+ # before the stream closes. The results are written on a helper thread that is waited for at most `timeout`
1483
+ # seconds in all, because a peer that stopped reading stalls the write of its result, or a delivery already
1484
+ # holding the stream's write mutex, for as long as its socket buffers stay full. Once the time is up, every stream
1485
+ # still open is closed from here: closing a Ruby IO ends a write stalled on it with an `IOError` the helper rescues,
1486
+ # while a stream object of another kind may keep the helper blocked, which no longer holds `close`. Either way
1487
+ # the stream goes without its result, like one whose acknowledgement was never written. The helper is left
1488
+ # to finish on its own rather than killed, so a write mutex is never dropped in the middle of a write.
1489
+ def teardown_listen_subscriptions(timeout: LISTEN_TEARDOWN_TIMEOUT)
1287
1490
  removed = @mutex.synchronize do
1288
1491
  subscriptions = @listen_subscriptions.dup
1289
1492
  @listen_subscriptions.clear
@@ -1294,35 +1497,72 @@ module MCP
1294
1497
 
1295
1498
  subscriptions
1296
1499
  end
1500
+ return if removed.empty?
1297
1501
 
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
1502
+ # Whichever side takes an entry out of `pending` closes its stream: the helper after the write, or `close` once
1503
+ # the time is up. Claiming before closing keeps a stream from being closed by both in either order.
1504
+ pending = removed.dup
1505
+ pending_mutex = Mutex.new
1506
+ writer = Thread.new do
1507
+ removed.each do |subscription_key, subscription|
1508
+ write_listen_closing_result(subscription)
1509
+ close_stream_safely(subscription[:stream]) if pending_mutex.synchronize { pending.delete(subscription_key) }
1510
+ end
1511
+ end
1304
1512
 
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]
1513
+ begin
1514
+ writer.join(timeout)
1515
+ rescue StandardError
1516
+ # A helper that died with an exception has stopped writing; the streams it did not finish are closed below.
1517
+ end
1308
1518
 
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
1519
+ left = pending_mutex.synchronize do
1520
+ subscriptions = pending.values
1521
+ pending.clear
1522
+ subscriptions
1523
+ end
1524
+ left.each { |subscription| close_stream_safely(subscription[:stream]) }
1525
+ end
1526
+
1527
+ # Marking the entry closed and writing the result under the stream's write mutex orders
1528
+ # this against in-flight deliveries and against the acknowledgement write: each one either lands
1529
+ # before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
1530
+ # The stream is left open for the caller to close.
1531
+ def write_listen_closing_result(subscription)
1532
+ subscription[:write_mutex].synchronize do
1533
+ subscription[:closed] = true
1534
+
1535
+ # A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
1536
+ # the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
1537
+ next unless subscription[:active]
1538
+
1539
+ begin
1540
+ send_to_stream(subscription[:stream], {
1541
+ jsonrpc: "2.0",
1542
+ id: subscription[:request_id],
1543
+ result: {
1544
+ # `SubscriptionsListenResult` is served at the transport layer and never
1545
+ # passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
1546
+ # stamped at its construction site.
1547
+ resultType: ResultType::COMPLETE,
1548
+ _meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
1549
+ },
1550
+ })
1551
+ rescue *STREAM_WRITE_ERRORS
1552
+ nil
1324
1553
  end
1325
- close_stream_safely(subscription[:stream])
1554
+ end
1555
+ rescue StandardError => e
1556
+ # Reported rather than raised: on the helper thread an error would end the teardown of every stream after this one,
1557
+ # and `close` is past waiting for it by the time a stalled write fails. A reporter that raises is dropped for
1558
+ # the same reason.
1559
+ begin
1560
+ MCP.configuration.exception_reporter.call(
1561
+ e,
1562
+ { subscription_id: subscription[:request_id], error: "Failed to close listen stream" },
1563
+ )
1564
+ rescue StandardError
1565
+ nil
1326
1566
  end
1327
1567
  end
1328
1568
 
@@ -1447,7 +1687,15 @@ module MCP
1447
1687
  # one. In the live case, reject with `-32600` so the original session is not abandoned.
1448
1688
  # In the unknown/expired case, return 404 so the client retries from scratch instead
1449
1689
  # of silently inheriting a fresh session under the old ID.
1450
- return already_initialized_response(body[:id]) if session_active?(session_id)
1690
+ # The live case is told only to the principal the session is bound to: for any other token the answer
1691
+ # is the 404 an unknown session gets, as on every other route, so a guessed session id is not confirmed
1692
+ # to exist by an `initialize` either.
1693
+ if session_active?(session_id)
1694
+ session = @mutex.synchronize { @sessions[session_id] }
1695
+ return session_not_found_response unless session && session_principal_matches?(session, request)
1696
+
1697
+ return already_initialized_response(body[:id])
1698
+ end
1451
1699
 
1452
1700
  return session_not_found_response
1453
1701
  end
@@ -1457,8 +1705,10 @@ module MCP
1457
1705
  # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
1458
1706
  # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
1459
1707
  # 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
1708
+ if !@stateless && session_id
1709
+ rejection = session_request_rejection(request, session_id)
1710
+
1711
+ return rejection if rejection
1462
1712
  end
1463
1713
 
1464
1714
  if notification?(body)
@@ -1467,14 +1717,14 @@ module MCP
1467
1717
  # branches; without it a custom notification handler could run without a live session.
1468
1718
  return session_not_found_response if !@stateless && !session_active?(session_id)
1469
1719
 
1470
- dispatch_notification(body_string, session_id)
1720
+ dispatch_notification(body_string, session_id, auth_info: request.env[OAuth::ENV_KEY])
1471
1721
  handle_accepted
1472
1722
  elsif response?(body)
1473
1723
  return session_not_found_response if !@stateless && !session_exists?(session_id)
1474
1724
 
1475
1725
  handle_response(body, session_id: session_id)
1476
1726
  else
1477
- handle_regular_request(body_string, session_id, related_request_id: body[:id])
1727
+ handle_regular_request(body_string, session_id, related_request_id: body[:id], auth_info: request.env[OAuth::ENV_KEY])
1478
1728
  end
1479
1729
  end
1480
1730
  rescue StandardError => e
@@ -1498,16 +1748,20 @@ module MCP
1498
1748
 
1499
1749
  return missing_session_id_response unless session_id
1500
1750
 
1751
+ # 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.
1752
+ # 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.
1753
+ rejection = session_request_rejection(request, session_id)
1754
+ return rejection if rejection
1755
+
1501
1756
  error_response = validate_and_touch_session(session_id)
1502
1757
  return error_response if error_response
1503
- return forbidden_response unless validate_session_request(request, session_id)
1504
1758
 
1505
1759
  protocol_version_error = validate_protocol_version_header(request)
1506
1760
  return protocol_version_error if protocol_version_error
1507
1761
 
1508
- return session_already_connected_response if get_session_stream(session_id)
1762
+ return session_already_connected_response if live_get_stream?(session_id)
1509
1763
 
1510
- setup_sse_stream(session_id)
1764
+ setup_sse_stream(session_id, request.env[OAuth::ENV_KEY])
1511
1765
  end
1512
1766
 
1513
1767
  def handle_delete(request)
@@ -1523,16 +1777,22 @@ module MCP
1523
1777
 
1524
1778
  return missing_session_id_response unless (session_id = extract_session_id(request))
1525
1779
  return session_not_found_response unless session_exists?(session_id)
1526
- return forbidden_response unless validate_session_request(request, session_id)
1780
+ rejection = session_request_rejection(request, session_id)
1781
+ return rejection if rejection
1527
1782
 
1528
1783
  protocol_version_error = validate_protocol_version_header(request)
1529
1784
  return protocol_version_error if protocol_version_error
1530
1785
 
1531
- cleanup_session(session_id)
1786
+ # The session may be gone by now, removed by the reaper or by the owner's own DELETE since the checks above:
1787
+ # a request that terminated nothing answers like one naming an unknown session, so a removal it did not cause
1788
+ # confirms nothing about the session having existed.
1789
+ return session_not_found_response unless cleanup_session(session_id)
1532
1790
 
1533
1791
  success_response
1534
1792
  end
1535
1793
 
1794
+ # Removes the session, closes its streams outside the lock, and returns the removed session, or nil when
1795
+ # there was none to remove.
1536
1796
  def cleanup_session(session_id)
1537
1797
  session = @mutex.synchronize do
1538
1798
  cleanup_session_unsafe(session_id)
@@ -1542,6 +1802,8 @@ module MCP
1542
1802
  close_stream_safely(session[:get_sse_stream])
1543
1803
  close_post_request_streams(session)
1544
1804
  end
1805
+
1806
+ session
1545
1807
  end
1546
1808
 
1547
1809
  # Removes a session from `@sessions` and returns it. Does not close the stream.
@@ -1587,23 +1849,48 @@ module MCP
1587
1849
 
1588
1850
  # Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
1589
1851
  # 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:
1852
+ # Three layers, each answering with the rejection response it returns. The built-in layers run before
1853
+ # the custom validator so a permissive `session_request_validator` cannot bypass them, and the principal
1854
+ # binding runs first of all, so that another principal gets the 404 whatever else its request carries:
1591
1855
  #
1856
+ # - Built-in principal binding (active when bearer authentication is in play): a session initialized under
1857
+ # one token identity refuses requests verified as a different one. The answer is the same 404 an unknown session gets,
1858
+ # so a guessed session ID is not confirmed to exist; it carries no `WWW-Authenticate` challenge either,
1859
+ # because the token itself is valid and re-authorization would not help.
1592
1860
  # - 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,
1861
+ # at `initialize` and this request carries a different one, reject with 403. Both must be present to compare,
1594
1862
  # 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)
1863
+ # - The application-supplied `session_request_validator`, which can enforce ownership policy beyond the built-in layers;
1864
+ # a falsy return rejects with 403.
1865
+ #
1866
+ # Returns nil when the request may proceed.
1867
+ def session_request_rejection(request, session_id)
1598
1868
  session = @mutex.synchronize { @sessions[session_id] }
1599
- return true unless session
1869
+ return unless session
1870
+ return session_not_found_response unless session_principal_matches?(session, request)
1600
1871
 
1601
1872
  session_origin = session[:origin]
1602
1873
  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
1874
+ return forbidden_response if session_origin && request_origin && session_origin != request_origin
1605
1875
 
1606
- true
1876
+ if @session_request_validator && !@session_request_validator.call(request, session_id)
1877
+ return forbidden_response
1878
+ end
1879
+
1880
+ nil
1881
+ end
1882
+
1883
+ # A session bound to a principal at `initialize` only accepts requests verified as the same `issuer`, `subject`,
1884
+ # and `client_id` triple (client-credentials tokens without a `sub` bind on `client_id` alone; nil members compare equal).
1885
+ # The issuer takes part so a subject and client id pair repeated across identity providers behind a custom verifier
1886
+ # does not collide. A token whose subject and client id are both nil records no binding, because there is no identity to compare.
1887
+ def session_principal_matches?(session, request)
1888
+ return true if session[:auth_subject].nil? && session[:auth_client_id].nil?
1889
+
1890
+ token = request.env[OAuth::ENV_KEY]
1891
+ return false if token.nil?
1892
+
1893
+ token.issuer == session[:auth_issuer] && token.subject == session[:auth_subject] && token.client_id == session[:auth_client_id]
1607
1894
  end
1608
1895
 
1609
1896
  def validate_accept_header(request, required_types)
@@ -1619,10 +1906,10 @@ module MCP
1619
1906
  nil
1620
1907
  end
1621
1908
 
1909
+ # Empty list members (`application/json,,text/event-stream`), which RFC 9110 section 5.6.1.2 has
1910
+ # a recipient parse and ignore, are skipped.
1622
1911
  def parse_accept_header(header)
1623
- header.split(",").map do |part|
1624
- part.split(";").first.strip.downcase
1625
- end
1912
+ header.split(",").map { |part| part.split(";").first.to_s.strip.downcase }.reject(&:empty?)
1626
1913
  end
1627
1914
 
1628
1915
  def validate_content_type(request)
@@ -1712,7 +1999,8 @@ module MCP
1712
1999
  # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
1713
2000
  # a subclass of `JSON::ParserError`, so it is caught below as a parse error.
1714
2001
  JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
1715
- rescue JSON::ParserError, TypeError
2002
+ # `symbolize_names` raises `EncodingError`, not `JSON::ParserError`, for a key that is not valid UTF-8.
2003
+ rescue JSON::ParserError, TypeError, EncodingError
1716
2004
  raise InvalidJsonError
1717
2005
  end
1718
2006
 
@@ -1766,20 +2054,26 @@ module MCP
1766
2054
  )
1767
2055
  end
1768
2056
 
2057
+ # The body leaves the `id` member out when there is no request id to echo, as the MCP schema allows
2058
+ # only a string or an integer there.
1769
2059
  def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
1770
2060
  error = { code: code, message: message }
1771
2061
  error[:data] = data if data
1772
- body = { jsonrpc: "2.0", id: id, error: error }
2062
+ body = { jsonrpc: "2.0" }
2063
+ body[:id] = id unless id.nil?
2064
+ body[:error] = error
1773
2065
  [status, { "content-type" => "application/json" }, [body.to_json]]
1774
2066
  end
1775
2067
 
2068
+ # A notification carries no id member at all; a message with `"id": null` is a malformed request,
2069
+ # which the server answers with Invalid Request instead of running it unanswered.
1776
2070
  def notification?(body)
1777
- !body[:id] && !!body[:method]
2071
+ !body.key?(:id) && !!body[:method]
1778
2072
  end
1779
2073
 
1780
2074
  # Dispatches a client-originated notification (e.g. `notifications/cancelled`,
1781
2075
  # `notifications/initialized`) through the server so it can update session state.
1782
- def dispatch_notification(body_string, session_id)
2076
+ def dispatch_notification(body_string, session_id, auth_info: nil)
1783
2077
  server_session = nil
1784
2078
  if @stateless
1785
2079
  server_session = ephemeral_session
@@ -1790,7 +2084,7 @@ module MCP
1790
2084
  end
1791
2085
  end
1792
2086
 
1793
- dispatch_handle_json(body_string, server_session)
2087
+ dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1794
2088
  rescue => e
1795
2089
  MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
1796
2090
  end
@@ -1819,6 +2113,7 @@ module MCP
1819
2113
 
1820
2114
  def handle_initialization(request, body_string, body)
1821
2115
  session_id = nil
2116
+ auth_info = request.env[OAuth::ENV_KEY]
1822
2117
 
1823
2118
  if @stateless
1824
2119
  server_session = ephemeral_session
@@ -1844,9 +2139,15 @@ module MCP
1844
2139
  get_sse_stream: nil,
1845
2140
  server_session: server_session,
1846
2141
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1847
- # Captured for the built-in Origin-consistency defense in `validate_session_request`.
2142
+ # Captured for the built-in Origin-consistency defense in `session_request_rejection`.
1848
2143
  # Not authentication.
1849
2144
  origin: request.env["HTTP_ORIGIN"],
2145
+ # Principal binding per the spec's session-binding guidance: the session is bound to the token identity that initialized it,
2146
+ # and `session_request_rejection` rejects later requests presenting a token for a different principal. All three fields are nil
2147
+ # when the request was not bearer-authenticated, which disables the check.
2148
+ auth_issuer: auth_info&.issuer,
2149
+ auth_subject: auth_info&.subject,
2150
+ auth_client_id: auth_info&.client_id,
1850
2151
  }
1851
2152
  true
1852
2153
  end
@@ -1855,13 +2156,13 @@ module MCP
1855
2156
  return too_many_sessions_response unless inserted
1856
2157
  end
1857
2158
 
1858
- response = server_session.handle_json(body_string)
2159
+ response = server_session.handle_json(body_string, auth_info: auth_info)
1859
2160
 
1860
2161
  # `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
2162
+ # an id (framed as a notification) reaches here. `Server#init` does not run for it, as for
2163
+ # any request method without an id, so `response` is nil and the session created above
2164
+ # is never initialized. Returning `[200, ..., [nil]]` would place nil in the Rack body (which
2165
+ # the web server cannot serialize), and the session would linger uninitialized. Discard it
1865
2166
  # and ack with 202, mirroring the nil-response handling in a regular request.
1866
2167
  if response.nil?
1867
2168
  cleanup_session(session_id) if session_id
@@ -1898,7 +2199,7 @@ module MCP
1898
2199
  )
1899
2200
  end
1900
2201
 
1901
- def handle_regular_request(body_string, session_id, related_request_id: nil)
2202
+ def handle_regular_request(body_string, session_id, related_request_id: nil, auth_info: nil)
1902
2203
  server_session = nil
1903
2204
 
1904
2205
  if @stateless
@@ -1922,9 +2223,9 @@ module MCP
1922
2223
  end
1923
2224
 
1924
2225
  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)
2226
+ handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id, auth_info: auth_info)
1926
2227
  else
1927
- response = dispatch_handle_json(body_string, server_session)
2228
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1928
2229
 
1929
2230
  # `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
1930
2231
  # Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
@@ -1938,7 +2239,7 @@ module MCP
1938
2239
  # Returns the POST response as an SSE stream so the server can send
1939
2240
  # JSON-RPC requests and notifications during request processing.
1940
2241
  # 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)
2242
+ def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil, auth_info: nil)
1942
2243
  body = proc do |stream|
1943
2244
  @mutex.synchronize do
1944
2245
  session = @sessions[session_id]
@@ -1953,7 +2254,7 @@ module MCP
1953
2254
  end
1954
2255
 
1955
2256
  begin
1956
- response = dispatch_handle_json(body_string, server_session)
2257
+ response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
1957
2258
 
1958
2259
  send_to_stream(stream, response) if response
1959
2260
  ensure
@@ -1983,20 +2284,25 @@ module MCP
1983
2284
  # When `related_request_id` is given, returns only the POST response
1984
2285
  # stream for that request (no fallback to GET SSE). This prevents
1985
2286
  # 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
2287
+ # When `related_request_id` is nil, returns the GET SSE stream, or nil once the token that opened it
2288
+ # has expired: that stream is detached from the session and added to `streams_to_close` instead, so its
2289
+ # deadline holds at the next write and not only at the keepalive tick. Called under `@mutex`; the caller
2290
+ # closes the collected streams outside it.
2291
+ def active_stream(session, streams_to_close, related_request_id: nil)
2292
+ return session.dig(:post_request_streams, related_request_id) if related_request_id
2293
+
2294
+ stream = session[:get_sse_stream]
2295
+ return stream unless stream && token_expiry_passed?(session[:get_sse_stream_expires_at])
2296
+
2297
+ streams_to_close << detach_get_stream(session)
2298
+ nil
1993
2299
  end
1994
2300
 
1995
- def dispatch_handle_json(body_string, server_session)
2301
+ def dispatch_handle_json(body_string, server_session, auth_info: nil)
1996
2302
  if server_session
1997
- server_session.handle_json(body_string)
2303
+ server_session.handle_json(body_string, auth_info: auth_info)
1998
2304
  else
1999
- @server.handle_json(body_string)
2305
+ @server.handle_json(body_string, auth_info: auth_info)
2000
2306
  end
2001
2307
  end
2002
2308
 
@@ -2027,8 +2333,26 @@ module MCP
2027
2333
  response
2028
2334
  end
2029
2335
 
2030
- def get_session_stream(session_id)
2031
- @mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
2336
+ # Whether the session holds a GET stream whose deadline has not passed. A stream whose token expired may still
2337
+ # be attached between its last delivery and its next keepalive tick; it is detached and closed here, so a client
2338
+ # reopening the stream with a fresh token is not turned away until one of those noticed.
2339
+ def live_get_stream?(session_id)
2340
+ detached = nil
2341
+ live = @mutex.synchronize do
2342
+ session = @sessions[session_id]
2343
+ stream = session && session[:get_sse_stream]
2344
+ if stream.nil?
2345
+ false
2346
+ elsif token_expiry_passed?(session[:get_sse_stream_expires_at])
2347
+ detached = detach_get_stream(session)
2348
+ false
2349
+ else
2350
+ true
2351
+ end
2352
+ end
2353
+
2354
+ close_stream_safely(detached) if detached
2355
+ live
2032
2356
  end
2033
2357
 
2034
2358
  def session_exists?(session_id)
@@ -2067,6 +2391,27 @@ module MCP
2067
2391
  active
2068
2392
  end
2069
2393
 
2394
+ # Built-in OAuth 2.1 bearer enforcement (active when `token_verifier:` is configured).
2395
+ # Returns nil on success, storing the verified `AccessToken` in `env["mcp.auth_info"]` where the dispatch paths
2396
+ # and `session_request_rejection` read it; returns the RFC 6750 challenge response on failure. OPTIONS is exempt:
2397
+ # CORS preflights never carry credentials, and rejecting them here would fail CORS closed for browser clients
2398
+ # while the transport still answers the preflight itself with 405 (an upstream CORS middleware normally intercepts it).
2399
+ def authenticate_request(request)
2400
+ return if @oauth_authenticator.nil?
2401
+ return if request.env["REQUEST_METHOD"] == "OPTIONS"
2402
+
2403
+ request.env[OAuth::ENV_KEY] = @oauth_authenticator.authenticate(request.env)
2404
+ nil
2405
+ rescue OAuth::Error => e
2406
+ @oauth_authenticator.challenge_response(e)
2407
+ rescue => e
2408
+ # A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure:
2409
+ # report it and answer 500 without a challenge, so the client does not discard a perfectly good token.
2410
+ MCP.configuration.exception_reporter.call(e, { transport: self.class.name })
2411
+
2412
+ [500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]]
2413
+ end
2414
+
2070
2415
  # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
2071
2416
  # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
2072
2417
  # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
@@ -2164,15 +2509,12 @@ module MCP
2164
2509
  end
2165
2510
 
2166
2511
  def invalid_request_response(message, request_id: nil)
2167
- body = {
2168
- jsonrpc: "2.0",
2512
+ json_rpc_error_response(
2513
+ status: 400,
2514
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
2515
+ message: message,
2169
2516
  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]]
2517
+ )
2176
2518
  end
2177
2519
 
2178
2520
  def session_already_connected_response
@@ -2193,59 +2535,114 @@ module MCP
2193
2535
  )
2194
2536
  end
2195
2537
 
2196
- def setup_sse_stream(session_id)
2197
- body = create_sse_body(session_id)
2538
+ def setup_sse_stream(session_id, auth_info = nil)
2539
+ body = create_sse_body(session_id, auth_info)
2198
2540
 
2199
2541
  [200, SSE_HEADERS.dup, body]
2200
2542
  end
2201
2543
 
2202
- def create_sse_body(session_id)
2544
+ def create_sse_body(session_id, auth_info = nil)
2203
2545
  proc do |stream|
2204
- stored = store_stream_for_session(session_id, stream)
2205
- start_keepalive_thread(session_id) if stored
2546
+ stored = store_stream_for_session(session_id, stream, auth_info)
2547
+ start_keepalive_thread(session_id, stream) if stored
2206
2548
  end
2207
2549
  end
2208
2550
 
2209
- def store_stream_for_session(session_id, stream)
2210
- @mutex.synchronize do
2551
+ # The expiry of the token that authenticated the GET is kept with the stream so the keepalive loop and
2552
+ # the delivery paths can close the stream once that token expires.
2553
+ def store_stream_for_session(session_id, stream, auth_info = nil)
2554
+ stored = @mutex.synchronize do
2211
2555
  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
2556
+ next false unless session && !session[:get_sse_stream]
2557
+
2558
+ session[:get_sse_stream] = stream
2559
+ session[:get_sse_stream_expires_at] = stream_token_expiry(auth_info)
2560
+ true
2221
2561
  end
2562
+
2563
+ # The expiry is nil for a stream opened without a token, and callers read a falsy return as "not stored",
2564
+ # so the stream itself is the return value.
2565
+ return stream if stored
2566
+
2567
+ # Either the session was removed, or another request already established a stream. The refused stream is
2568
+ # closed outside the lock like every other stream this transport closes, so a close that blocks on the peer
2569
+ # cannot hold every other session on `@mutex`.
2570
+ close_stream_safely(stream)
2571
+ nil
2222
2572
  end
2223
2573
 
2224
- def start_keepalive_thread(session_id)
2574
+ # The thread acts on the stream it was started for and on no other: the session may have detached that
2575
+ # stream at a delivery once its token expired and the client may have opened a new one since, which gets
2576
+ # a keepalive thread of its own. The loop therefore ends as soon as the session no longer holds this stream,
2577
+ # and a loop that did not run to its end (a failed ping, or any other abnormal exit) drops the stream through
2578
+ # `drop_broken_stream`, which tears the session down only while it still holds that stream, as before.
2579
+ def start_keepalive_thread(session_id, stream)
2225
2580
  Thread.new do
2226
- while session_active_with_stream?(session_id)
2581
+ ended = false
2582
+
2583
+ while session_holds_stream?(session_id, stream)
2584
+ # Checked ahead of each tick so the stream does not outlive the token that opened it.
2585
+ # Revocation is not re-checked: the reference SDKs verify a stream at open only.
2586
+ # An expired token ends the stream alone: every other request of the session is verified on its own,
2587
+ # and the client may reopen the stream with a fresh token.
2588
+ if get_stream_token_expired?(session_id)
2589
+ close_get_stream(session_id, stream)
2590
+ break
2591
+ end
2592
+
2227
2593
  sleep(30)
2228
- send_keepalive_ping(session_id)
2594
+
2595
+ send_keepalive_ping(session_id, stream)
2229
2596
  end
2597
+
2598
+ ended = true
2230
2599
  rescue StandardError => e
2231
2600
  MCP.configuration.exception_reporter.call(e, { session_id: session_id })
2232
2601
  ensure
2233
- cleanup_session(session_id)
2602
+ drop_broken_stream(session_id, stream, nil) unless ended
2234
2603
  end
2235
2604
  end
2236
2605
 
2237
- def session_active_with_stream?(session_id)
2238
- @mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
2606
+ def session_holds_stream?(session_id, stream)
2607
+ @mutex.synchronize do
2608
+ session = @sessions[session_id]
2609
+ !session.nil? && session[:get_sse_stream].equal?(stream)
2610
+ end
2239
2611
  end
2240
2612
 
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
2613
+ def get_stream_token_expired?(session_id)
2614
+ expires_at = @mutex.synchronize do
2245
2615
  session = @sessions[session_id]
2246
- session && session[:get_sse_stream]
2616
+ session && session[:get_sse_stream_expires_at]
2247
2617
  end
2248
- return unless stream
2618
+
2619
+ token_expiry_passed?(expires_at)
2620
+ end
2621
+
2622
+ # Detaches and closes the session's GET stream when it is still `stream`, leaving the session in place.
2623
+ def close_get_stream(session_id, stream)
2624
+ detached = @mutex.synchronize do
2625
+ session = @sessions[session_id]
2626
+ next unless session && session[:get_sse_stream].equal?(stream)
2627
+
2628
+ detach_get_stream(session)
2629
+ end
2630
+
2631
+ close_stream_safely(detached) if detached
2632
+ end
2633
+
2634
+ # Removes the GET stream and its deadline from `session` under `@mutex` and returns the stream for closing
2635
+ # outside the lock.
2636
+ def detach_get_stream(session)
2637
+ session.delete(:get_sse_stream_expires_at)
2638
+ session.delete(:get_sse_stream)
2639
+ end
2640
+
2641
+ # Pings `stream` only while the session still holds it, checked under the lock and written outside it so
2642
+ # a stalled reader cannot block every other session on `@mutex`: a thread whose stream was detached during
2643
+ # its sleep must not write to the one the client opened since, which has a keepalive thread of its own.
2644
+ def send_keepalive_ping(session_id, stream)
2645
+ return unless session_holds_stream?(session_id, stream)
2249
2646
 
2250
2647
  send_ping_to_stream(stream)
2251
2648
  rescue *STREAM_WRITE_ERRORS => e