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