mcp 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/mcp/client/http.rb +94 -17
- data/lib/mcp/client/oauth/flow.rb +68 -21
- data/lib/mcp/client/oauth/provider.rb +11 -0
- data/lib/mcp/client/stdio.rb +23 -4
- 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/streamable_http_transport.rb +488 -120
- data/lib/mcp/server.rb +55 -28
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- 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:`.
|
|
@@ -339,6 +410,11 @@ module MCP
|
|
|
339
410
|
rebinding_error = validate_dns_rebinding(request)
|
|
340
411
|
return rebinding_error if rebinding_error
|
|
341
412
|
|
|
413
|
+
# Bearer enforcement runs after the DNS-rebinding rejection (which must stay the cheapest gate and
|
|
414
|
+
# never trigger verifier work) and before any body read, so an unauthenticated request costs no parsing.
|
|
415
|
+
auth_error = authenticate_request(request)
|
|
416
|
+
return auth_error if auth_error
|
|
417
|
+
|
|
342
418
|
# Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
|
|
343
419
|
# every supported list routes to the sessionless modern path, so an unknown future version receives
|
|
344
420
|
# the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
|
|
@@ -386,8 +462,28 @@ module MCP
|
|
|
386
462
|
end
|
|
387
463
|
end
|
|
388
464
|
|
|
465
|
+
# The reaper is asked to stop and joined before it is killed: `reap_expired_sessions` removes expired sessions
|
|
466
|
+
# under `@mutex` and closes their streams after releasing it, and a kill landing between the two would leave
|
|
467
|
+
# those streams open with no session left to find them by. Waking the reaper ends its wait at once, and the join
|
|
468
|
+
# lets a reap already past its removal finish its closes before the rest of the transport is torn down.
|
|
469
|
+
# The join is bounded, so a stream whose close never returns cannot hold the shutdown, and a reaper that died
|
|
470
|
+
# with a `StandardError` does not stop the teardown below: `join` re-raises the exception a thread died with,
|
|
471
|
+
# which is not this method's to raise. A reaper still alive after the wait is killed as before, leaving
|
|
472
|
+
# the streams of that reap it had not closed yet as they were.
|
|
389
473
|
def close
|
|
390
|
-
@
|
|
474
|
+
reaper = @mutex.synchronize do
|
|
475
|
+
@closing = true
|
|
476
|
+
@reaper_wakeup.signal
|
|
477
|
+
@reaper_thread
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
begin
|
|
481
|
+
reaper&.join(REAPER_JOIN_TIMEOUT)
|
|
482
|
+
rescue StandardError
|
|
483
|
+
# The reaper is gone either way; the exception that ended it is not this method's to report.
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
reaper.kill if reaper&.alive?
|
|
391
487
|
@reaper_thread = nil
|
|
392
488
|
|
|
393
489
|
teardown_listen_subscriptions
|
|
@@ -459,7 +555,7 @@ module MCP
|
|
|
459
555
|
next
|
|
460
556
|
end
|
|
461
557
|
|
|
462
|
-
active_stream(session, related_request_id: related_request_id)
|
|
558
|
+
active_stream(session, streams_to_close, related_request_id: related_request_id)
|
|
463
559
|
end
|
|
464
560
|
|
|
465
561
|
close_streams(streams_to_close)
|
|
@@ -476,14 +572,16 @@ module MCP
|
|
|
476
572
|
expired_session_ids = []
|
|
477
573
|
|
|
478
574
|
collected = @sessions.filter_map do |session_id, session|
|
|
479
|
-
next unless
|
|
575
|
+
next unless session[:get_sse_stream]
|
|
480
576
|
|
|
481
577
|
if session_expired?(session)
|
|
482
578
|
expired_session_ids << session_id
|
|
483
579
|
next
|
|
484
580
|
end
|
|
485
581
|
|
|
486
|
-
|
|
582
|
+
stream = active_stream(session, streams_to_close)
|
|
583
|
+
|
|
584
|
+
[session_id, stream] if stream
|
|
487
585
|
end
|
|
488
586
|
|
|
489
587
|
expired_session_ids.each do |session_id|
|
|
@@ -514,6 +612,11 @@ module MCP
|
|
|
514
612
|
# Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
|
|
515
613
|
# a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
|
|
516
614
|
# under `@mutex`, and the affected streams are closed outside it.
|
|
615
|
+
#
|
|
616
|
+
# Only the GET stream the session still holds takes the session down with it, judged under the same hold of
|
|
617
|
+
# the lock that removes the session: the failed write may have been picked for a stream that was detached since,
|
|
618
|
+
# because its token expired and the client reopened, and the stream opened in its place must stay. A failed
|
|
619
|
+
# stream the session no longer holds was closed by whichever path detached it, so there is nothing left to do.
|
|
517
620
|
def drop_broken_stream(session_id, stream, related_request_id)
|
|
518
621
|
streams_to_close = []
|
|
519
622
|
|
|
@@ -527,7 +630,7 @@ module MCP
|
|
|
527
630
|
session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
|
|
528
631
|
|
|
529
632
|
streams_to_close << stream
|
|
530
|
-
|
|
633
|
+
elsif session && session[:get_sse_stream].equal?(stream)
|
|
531
634
|
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
532
635
|
end
|
|
533
636
|
end
|
|
@@ -585,6 +688,7 @@ module MCP
|
|
|
585
688
|
|
|
586
689
|
# Register the pending response and resolve the stream under the lock, but perform
|
|
587
690
|
# the write outside it so a stalled reader cannot block every other session on `@mutex`.
|
|
691
|
+
streams_to_close = []
|
|
588
692
|
stream = @mutex.synchronize do
|
|
589
693
|
unless (session = @sessions[session_id])
|
|
590
694
|
raise "Session not found: #{session_id}."
|
|
@@ -592,9 +696,11 @@ module MCP
|
|
|
592
696
|
|
|
593
697
|
@pending_responses[request_id] = { queue: pending_response, session_id: session_id }
|
|
594
698
|
|
|
595
|
-
active_stream(session, related_request_id: related_request_id)
|
|
699
|
+
active_stream(session, streams_to_close, related_request_id: related_request_id)
|
|
596
700
|
end
|
|
597
701
|
|
|
702
|
+
close_streams(streams_to_close)
|
|
703
|
+
|
|
598
704
|
sent = false
|
|
599
705
|
if stream
|
|
600
706
|
begin
|
|
@@ -695,10 +801,18 @@ module MCP
|
|
|
695
801
|
|
|
696
802
|
private
|
|
697
803
|
|
|
804
|
+
# The wait between reaps is a condition variable wait under `@mutex` rather than a sleep, so `close` can end it
|
|
805
|
+
# by signalling; a spurious wakeup only runs a reap early.
|
|
698
806
|
def start_reaper_thread
|
|
699
807
|
@reaper_thread = Thread.new do
|
|
700
808
|
loop do
|
|
701
|
-
|
|
809
|
+
closing = @mutex.synchronize do
|
|
810
|
+
@reaper_wakeup.wait(@mutex, SESSION_REAP_INTERVAL) unless @closing
|
|
811
|
+
@closing
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
break if closing
|
|
815
|
+
|
|
702
816
|
reap_expired_sessions
|
|
703
817
|
rescue StandardError => e
|
|
704
818
|
MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
|
|
@@ -797,13 +911,13 @@ module MCP
|
|
|
797
911
|
# to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
|
|
798
912
|
# serve an open SSE stream needs, instead of a `Proc` body it can never call.
|
|
799
913
|
if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
|
|
800
|
-
return handle_subscriptions_listen(body)
|
|
914
|
+
return handle_subscriptions_listen(body, auth_info: request.env[OAuth::ENV_KEY])
|
|
801
915
|
end
|
|
802
916
|
|
|
803
917
|
session = modern_session
|
|
804
918
|
notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
|
|
805
919
|
begin
|
|
806
|
-
response = @server.handle(body, session: session)
|
|
920
|
+
response = @server.handle(body, session: session, auth_info: request.env[OAuth::ENV_KEY])
|
|
807
921
|
ensure
|
|
808
922
|
@mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
|
|
809
923
|
end
|
|
@@ -895,7 +1009,7 @@ module MCP
|
|
|
895
1009
|
# (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
|
|
896
1010
|
# response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
|
|
897
1011
|
# `listen_keepalive_interval` seconds so a dropped connection frees its slot.
|
|
898
|
-
def handle_subscriptions_listen(body)
|
|
1012
|
+
def handle_subscriptions_listen(body, auth_info: nil)
|
|
899
1013
|
request_id = body[:id]
|
|
900
1014
|
params = body[:params]
|
|
901
1015
|
|
|
@@ -968,7 +1082,7 @@ module MCP
|
|
|
968
1082
|
)
|
|
969
1083
|
end
|
|
970
1084
|
|
|
971
|
-
[200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement)]
|
|
1085
|
+
[200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement, auth_info)]
|
|
972
1086
|
end
|
|
973
1087
|
|
|
974
1088
|
# The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
|
|
@@ -1049,7 +1163,7 @@ module MCP
|
|
|
1049
1163
|
# The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
|
|
1050
1164
|
# the requesting client's own in-flight requests, and two clients that pick the same one must each get
|
|
1051
1165
|
# their stream, stamped with the id they sent.
|
|
1052
|
-
def listen_sse_body(request_id, honored, acknowledgement)
|
|
1166
|
+
def listen_sse_body(request_id, honored, acknowledgement, auth_info = nil)
|
|
1053
1167
|
ListenStreamBody.new do |stream|
|
|
1054
1168
|
subscription_key = SecureRandom.uuid
|
|
1055
1169
|
subscription = nil
|
|
@@ -1062,9 +1176,12 @@ module MCP
|
|
|
1062
1176
|
|
|
1063
1177
|
@mutex.synchronize do
|
|
1064
1178
|
unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
|
|
1179
|
+
# The expiry of the token that authenticated the listen request is kept with the stream
|
|
1180
|
+
# so the keepalive loop and the delivery path can close the stream once that token expires.
|
|
1065
1181
|
subscription = {
|
|
1066
1182
|
request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
|
|
1067
1183
|
write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
|
|
1184
|
+
expires_at: stream_token_expiry(auth_info),
|
|
1068
1185
|
}
|
|
1069
1186
|
@listen_subscriptions[subscription_key] = subscription
|
|
1070
1187
|
end
|
|
@@ -1090,14 +1207,11 @@ module MCP
|
|
|
1090
1207
|
true
|
|
1091
1208
|
end
|
|
1092
1209
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
close_removed_listen_stream(subscription)
|
|
1097
|
-
end
|
|
1210
|
+
# An acknowledgement skipped because the entry was marked closed leaves the stream to the path that marked
|
|
1211
|
+
# it, the teardown, which closes the stream itself once it holds the write mutex.
|
|
1212
|
+
start_listen_keepalive_thread(subscription_key, request_id) if acknowledged
|
|
1098
1213
|
rescue *STREAM_WRITE_ERRORS
|
|
1099
|
-
remove_listen_subscription(subscription_key)
|
|
1100
|
-
close_removed_listen_stream(subscription)
|
|
1214
|
+
close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
|
|
1101
1215
|
end
|
|
1102
1216
|
end
|
|
1103
1217
|
end
|
|
@@ -1121,10 +1235,15 @@ module MCP
|
|
|
1121
1235
|
registered = @mutex.synchronize do
|
|
1122
1236
|
subscription = @listen_subscriptions[subscription_key]
|
|
1123
1237
|
next false unless subscription
|
|
1238
|
+
# Checked ahead of each tick so the stream does not outlive the token that opened it.
|
|
1239
|
+
# Revocation is not re-checked: the reference SDKs verify a stream at open only.
|
|
1240
|
+
next false if token_expiry_passed?(subscription[:expires_at])
|
|
1124
1241
|
|
|
1125
|
-
|
|
1242
|
+
# The wait is cut short at the stream's deadline, so the stream ends at that deadline rather than
|
|
1243
|
+
# up to one interval after it, and a deadline that passed during the wait ends the loop before the ping.
|
|
1244
|
+
subscription[:keepalive_wakeup].wait(@mutex, listen_keepalive_wait(subscription[:expires_at]))
|
|
1126
1245
|
|
|
1127
|
-
@listen_subscriptions.key?(subscription_key)
|
|
1246
|
+
@listen_subscriptions.key?(subscription_key) && !token_expiry_passed?(subscription[:expires_at])
|
|
1128
1247
|
end
|
|
1129
1248
|
break unless registered
|
|
1130
1249
|
|
|
@@ -1160,6 +1279,40 @@ module MCP
|
|
|
1160
1279
|
end
|
|
1161
1280
|
end
|
|
1162
1281
|
|
|
1282
|
+
# The deadline at which a stream authenticated by `auth_info` is closed: whichever comes first of the token's own
|
|
1283
|
+
# expiry and `max_stream_lifetime:` seconds from now. Only the deadline is retained with the stream,
|
|
1284
|
+
# never the `AccessToken` itself, which would park the raw bearer credential in `@sessions` or `@listen_subscriptions`
|
|
1285
|
+
# for the life of the stream. The cap is what bounds a stream whose token reports no expiry (`exp` is optional in
|
|
1286
|
+
# an RFC 7662 introspection response), so "a stream does not outlive its credential" holds even when the credential
|
|
1287
|
+
# never says when it ends. A stream opened without a token is not capped: there is no credential to bound,
|
|
1288
|
+
# and capping it would change a transport used without `token_verifier:`.
|
|
1289
|
+
def stream_token_expiry(auth_info)
|
|
1290
|
+
return unless auth_info
|
|
1291
|
+
|
|
1292
|
+
deadlines = []
|
|
1293
|
+
token_expiry = auth_info.expires_at if auth_info.respond_to?(:expires_at)
|
|
1294
|
+
# A custom verifier that breaks the `AccessToken` contract with an expiry that is not a number must not fail
|
|
1295
|
+
# the stream while it is being set up: the value is ignored and only the cap applies.
|
|
1296
|
+
deadlines << token_expiry if token_expiry.is_a?(Numeric)
|
|
1297
|
+
deadlines << Time.now.to_i + @max_stream_lifetime if @max_stream_lifetime
|
|
1298
|
+
|
|
1299
|
+
deadlines.min
|
|
1300
|
+
end
|
|
1301
|
+
|
|
1302
|
+
# Same rule as `AccessToken#expired?`: no expiry means the token never expires here.
|
|
1303
|
+
def token_expiry_passed?(expires_at)
|
|
1304
|
+
!expires_at.nil? && expires_at <= Time.now.to_i
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
# Seconds the listen keepalive thread waits before its next tick: the interval, or the time left until
|
|
1308
|
+
# the stream's deadline when that comes first. The floor keeps a deadline a few microseconds away from
|
|
1309
|
+
# turning into a zero-length wait.
|
|
1310
|
+
def listen_keepalive_wait(expires_at)
|
|
1311
|
+
return @listen_keepalive_interval if expires_at.nil?
|
|
1312
|
+
|
|
1313
|
+
[[expires_at - Time.now.to_f, LISTEN_DEADLINE_WAIT_FLOOR].max, @listen_keepalive_interval].min
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1163
1316
|
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
1164
1317
|
# and the acknowledgement only includes types the server actually supports
|
|
1165
1318
|
# (derived from its declared capabilities).
|
|
@@ -1224,7 +1377,25 @@ module MCP
|
|
|
1224
1377
|
# nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
|
|
1225
1378
|
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1226
1379
|
# delivering to it would put a notification ahead of the acknowledgement.
|
|
1227
|
-
|
|
1380
|
+
# An active entry whose token deadline has passed is closed here instead of delivered to, so the deadline
|
|
1381
|
+
# holds on a transport whose keepalive is disabled as well. An expired entry still inactive is skipped
|
|
1382
|
+
# like any inactive one and caught by the next delivery once its acknowledgement is written.
|
|
1383
|
+
expired_keys = []
|
|
1384
|
+
candidates = @mutex.synchronize do
|
|
1385
|
+
@listen_subscriptions.select do |key, subscription|
|
|
1386
|
+
next false unless subscription[:active]
|
|
1387
|
+
next true unless token_expiry_passed?(subscription[:expires_at])
|
|
1388
|
+
|
|
1389
|
+
expired_keys << key
|
|
1390
|
+
false
|
|
1391
|
+
end
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1394
|
+
expired_keys.each do |key|
|
|
1395
|
+
subscription = remove_listen_subscription(key)
|
|
1396
|
+
|
|
1397
|
+
close_removed_listen_stream(subscription) if subscription
|
|
1398
|
+
end
|
|
1228
1399
|
|
|
1229
1400
|
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1230
1401
|
matched = candidates.select do |_key, subscription|
|
|
@@ -1251,8 +1422,9 @@ module MCP
|
|
|
1251
1422
|
e,
|
|
1252
1423
|
{ subscription_id: subscription[:request_id], error: "Failed to send notification" },
|
|
1253
1424
|
)
|
|
1254
|
-
|
|
1255
|
-
|
|
1425
|
+
# Closed only when the removal is this path's own, as every other removal path does: the keepalive loop,
|
|
1426
|
+
# a token expiry, or the teardown that removed the entry first closes the stream itself.
|
|
1427
|
+
close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
|
|
1256
1428
|
end
|
|
1257
1429
|
end
|
|
1258
1430
|
end
|
|
@@ -1282,8 +1454,14 @@ module MCP
|
|
|
1282
1454
|
end
|
|
1283
1455
|
|
|
1284
1456
|
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
|
|
1285
|
-
# before the stream closes.
|
|
1286
|
-
|
|
1457
|
+
# before the stream closes. The results are written on a helper thread that is waited for at most `timeout`
|
|
1458
|
+
# seconds in all, because a peer that stopped reading stalls the write of its result, or a delivery already
|
|
1459
|
+
# holding the stream's write mutex, for as long as its socket buffers stay full. Once the time is up, every stream
|
|
1460
|
+
# still open is closed from here: closing a Ruby IO ends a write stalled on it with an `IOError` the helper rescues,
|
|
1461
|
+
# while a stream object of another kind may keep the helper blocked, which no longer holds `close`. Either way
|
|
1462
|
+
# the stream goes without its result, like one whose acknowledgement was never written. The helper is left
|
|
1463
|
+
# to finish on its own rather than killed, so a write mutex is never dropped in the middle of a write.
|
|
1464
|
+
def teardown_listen_subscriptions(timeout: LISTEN_TEARDOWN_TIMEOUT)
|
|
1287
1465
|
removed = @mutex.synchronize do
|
|
1288
1466
|
subscriptions = @listen_subscriptions.dup
|
|
1289
1467
|
@listen_subscriptions.clear
|
|
@@ -1294,35 +1472,72 @@ module MCP
|
|
|
1294
1472
|
|
|
1295
1473
|
subscriptions
|
|
1296
1474
|
end
|
|
1475
|
+
return if removed.empty?
|
|
1297
1476
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1477
|
+
# Whichever side takes an entry out of `pending` closes its stream: the helper after the write, or `close` once
|
|
1478
|
+
# the time is up. Claiming before closing keeps a stream from being closed by both in either order.
|
|
1479
|
+
pending = removed.dup
|
|
1480
|
+
pending_mutex = Mutex.new
|
|
1481
|
+
writer = Thread.new do
|
|
1482
|
+
removed.each do |subscription_key, subscription|
|
|
1483
|
+
write_listen_closing_result(subscription)
|
|
1484
|
+
close_stream_safely(subscription[:stream]) if pending_mutex.synchronize { pending.delete(subscription_key) }
|
|
1485
|
+
end
|
|
1486
|
+
end
|
|
1304
1487
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1488
|
+
begin
|
|
1489
|
+
writer.join(timeout)
|
|
1490
|
+
rescue StandardError
|
|
1491
|
+
# A helper that died with an exception has stopped writing; the streams it did not finish are closed below.
|
|
1492
|
+
end
|
|
1308
1493
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1494
|
+
left = pending_mutex.synchronize do
|
|
1495
|
+
subscriptions = pending.values
|
|
1496
|
+
pending.clear
|
|
1497
|
+
subscriptions
|
|
1498
|
+
end
|
|
1499
|
+
left.each { |subscription| close_stream_safely(subscription[:stream]) }
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
# Marking the entry closed and writing the result under the stream's write mutex orders
|
|
1503
|
+
# this against in-flight deliveries and against the acknowledgement write: each one either lands
|
|
1504
|
+
# before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
|
|
1505
|
+
# The stream is left open for the caller to close.
|
|
1506
|
+
def write_listen_closing_result(subscription)
|
|
1507
|
+
subscription[:write_mutex].synchronize do
|
|
1508
|
+
subscription[:closed] = true
|
|
1509
|
+
|
|
1510
|
+
# A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
|
|
1511
|
+
# the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
|
|
1512
|
+
next unless subscription[:active]
|
|
1513
|
+
|
|
1514
|
+
begin
|
|
1515
|
+
send_to_stream(subscription[:stream], {
|
|
1516
|
+
jsonrpc: "2.0",
|
|
1517
|
+
id: subscription[:request_id],
|
|
1518
|
+
result: {
|
|
1519
|
+
# `SubscriptionsListenResult` is served at the transport layer and never
|
|
1520
|
+
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
|
|
1521
|
+
# stamped at its construction site.
|
|
1522
|
+
resultType: ResultType::COMPLETE,
|
|
1523
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
|
|
1524
|
+
},
|
|
1525
|
+
})
|
|
1526
|
+
rescue *STREAM_WRITE_ERRORS
|
|
1527
|
+
nil
|
|
1324
1528
|
end
|
|
1325
|
-
|
|
1529
|
+
end
|
|
1530
|
+
rescue StandardError => e
|
|
1531
|
+
# Reported rather than raised: on the helper thread an error would end the teardown of every stream after this one,
|
|
1532
|
+
# and `close` is past waiting for it by the time a stalled write fails. A reporter that raises is dropped for
|
|
1533
|
+
# the same reason.
|
|
1534
|
+
begin
|
|
1535
|
+
MCP.configuration.exception_reporter.call(
|
|
1536
|
+
e,
|
|
1537
|
+
{ subscription_id: subscription[:request_id], error: "Failed to close listen stream" },
|
|
1538
|
+
)
|
|
1539
|
+
rescue StandardError
|
|
1540
|
+
nil
|
|
1326
1541
|
end
|
|
1327
1542
|
end
|
|
1328
1543
|
|
|
@@ -1447,7 +1662,15 @@ module MCP
|
|
|
1447
1662
|
# one. In the live case, reject with `-32600` so the original session is not abandoned.
|
|
1448
1663
|
# In the unknown/expired case, return 404 so the client retries from scratch instead
|
|
1449
1664
|
# of silently inheriting a fresh session under the old ID.
|
|
1450
|
-
|
|
1665
|
+
# The live case is told only to the principal the session is bound to: for any other token the answer
|
|
1666
|
+
# is the 404 an unknown session gets, as on every other route, so a guessed session id is not confirmed
|
|
1667
|
+
# to exist by an `initialize` either.
|
|
1668
|
+
if session_active?(session_id)
|
|
1669
|
+
session = @mutex.synchronize { @sessions[session_id] }
|
|
1670
|
+
return session_not_found_response unless session && session_principal_matches?(session, request)
|
|
1671
|
+
|
|
1672
|
+
return already_initialized_response(body[:id])
|
|
1673
|
+
end
|
|
1451
1674
|
|
|
1452
1675
|
return session_not_found_response
|
|
1453
1676
|
end
|
|
@@ -1457,8 +1680,10 @@ module MCP
|
|
|
1457
1680
|
# Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
|
|
1458
1681
|
# and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
|
|
1459
1682
|
# otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
|
|
1460
|
-
if !@stateless && session_id
|
|
1461
|
-
|
|
1683
|
+
if !@stateless && session_id
|
|
1684
|
+
rejection = session_request_rejection(request, session_id)
|
|
1685
|
+
|
|
1686
|
+
return rejection if rejection
|
|
1462
1687
|
end
|
|
1463
1688
|
|
|
1464
1689
|
if notification?(body)
|
|
@@ -1467,14 +1692,14 @@ module MCP
|
|
|
1467
1692
|
# branches; without it a custom notification handler could run without a live session.
|
|
1468
1693
|
return session_not_found_response if !@stateless && !session_active?(session_id)
|
|
1469
1694
|
|
|
1470
|
-
dispatch_notification(body_string, session_id)
|
|
1695
|
+
dispatch_notification(body_string, session_id, auth_info: request.env[OAuth::ENV_KEY])
|
|
1471
1696
|
handle_accepted
|
|
1472
1697
|
elsif response?(body)
|
|
1473
1698
|
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
1474
1699
|
|
|
1475
1700
|
handle_response(body, session_id: session_id)
|
|
1476
1701
|
else
|
|
1477
|
-
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
1702
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id], auth_info: request.env[OAuth::ENV_KEY])
|
|
1478
1703
|
end
|
|
1479
1704
|
end
|
|
1480
1705
|
rescue StandardError => e
|
|
@@ -1498,16 +1723,20 @@ module MCP
|
|
|
1498
1723
|
|
|
1499
1724
|
return missing_session_id_response unless session_id
|
|
1500
1725
|
|
|
1726
|
+
# The ownership gate runs before the session is touched, so a rejected request cannot refresh the idle timer of a session it does not own.
|
|
1727
|
+
# The visible outcome is unchanged: an unknown session passes the gate and fails the lookup, and an expired one fails it, both with the same 404.
|
|
1728
|
+
rejection = session_request_rejection(request, session_id)
|
|
1729
|
+
return rejection if rejection
|
|
1730
|
+
|
|
1501
1731
|
error_response = validate_and_touch_session(session_id)
|
|
1502
1732
|
return error_response if error_response
|
|
1503
|
-
return forbidden_response unless validate_session_request(request, session_id)
|
|
1504
1733
|
|
|
1505
1734
|
protocol_version_error = validate_protocol_version_header(request)
|
|
1506
1735
|
return protocol_version_error if protocol_version_error
|
|
1507
1736
|
|
|
1508
|
-
return session_already_connected_response if
|
|
1737
|
+
return session_already_connected_response if live_get_stream?(session_id)
|
|
1509
1738
|
|
|
1510
|
-
setup_sse_stream(session_id)
|
|
1739
|
+
setup_sse_stream(session_id, request.env[OAuth::ENV_KEY])
|
|
1511
1740
|
end
|
|
1512
1741
|
|
|
1513
1742
|
def handle_delete(request)
|
|
@@ -1523,16 +1752,22 @@ module MCP
|
|
|
1523
1752
|
|
|
1524
1753
|
return missing_session_id_response unless (session_id = extract_session_id(request))
|
|
1525
1754
|
return session_not_found_response unless session_exists?(session_id)
|
|
1526
|
-
|
|
1755
|
+
rejection = session_request_rejection(request, session_id)
|
|
1756
|
+
return rejection if rejection
|
|
1527
1757
|
|
|
1528
1758
|
protocol_version_error = validate_protocol_version_header(request)
|
|
1529
1759
|
return protocol_version_error if protocol_version_error
|
|
1530
1760
|
|
|
1531
|
-
|
|
1761
|
+
# The session may be gone by now, removed by the reaper or by the owner's own DELETE since the checks above:
|
|
1762
|
+
# a request that terminated nothing answers like one naming an unknown session, so a removal it did not cause
|
|
1763
|
+
# confirms nothing about the session having existed.
|
|
1764
|
+
return session_not_found_response unless cleanup_session(session_id)
|
|
1532
1765
|
|
|
1533
1766
|
success_response
|
|
1534
1767
|
end
|
|
1535
1768
|
|
|
1769
|
+
# Removes the session, closes its streams outside the lock, and returns the removed session, or nil when
|
|
1770
|
+
# there was none to remove.
|
|
1536
1771
|
def cleanup_session(session_id)
|
|
1537
1772
|
session = @mutex.synchronize do
|
|
1538
1773
|
cleanup_session_unsafe(session_id)
|
|
@@ -1542,6 +1777,8 @@ module MCP
|
|
|
1542
1777
|
close_stream_safely(session[:get_sse_stream])
|
|
1543
1778
|
close_post_request_streams(session)
|
|
1544
1779
|
end
|
|
1780
|
+
|
|
1781
|
+
session
|
|
1545
1782
|
end
|
|
1546
1783
|
|
|
1547
1784
|
# Removes a session from `@sessions` and returns it. Does not close the stream.
|
|
@@ -1587,23 +1824,48 @@ module MCP
|
|
|
1587
1824
|
|
|
1588
1825
|
# Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
|
|
1589
1826
|
# The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
|
|
1590
|
-
#
|
|
1827
|
+
# Three layers, each answering with the rejection response it returns. The built-in layers run before
|
|
1828
|
+
# the custom validator so a permissive `session_request_validator` cannot bypass them, and the principal
|
|
1829
|
+
# binding runs first of all, so that another principal gets the 404 whatever else its request carries:
|
|
1591
1830
|
#
|
|
1831
|
+
# - Built-in principal binding (active when bearer authentication is in play): a session initialized under
|
|
1832
|
+
# one token identity refuses requests verified as a different one. The answer is the same 404 an unknown session gets,
|
|
1833
|
+
# so a guessed session ID is not confirmed to exist; it carries no `WWW-Authenticate` challenge either,
|
|
1834
|
+
# because the token itself is valid and re-authorization would not help.
|
|
1592
1835
|
# - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
|
|
1593
|
-
# at `initialize` and this request carries a different one, reject. Both must be present to compare,
|
|
1836
|
+
# at `initialize` and this request carries a different one, reject with 403. Both must be present to compare,
|
|
1594
1837
|
# so non-browser clients that send no `Origin` are unaffected.
|
|
1595
|
-
# - The application-supplied `session_request_validator`, which can enforce
|
|
1596
|
-
#
|
|
1597
|
-
|
|
1838
|
+
# - The application-supplied `session_request_validator`, which can enforce ownership policy beyond the built-in layers;
|
|
1839
|
+
# a falsy return rejects with 403.
|
|
1840
|
+
#
|
|
1841
|
+
# Returns nil when the request may proceed.
|
|
1842
|
+
def session_request_rejection(request, session_id)
|
|
1598
1843
|
session = @mutex.synchronize { @sessions[session_id] }
|
|
1599
|
-
return
|
|
1844
|
+
return unless session
|
|
1845
|
+
return session_not_found_response unless session_principal_matches?(session, request)
|
|
1600
1846
|
|
|
1601
1847
|
session_origin = session[:origin]
|
|
1602
1848
|
request_origin = request.env["HTTP_ORIGIN"]
|
|
1603
|
-
return
|
|
1604
|
-
return @session_request_validator.call(request, session_id) if @session_request_validator
|
|
1849
|
+
return forbidden_response if session_origin && request_origin && session_origin != request_origin
|
|
1605
1850
|
|
|
1606
|
-
|
|
1851
|
+
if @session_request_validator && !@session_request_validator.call(request, session_id)
|
|
1852
|
+
return forbidden_response
|
|
1853
|
+
end
|
|
1854
|
+
|
|
1855
|
+
nil
|
|
1856
|
+
end
|
|
1857
|
+
|
|
1858
|
+
# A session bound to a principal at `initialize` only accepts requests verified as the same `issuer`, `subject`,
|
|
1859
|
+
# and `client_id` triple (client-credentials tokens without a `sub` bind on `client_id` alone; nil members compare equal).
|
|
1860
|
+
# The issuer takes part so a subject and client id pair repeated across identity providers behind a custom verifier
|
|
1861
|
+
# does not collide. A token whose subject and client id are both nil records no binding, because there is no identity to compare.
|
|
1862
|
+
def session_principal_matches?(session, request)
|
|
1863
|
+
return true if session[:auth_subject].nil? && session[:auth_client_id].nil?
|
|
1864
|
+
|
|
1865
|
+
token = request.env[OAuth::ENV_KEY]
|
|
1866
|
+
return false if token.nil?
|
|
1867
|
+
|
|
1868
|
+
token.issuer == session[:auth_issuer] && token.subject == session[:auth_subject] && token.client_id == session[:auth_client_id]
|
|
1607
1869
|
end
|
|
1608
1870
|
|
|
1609
1871
|
def validate_accept_header(request, required_types)
|
|
@@ -1779,7 +2041,7 @@ module MCP
|
|
|
1779
2041
|
|
|
1780
2042
|
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
|
|
1781
2043
|
# `notifications/initialized`) through the server so it can update session state.
|
|
1782
|
-
def dispatch_notification(body_string, session_id)
|
|
2044
|
+
def dispatch_notification(body_string, session_id, auth_info: nil)
|
|
1783
2045
|
server_session = nil
|
|
1784
2046
|
if @stateless
|
|
1785
2047
|
server_session = ephemeral_session
|
|
@@ -1790,7 +2052,7 @@ module MCP
|
|
|
1790
2052
|
end
|
|
1791
2053
|
end
|
|
1792
2054
|
|
|
1793
|
-
dispatch_handle_json(body_string, server_session)
|
|
2055
|
+
dispatch_handle_json(body_string, server_session, auth_info: auth_info)
|
|
1794
2056
|
rescue => e
|
|
1795
2057
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
|
|
1796
2058
|
end
|
|
@@ -1819,6 +2081,7 @@ module MCP
|
|
|
1819
2081
|
|
|
1820
2082
|
def handle_initialization(request, body_string, body)
|
|
1821
2083
|
session_id = nil
|
|
2084
|
+
auth_info = request.env[OAuth::ENV_KEY]
|
|
1822
2085
|
|
|
1823
2086
|
if @stateless
|
|
1824
2087
|
server_session = ephemeral_session
|
|
@@ -1844,9 +2107,15 @@ module MCP
|
|
|
1844
2107
|
get_sse_stream: nil,
|
|
1845
2108
|
server_session: server_session,
|
|
1846
2109
|
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
1847
|
-
# Captured for the built-in Origin-consistency defense in `
|
|
2110
|
+
# Captured for the built-in Origin-consistency defense in `session_request_rejection`.
|
|
1848
2111
|
# Not authentication.
|
|
1849
2112
|
origin: request.env["HTTP_ORIGIN"],
|
|
2113
|
+
# Principal binding per the spec's session-binding guidance: the session is bound to the token identity that initialized it,
|
|
2114
|
+
# and `session_request_rejection` rejects later requests presenting a token for a different principal. All three fields are nil
|
|
2115
|
+
# when the request was not bearer-authenticated, which disables the check.
|
|
2116
|
+
auth_issuer: auth_info&.issuer,
|
|
2117
|
+
auth_subject: auth_info&.subject,
|
|
2118
|
+
auth_client_id: auth_info&.client_id,
|
|
1850
2119
|
}
|
|
1851
2120
|
true
|
|
1852
2121
|
end
|
|
@@ -1855,7 +2124,7 @@ module MCP
|
|
|
1855
2124
|
return too_many_sessions_response unless inserted
|
|
1856
2125
|
end
|
|
1857
2126
|
|
|
1858
|
-
response = server_session.handle_json(body_string)
|
|
2127
|
+
response = server_session.handle_json(body_string, auth_info: auth_info)
|
|
1859
2128
|
|
|
1860
2129
|
# `initialize_request?` matches on the method alone, so an `initialize` sent without
|
|
1861
2130
|
# an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
|
|
@@ -1898,7 +2167,7 @@ module MCP
|
|
|
1898
2167
|
)
|
|
1899
2168
|
end
|
|
1900
2169
|
|
|
1901
|
-
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
2170
|
+
def handle_regular_request(body_string, session_id, related_request_id: nil, auth_info: nil)
|
|
1902
2171
|
server_session = nil
|
|
1903
2172
|
|
|
1904
2173
|
if @stateless
|
|
@@ -1922,9 +2191,9 @@ module MCP
|
|
|
1922
2191
|
end
|
|
1923
2192
|
|
|
1924
2193
|
if session_id && !@stateless && !@enable_json_response
|
|
1925
|
-
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
2194
|
+
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id, auth_info: auth_info)
|
|
1926
2195
|
else
|
|
1927
|
-
response = dispatch_handle_json(body_string, server_session)
|
|
2196
|
+
response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
|
|
1928
2197
|
|
|
1929
2198
|
# `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
|
|
1930
2199
|
# Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
|
|
@@ -1938,7 +2207,7 @@ module MCP
|
|
|
1938
2207
|
# Returns the POST response as an SSE stream so the server can send
|
|
1939
2208
|
# JSON-RPC requests and notifications during request processing.
|
|
1940
2209
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
1941
|
-
def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
|
|
2210
|
+
def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil, auth_info: nil)
|
|
1942
2211
|
body = proc do |stream|
|
|
1943
2212
|
@mutex.synchronize do
|
|
1944
2213
|
session = @sessions[session_id]
|
|
@@ -1953,7 +2222,7 @@ module MCP
|
|
|
1953
2222
|
end
|
|
1954
2223
|
|
|
1955
2224
|
begin
|
|
1956
|
-
response = dispatch_handle_json(body_string, server_session)
|
|
2225
|
+
response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
|
|
1957
2226
|
|
|
1958
2227
|
send_to_stream(stream, response) if response
|
|
1959
2228
|
ensure
|
|
@@ -1983,20 +2252,25 @@ module MCP
|
|
|
1983
2252
|
# When `related_request_id` is given, returns only the POST response
|
|
1984
2253
|
# stream for that request (no fallback to GET SSE). This prevents
|
|
1985
2254
|
# request-scoped messages from leaking to the wrong stream.
|
|
1986
|
-
# When `related_request_id` is nil, returns the GET SSE stream
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2255
|
+
# When `related_request_id` is nil, returns the GET SSE stream, or nil once the token that opened it
|
|
2256
|
+
# has expired: that stream is detached from the session and added to `streams_to_close` instead, so its
|
|
2257
|
+
# deadline holds at the next write and not only at the keepalive tick. Called under `@mutex`; the caller
|
|
2258
|
+
# closes the collected streams outside it.
|
|
2259
|
+
def active_stream(session, streams_to_close, related_request_id: nil)
|
|
2260
|
+
return session.dig(:post_request_streams, related_request_id) if related_request_id
|
|
2261
|
+
|
|
2262
|
+
stream = session[:get_sse_stream]
|
|
2263
|
+
return stream unless stream && token_expiry_passed?(session[:get_sse_stream_expires_at])
|
|
2264
|
+
|
|
2265
|
+
streams_to_close << detach_get_stream(session)
|
|
2266
|
+
nil
|
|
1993
2267
|
end
|
|
1994
2268
|
|
|
1995
|
-
def dispatch_handle_json(body_string, server_session)
|
|
2269
|
+
def dispatch_handle_json(body_string, server_session, auth_info: nil)
|
|
1996
2270
|
if server_session
|
|
1997
|
-
server_session.handle_json(body_string)
|
|
2271
|
+
server_session.handle_json(body_string, auth_info: auth_info)
|
|
1998
2272
|
else
|
|
1999
|
-
@server.handle_json(body_string)
|
|
2273
|
+
@server.handle_json(body_string, auth_info: auth_info)
|
|
2000
2274
|
end
|
|
2001
2275
|
end
|
|
2002
2276
|
|
|
@@ -2027,8 +2301,26 @@ module MCP
|
|
|
2027
2301
|
response
|
|
2028
2302
|
end
|
|
2029
2303
|
|
|
2030
|
-
|
|
2031
|
-
|
|
2304
|
+
# Whether the session holds a GET stream whose deadline has not passed. A stream whose token expired may still
|
|
2305
|
+
# be attached between its last delivery and its next keepalive tick; it is detached and closed here, so a client
|
|
2306
|
+
# reopening the stream with a fresh token is not turned away until one of those noticed.
|
|
2307
|
+
def live_get_stream?(session_id)
|
|
2308
|
+
detached = nil
|
|
2309
|
+
live = @mutex.synchronize do
|
|
2310
|
+
session = @sessions[session_id]
|
|
2311
|
+
stream = session && session[:get_sse_stream]
|
|
2312
|
+
if stream.nil?
|
|
2313
|
+
false
|
|
2314
|
+
elsif token_expiry_passed?(session[:get_sse_stream_expires_at])
|
|
2315
|
+
detached = detach_get_stream(session)
|
|
2316
|
+
false
|
|
2317
|
+
else
|
|
2318
|
+
true
|
|
2319
|
+
end
|
|
2320
|
+
end
|
|
2321
|
+
|
|
2322
|
+
close_stream_safely(detached) if detached
|
|
2323
|
+
live
|
|
2032
2324
|
end
|
|
2033
2325
|
|
|
2034
2326
|
def session_exists?(session_id)
|
|
@@ -2067,6 +2359,27 @@ module MCP
|
|
|
2067
2359
|
active
|
|
2068
2360
|
end
|
|
2069
2361
|
|
|
2362
|
+
# Built-in OAuth 2.1 bearer enforcement (active when `token_verifier:` is configured).
|
|
2363
|
+
# Returns nil on success, storing the verified `AccessToken` in `env["mcp.auth_info"]` where the dispatch paths
|
|
2364
|
+
# and `session_request_rejection` read it; returns the RFC 6750 challenge response on failure. OPTIONS is exempt:
|
|
2365
|
+
# CORS preflights never carry credentials, and rejecting them here would fail CORS closed for browser clients
|
|
2366
|
+
# while the transport still answers the preflight itself with 405 (an upstream CORS middleware normally intercepts it).
|
|
2367
|
+
def authenticate_request(request)
|
|
2368
|
+
return if @oauth_authenticator.nil?
|
|
2369
|
+
return if request.env["REQUEST_METHOD"] == "OPTIONS"
|
|
2370
|
+
|
|
2371
|
+
request.env[OAuth::ENV_KEY] = @oauth_authenticator.authenticate(request.env)
|
|
2372
|
+
nil
|
|
2373
|
+
rescue OAuth::Error => e
|
|
2374
|
+
@oauth_authenticator.challenge_response(e)
|
|
2375
|
+
rescue => e
|
|
2376
|
+
# A misbehaving verifier (e.g. an unreachable JWKS or introspection endpoint) is an internal failure:
|
|
2377
|
+
# report it and answer 500 without a challenge, so the client does not discard a perfectly good token.
|
|
2378
|
+
MCP.configuration.exception_reporter.call(e, { transport: self.class.name })
|
|
2379
|
+
|
|
2380
|
+
[500, { "content-type" => "application/json" }, [{ error: "server_error" }.to_json]]
|
|
2381
|
+
end
|
|
2382
|
+
|
|
2070
2383
|
# Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
|
|
2071
2384
|
# to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
|
|
2072
2385
|
# pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
|
|
@@ -2193,59 +2506,114 @@ module MCP
|
|
|
2193
2506
|
)
|
|
2194
2507
|
end
|
|
2195
2508
|
|
|
2196
|
-
def setup_sse_stream(session_id)
|
|
2197
|
-
body = create_sse_body(session_id)
|
|
2509
|
+
def setup_sse_stream(session_id, auth_info = nil)
|
|
2510
|
+
body = create_sse_body(session_id, auth_info)
|
|
2198
2511
|
|
|
2199
2512
|
[200, SSE_HEADERS.dup, body]
|
|
2200
2513
|
end
|
|
2201
2514
|
|
|
2202
|
-
def create_sse_body(session_id)
|
|
2515
|
+
def create_sse_body(session_id, auth_info = nil)
|
|
2203
2516
|
proc do |stream|
|
|
2204
|
-
stored = store_stream_for_session(session_id, stream)
|
|
2205
|
-
start_keepalive_thread(session_id) if stored
|
|
2517
|
+
stored = store_stream_for_session(session_id, stream, auth_info)
|
|
2518
|
+
start_keepalive_thread(session_id, stream) if stored
|
|
2206
2519
|
end
|
|
2207
2520
|
end
|
|
2208
2521
|
|
|
2209
|
-
|
|
2210
|
-
|
|
2522
|
+
# The expiry of the token that authenticated the GET is kept with the stream so the keepalive loop and
|
|
2523
|
+
# the delivery paths can close the stream once that token expires.
|
|
2524
|
+
def store_stream_for_session(session_id, stream, auth_info = nil)
|
|
2525
|
+
stored = @mutex.synchronize do
|
|
2211
2526
|
session = @sessions[session_id]
|
|
2212
|
-
|
|
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
|
|
2527
|
+
next false unless session && !session[:get_sse_stream]
|
|
2528
|
+
|
|
2529
|
+
session[:get_sse_stream] = stream
|
|
2530
|
+
session[:get_sse_stream_expires_at] = stream_token_expiry(auth_info)
|
|
2531
|
+
true
|
|
2221
2532
|
end
|
|
2533
|
+
|
|
2534
|
+
# The expiry is nil for a stream opened without a token, and callers read a falsy return as "not stored",
|
|
2535
|
+
# so the stream itself is the return value.
|
|
2536
|
+
return stream if stored
|
|
2537
|
+
|
|
2538
|
+
# Either the session was removed, or another request already established a stream. The refused stream is
|
|
2539
|
+
# closed outside the lock like every other stream this transport closes, so a close that blocks on the peer
|
|
2540
|
+
# cannot hold every other session on `@mutex`.
|
|
2541
|
+
close_stream_safely(stream)
|
|
2542
|
+
nil
|
|
2222
2543
|
end
|
|
2223
2544
|
|
|
2224
|
-
|
|
2545
|
+
# The thread acts on the stream it was started for and on no other: the session may have detached that
|
|
2546
|
+
# stream at a delivery once its token expired and the client may have opened a new one since, which gets
|
|
2547
|
+
# a keepalive thread of its own. The loop therefore ends as soon as the session no longer holds this stream,
|
|
2548
|
+
# and a loop that did not run to its end (a failed ping, or any other abnormal exit) drops the stream through
|
|
2549
|
+
# `drop_broken_stream`, which tears the session down only while it still holds that stream, as before.
|
|
2550
|
+
def start_keepalive_thread(session_id, stream)
|
|
2225
2551
|
Thread.new do
|
|
2226
|
-
|
|
2552
|
+
ended = false
|
|
2553
|
+
|
|
2554
|
+
while session_holds_stream?(session_id, stream)
|
|
2555
|
+
# Checked ahead of each tick so the stream does not outlive the token that opened it.
|
|
2556
|
+
# Revocation is not re-checked: the reference SDKs verify a stream at open only.
|
|
2557
|
+
# An expired token ends the stream alone: every other request of the session is verified on its own,
|
|
2558
|
+
# and the client may reopen the stream with a fresh token.
|
|
2559
|
+
if get_stream_token_expired?(session_id)
|
|
2560
|
+
close_get_stream(session_id, stream)
|
|
2561
|
+
break
|
|
2562
|
+
end
|
|
2563
|
+
|
|
2227
2564
|
sleep(30)
|
|
2228
|
-
|
|
2565
|
+
|
|
2566
|
+
send_keepalive_ping(session_id, stream)
|
|
2229
2567
|
end
|
|
2568
|
+
|
|
2569
|
+
ended = true
|
|
2230
2570
|
rescue StandardError => e
|
|
2231
2571
|
MCP.configuration.exception_reporter.call(e, { session_id: session_id })
|
|
2232
2572
|
ensure
|
|
2233
|
-
|
|
2573
|
+
drop_broken_stream(session_id, stream, nil) unless ended
|
|
2234
2574
|
end
|
|
2235
2575
|
end
|
|
2236
2576
|
|
|
2237
|
-
def
|
|
2238
|
-
@mutex.synchronize
|
|
2577
|
+
def session_holds_stream?(session_id, stream)
|
|
2578
|
+
@mutex.synchronize do
|
|
2579
|
+
session = @sessions[session_id]
|
|
2580
|
+
!session.nil? && session[:get_sse_stream].equal?(stream)
|
|
2581
|
+
end
|
|
2239
2582
|
end
|
|
2240
2583
|
|
|
2241
|
-
def
|
|
2242
|
-
|
|
2243
|
-
# cannot block every other session on `@mutex`.
|
|
2244
|
-
stream = @mutex.synchronize do
|
|
2584
|
+
def get_stream_token_expired?(session_id)
|
|
2585
|
+
expires_at = @mutex.synchronize do
|
|
2245
2586
|
session = @sessions[session_id]
|
|
2246
|
-
session && session[:
|
|
2587
|
+
session && session[:get_sse_stream_expires_at]
|
|
2247
2588
|
end
|
|
2248
|
-
|
|
2589
|
+
|
|
2590
|
+
token_expiry_passed?(expires_at)
|
|
2591
|
+
end
|
|
2592
|
+
|
|
2593
|
+
# Detaches and closes the session's GET stream when it is still `stream`, leaving the session in place.
|
|
2594
|
+
def close_get_stream(session_id, stream)
|
|
2595
|
+
detached = @mutex.synchronize do
|
|
2596
|
+
session = @sessions[session_id]
|
|
2597
|
+
next unless session && session[:get_sse_stream].equal?(stream)
|
|
2598
|
+
|
|
2599
|
+
detach_get_stream(session)
|
|
2600
|
+
end
|
|
2601
|
+
|
|
2602
|
+
close_stream_safely(detached) if detached
|
|
2603
|
+
end
|
|
2604
|
+
|
|
2605
|
+
# Removes the GET stream and its deadline from `session` under `@mutex` and returns the stream for closing
|
|
2606
|
+
# outside the lock.
|
|
2607
|
+
def detach_get_stream(session)
|
|
2608
|
+
session.delete(:get_sse_stream_expires_at)
|
|
2609
|
+
session.delete(:get_sse_stream)
|
|
2610
|
+
end
|
|
2611
|
+
|
|
2612
|
+
# Pings `stream` only while the session still holds it, checked under the lock and written outside it so
|
|
2613
|
+
# a stalled reader cannot block every other session on `@mutex`: a thread whose stream was detached during
|
|
2614
|
+
# its sleep must not write to the one the client opened since, which has a keepalive thread of its own.
|
|
2615
|
+
def send_keepalive_ping(session_id, stream)
|
|
2616
|
+
return unless session_holds_stream?(session_id, stream)
|
|
2249
2617
|
|
|
2250
2618
|
send_ping_to_stream(stream)
|
|
2251
2619
|
rescue *STREAM_WRITE_ERRORS => e
|