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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/json_rpc_handler.rb +69 -20
- data/lib/mcp/client/http.rb +117 -25
- data/lib/mcp/client/oauth/flow.rb +68 -21
- data/lib/mcp/client/oauth/provider.rb +11 -0
- data/lib/mcp/client/stdio.rb +35 -6
- data/lib/mcp/server/oauth/access_token.rb +89 -0
- data/lib/mcp/server/oauth/authenticator.rb +150 -0
- data/lib/mcp/server/oauth/challenge.rb +114 -0
- data/lib/mcp/server/oauth/errors.rb +58 -0
- data/lib/mcp/server/oauth/introspection_verifier.rb +151 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +330 -0
- data/lib/mcp/server/oauth/middleware.rb +60 -0
- data/lib/mcp/server/oauth/protected_resource_metadata.rb +138 -0
- data/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb +86 -0
- data/lib/mcp/server/oauth/token_verifier.rb +135 -0
- data/lib/mcp/server/oauth.rb +29 -0
- data/lib/mcp/server/transports/stdio_transport.rb +45 -8
- data/lib/mcp/server/transports/streamable_http_transport.rb +546 -149
- data/lib/mcp/server.rb +123 -35
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +18 -8
- data/lib/mcp/transport.rb +8 -0
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -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
|
-
@
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" }, [
|
|
957
|
+
[modern_http_status(response), { "content-type" => "application/json" }, [response_json]]
|
|
821
958
|
else
|
|
822
|
-
frames = notifications + [
|
|
823
|
-
sse_body = frames.map { |frame| "event: message\ndata: #{frame
|
|
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
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1461
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
1596
|
-
#
|
|
1597
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
|
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 `
|
|
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`
|
|
1862
|
-
#
|
|
1863
|
-
# Returning `[200, ..., [nil]]` would place nil in the Rack body (which
|
|
1864
|
-
#
|
|
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
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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
|
-
|
|
2031
|
-
|
|
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
|
-
|
|
2168
|
-
|
|
2512
|
+
json_rpc_error_response(
|
|
2513
|
+
status: 400,
|
|
2514
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
2515
|
+
message: message,
|
|
2169
2516
|
id: request_id,
|
|
2170
|
-
|
|
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
|
-
|
|
2210
|
-
|
|
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
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2602
|
+
drop_broken_stream(session_id, stream, nil) unless ended
|
|
2234
2603
|
end
|
|
2235
2604
|
end
|
|
2236
2605
|
|
|
2237
|
-
def
|
|
2238
|
-
@mutex.synchronize
|
|
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
|
|
2242
|
-
|
|
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[:
|
|
2616
|
+
session && session[:get_sse_stream_expires_at]
|
|
2247
2617
|
end
|
|
2248
|
-
|
|
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
|