mcp 1.6.1 → 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 +105 -18
- data/lib/mcp/client/oauth/flow.rb +276 -23
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +77 -6
- data/lib/mcp/client/stdio.rb +23 -4
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- 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 +712 -160
- data/lib/mcp/server.rb +63 -31
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "set"
|
|
4
5
|
require_relative "../../result_type"
|
|
5
6
|
require_relative "../../transport"
|
|
6
7
|
|
|
@@ -48,6 +49,20 @@ module MCP
|
|
|
48
49
|
# pass `max_listen_subscriptions: nil` to opt out.
|
|
49
50
|
DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000
|
|
50
51
|
|
|
52
|
+
# Bound on the bytes of resource URIs one `subscriptions/listen` request may name in `resourceSubscriptions`
|
|
53
|
+
# (the sum of their byte lengths after duplicates are dropped). The URIs are held for the life of the stream,
|
|
54
|
+
# so without a bound each stream could retain a request body's worth of URIs and the retained total would be
|
|
55
|
+
# `max_listen_subscriptions` times `max_request_bytes`. 64 KiB names about a thousand ordinary URIs;
|
|
56
|
+
# pass `max_resource_subscription_bytes: nil` to opt out. The specification sets no limit, nor do the TypeScript
|
|
57
|
+
# and Python SDKs; this is the same kind of local safety limit as `max_request_bytes`.
|
|
58
|
+
DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES = 64 * 1024
|
|
59
|
+
|
|
60
|
+
# Bound on the number of distinct resource URIs one `subscriptions/listen` request may name. A byte bound does
|
|
61
|
+
# not tightly bound the memory a stream keeps, since each retained URI carries a fixed per-object cost however
|
|
62
|
+
# short it is, so the count is bounded as well. Like `MAX_JSON_NESTING`, it is a structural limit applied
|
|
63
|
+
# whatever `max_resource_subscription_bytes` is, including `nil`.
|
|
64
|
+
MAX_RESOURCE_SUBSCRIPTION_URIS = 1_024
|
|
65
|
+
|
|
51
66
|
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
|
|
52
67
|
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
|
53
68
|
private_constant :UNSET_IDLE_TIMEOUT
|
|
@@ -67,14 +82,27 @@ module MCP
|
|
|
67
82
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
|
|
68
83
|
DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
|
|
69
84
|
|
|
70
|
-
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# payload); raise `max_request_bytes:` for unusually large payloads. Matches the
|
|
75
|
-
# TypeScript SDK's 4 MB default.
|
|
85
|
+
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole body into memory and parses it,
|
|
86
|
+
# so without a cap a single unauthenticated POST can allocate gigabytes and OOM the worker. 4 MiB comfortably fits
|
|
87
|
+
# a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 payload); raise `max_request_bytes:`
|
|
88
|
+
# for unusually large payloads. Matches the TypeScript SDK's 4 MB default.
|
|
76
89
|
DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
77
90
|
|
|
91
|
+
# Bound on the body of an `initialize` request in stateful mode. Its `clientInfo` and `capabilities` are
|
|
92
|
+
# kept for the life of the session, so without a bound of its own each session could retain a whole
|
|
93
|
+
# `max_request_bytes` body and the retained total would be `max_sessions` times that. An ordinary
|
|
94
|
+
# `initialize` is under 2 KiB; 64 KiB leaves room for large `experimental` capabilities. Pass
|
|
95
|
+
# `max_initialize_request_bytes: nil` to opt out. The TypeScript and Python SDKs keep the initialize
|
|
96
|
+
# data whole with no bound of their own; this is the same kind of local safety limit as `max_request_bytes`.
|
|
97
|
+
DEFAULT_MAX_INITIALIZE_REQUEST_BYTES = 64 * 1024
|
|
98
|
+
|
|
99
|
+
# Bound on the number of JSON values (objects, arrays, keys, and scalars) in the `params` of an `initialize`
|
|
100
|
+
# request in stateful mode. A byte bound alone does not tightly bound the memory a session keeps: a body of
|
|
101
|
+
# many small values parses into far more objects than its size suggests, so the parsed structure is bounded
|
|
102
|
+
# as well. Like `MAX_JSON_NESTING`, it is a structural limit applied whatever `max_initialize_request_bytes`
|
|
103
|
+
# is, including `nil`. An ordinary `initialize` holds a few dozen values.
|
|
104
|
+
MAX_INITIALIZE_PARAMS_VALUES = 1_024
|
|
105
|
+
|
|
78
106
|
# Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
|
|
79
107
|
# the stack or amplify parse cost (complements the byte cap).
|
|
80
108
|
MAX_JSON_NESTING = 64
|
|
@@ -93,6 +121,18 @@ module MCP
|
|
|
93
121
|
# 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream.
|
|
94
122
|
DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15
|
|
95
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
|
+
|
|
96
136
|
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
|
|
97
137
|
#
|
|
98
138
|
# @param server [MCP::Server] the server whose requests this transport dispatches.
|
|
@@ -122,9 +162,19 @@ module MCP
|
|
|
122
162
|
# ownership is not enforced.
|
|
123
163
|
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
|
|
124
164
|
# requests are rejected with HTTP 413. Defaults to 4 MiB.
|
|
165
|
+
# @param max_initialize_request_bytes [Integer, nil] upper bound in bytes on the body of an `initialize`
|
|
166
|
+
# request in stateful mode, whose `clientInfo` and `capabilities` the session keeps; a larger one is
|
|
167
|
+
# rejected with HTTP 413 before any session is created. Defaults to `DEFAULT_MAX_INITIALIZE_REQUEST_BYTES`
|
|
168
|
+
# (64 KiB), and `nil` disables this bound, leaving `max_request_bytes` as the only cap on the body; `params`
|
|
169
|
+
# holding more than `MAX_INITIALIZE_PARAMS_VALUES` JSON values are rejected the same way either way.
|
|
170
|
+
# Neither applies in stateless mode, which retains nothing.
|
|
125
171
|
# @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
|
|
126
172
|
# streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
|
|
127
173
|
# the cap.
|
|
174
|
+
# @param max_resource_subscription_bytes [Integer, nil] bound on the total byte length of the distinct
|
|
175
|
+
# resource URIs one `subscriptions/listen` request names in `resourceSubscriptions`; a request over
|
|
176
|
+
# the bound is rejected with HTTP 400 and JSON-RPC `-32602`.
|
|
177
|
+
# Defaults to `DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES` (64 KiB), and `nil` disables the bound.
|
|
128
178
|
# @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
|
|
129
179
|
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
|
|
130
180
|
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
|
|
@@ -138,6 +188,28 @@ module MCP
|
|
|
138
188
|
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
|
|
139
189
|
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
|
|
140
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.
|
|
141
213
|
def initialize(
|
|
142
214
|
server,
|
|
143
215
|
stateless: false,
|
|
@@ -149,10 +221,18 @@ module MCP
|
|
|
149
221
|
dns_rebinding_protection: true,
|
|
150
222
|
session_request_validator: nil,
|
|
151
223
|
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
224
|
+
max_initialize_request_bytes: DEFAULT_MAX_INITIALIZE_REQUEST_BYTES,
|
|
152
225
|
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
|
226
|
+
max_resource_subscription_bytes: DEFAULT_MAX_RESOURCE_SUBSCRIPTION_BYTES,
|
|
153
227
|
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
154
228
|
serve_subscriptions_listen: true,
|
|
155
|
-
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
|
|
156
236
|
)
|
|
157
237
|
super(server)
|
|
158
238
|
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
|
|
@@ -170,8 +250,9 @@ module MCP
|
|
|
170
250
|
@pending_responses = {}
|
|
171
251
|
|
|
172
252
|
# Maps a key the transport mints for each `subscriptions/listen` stream to
|
|
173
|
-
# `{ request_id: listen_request_id, stream: stream_object, filter:
|
|
174
|
-
#
|
|
253
|
+
# `{ request_id: listen_request_id, stream: stream_object, filter: honored_list_changed_flags,
|
|
254
|
+
# resource_uris: Set_of_honored_resource_uris, active: boolean, write_mutex: Mutex,
|
|
255
|
+
# keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
|
|
175
256
|
# unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
|
|
176
257
|
# two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
|
|
177
258
|
# so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
|
|
@@ -212,12 +293,25 @@ module MCP
|
|
|
212
293
|
|
|
213
294
|
@max_request_bytes = max_request_bytes
|
|
214
295
|
|
|
296
|
+
if !max_initialize_request_bytes.nil? && !(max_initialize_request_bytes.is_a?(Integer) && max_initialize_request_bytes > 0)
|
|
297
|
+
raise ArgumentError, "max_initialize_request_bytes must be a positive Integer or nil"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# The bound guards what stateful sessions retain; stateless mode keeps none.
|
|
301
|
+
@max_initialize_request_bytes = stateless ? nil : max_initialize_request_bytes
|
|
302
|
+
|
|
215
303
|
if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
|
|
216
304
|
raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
|
|
217
305
|
end
|
|
218
306
|
|
|
219
307
|
@max_listen_subscriptions = max_listen_subscriptions
|
|
220
308
|
|
|
309
|
+
if !max_resource_subscription_bytes.nil? && !(max_resource_subscription_bytes.is_a?(Integer) && max_resource_subscription_bytes > 0)
|
|
310
|
+
raise ArgumentError, "max_resource_subscription_bytes must be a positive Integer or nil"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
@max_resource_subscription_bytes = max_resource_subscription_bytes
|
|
314
|
+
|
|
221
315
|
if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
|
|
222
316
|
raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
|
|
223
317
|
end
|
|
@@ -231,6 +325,32 @@ module MCP
|
|
|
231
325
|
|
|
232
326
|
@server_to_client_request_timeout = server_to_client_request_timeout
|
|
233
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
|
|
234
354
|
start_reaper_thread if @session_idle_timeout
|
|
235
355
|
end
|
|
236
356
|
|
|
@@ -239,6 +359,11 @@ module MCP
|
|
|
239
359
|
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
|
|
240
360
|
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
241
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
|
|
242
367
|
|
|
243
368
|
# Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
|
|
244
369
|
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
|
|
@@ -285,6 +410,11 @@ module MCP
|
|
|
285
410
|
rebinding_error = validate_dns_rebinding(request)
|
|
286
411
|
return rebinding_error if rebinding_error
|
|
287
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
|
+
|
|
288
418
|
# Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
|
|
289
419
|
# every supported list routes to the sessionless modern path, so an unknown future version receives
|
|
290
420
|
# the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
|
|
@@ -332,8 +462,28 @@ module MCP
|
|
|
332
462
|
end
|
|
333
463
|
end
|
|
334
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.
|
|
335
473
|
def close
|
|
336
|
-
@
|
|
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?
|
|
337
487
|
@reaper_thread = nil
|
|
338
488
|
|
|
339
489
|
teardown_listen_subscriptions
|
|
@@ -405,7 +555,7 @@ module MCP
|
|
|
405
555
|
next
|
|
406
556
|
end
|
|
407
557
|
|
|
408
|
-
active_stream(session, related_request_id: related_request_id)
|
|
558
|
+
active_stream(session, streams_to_close, related_request_id: related_request_id)
|
|
409
559
|
end
|
|
410
560
|
|
|
411
561
|
close_streams(streams_to_close)
|
|
@@ -422,14 +572,16 @@ module MCP
|
|
|
422
572
|
expired_session_ids = []
|
|
423
573
|
|
|
424
574
|
collected = @sessions.filter_map do |session_id, session|
|
|
425
|
-
next unless
|
|
575
|
+
next unless session[:get_sse_stream]
|
|
426
576
|
|
|
427
577
|
if session_expired?(session)
|
|
428
578
|
expired_session_ids << session_id
|
|
429
579
|
next
|
|
430
580
|
end
|
|
431
581
|
|
|
432
|
-
|
|
582
|
+
stream = active_stream(session, streams_to_close)
|
|
583
|
+
|
|
584
|
+
[session_id, stream] if stream
|
|
433
585
|
end
|
|
434
586
|
|
|
435
587
|
expired_session_ids.each do |session_id|
|
|
@@ -460,6 +612,11 @@ module MCP
|
|
|
460
612
|
# Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
|
|
461
613
|
# a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
|
|
462
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.
|
|
463
620
|
def drop_broken_stream(session_id, stream, related_request_id)
|
|
464
621
|
streams_to_close = []
|
|
465
622
|
|
|
@@ -473,7 +630,7 @@ module MCP
|
|
|
473
630
|
session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
|
|
474
631
|
|
|
475
632
|
streams_to_close << stream
|
|
476
|
-
|
|
633
|
+
elsif session && session[:get_sse_stream].equal?(stream)
|
|
477
634
|
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
478
635
|
end
|
|
479
636
|
end
|
|
@@ -531,6 +688,7 @@ module MCP
|
|
|
531
688
|
|
|
532
689
|
# Register the pending response and resolve the stream under the lock, but perform
|
|
533
690
|
# the write outside it so a stalled reader cannot block every other session on `@mutex`.
|
|
691
|
+
streams_to_close = []
|
|
534
692
|
stream = @mutex.synchronize do
|
|
535
693
|
unless (session = @sessions[session_id])
|
|
536
694
|
raise "Session not found: #{session_id}."
|
|
@@ -538,9 +696,11 @@ module MCP
|
|
|
538
696
|
|
|
539
697
|
@pending_responses[request_id] = { queue: pending_response, session_id: session_id }
|
|
540
698
|
|
|
541
|
-
active_stream(session, related_request_id: related_request_id)
|
|
699
|
+
active_stream(session, streams_to_close, related_request_id: related_request_id)
|
|
542
700
|
end
|
|
543
701
|
|
|
702
|
+
close_streams(streams_to_close)
|
|
703
|
+
|
|
544
704
|
sent = false
|
|
545
705
|
if stream
|
|
546
706
|
begin
|
|
@@ -641,10 +801,18 @@ module MCP
|
|
|
641
801
|
|
|
642
802
|
private
|
|
643
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.
|
|
644
806
|
def start_reaper_thread
|
|
645
807
|
@reaper_thread = Thread.new do
|
|
646
808
|
loop do
|
|
647
|
-
|
|
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
|
+
|
|
648
816
|
reap_expired_sessions
|
|
649
817
|
rescue StandardError => e
|
|
650
818
|
MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
|
|
@@ -743,13 +911,13 @@ module MCP
|
|
|
743
911
|
# to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
|
|
744
912
|
# serve an open SSE stream needs, instead of a `Proc` body it can never call.
|
|
745
913
|
if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
|
|
746
|
-
return handle_subscriptions_listen(body)
|
|
914
|
+
return handle_subscriptions_listen(body, auth_info: request.env[OAuth::ENV_KEY])
|
|
747
915
|
end
|
|
748
916
|
|
|
749
917
|
session = modern_session
|
|
750
918
|
notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
|
|
751
919
|
begin
|
|
752
|
-
response = @server.handle(body, session: session)
|
|
920
|
+
response = @server.handle(body, session: session, auth_info: request.env[OAuth::ENV_KEY])
|
|
753
921
|
ensure
|
|
754
922
|
@mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
|
|
755
923
|
end
|
|
@@ -841,7 +1009,7 @@ module MCP
|
|
|
841
1009
|
# (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
|
|
842
1010
|
# response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
|
|
843
1011
|
# `listen_keepalive_interval` seconds so a dropped connection frees its slot.
|
|
844
|
-
def handle_subscriptions_listen(body)
|
|
1012
|
+
def handle_subscriptions_listen(body, auth_info: nil)
|
|
845
1013
|
request_id = body[:id]
|
|
846
1014
|
params = body[:params]
|
|
847
1015
|
|
|
@@ -850,6 +1018,14 @@ module MCP
|
|
|
850
1018
|
return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
|
|
851
1019
|
end
|
|
852
1020
|
|
|
1021
|
+
# The id is echoed in every message of the stream, so one that cannot be written back as JSON
|
|
1022
|
+
# (a String holding bytes that are not valid UTF-8 parses, but does not generate) is refused here,
|
|
1023
|
+
# while the refusal can still answer with a null id, rather than surfacing as a failed write after
|
|
1024
|
+
# the stream has been registered.
|
|
1025
|
+
unless json_encodable?(request_id)
|
|
1026
|
+
return invalid_request_response("Invalid Request: subscriptions/listen id must be valid UTF-8")
|
|
1027
|
+
end
|
|
1028
|
+
|
|
853
1029
|
begin
|
|
854
1030
|
if RequestEnvelope.modern?(params)
|
|
855
1031
|
RequestEnvelope.parse!(params, request: params)
|
|
@@ -876,6 +1052,15 @@ module MCP
|
|
|
876
1052
|
)
|
|
877
1053
|
end
|
|
878
1054
|
|
|
1055
|
+
if (problem = resource_subscriptions_problem(filter))
|
|
1056
|
+
return json_rpc_error_response(
|
|
1057
|
+
status: 400,
|
|
1058
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
1059
|
+
message: "Invalid params: subscriptions/listen #{problem}",
|
|
1060
|
+
id: request_id,
|
|
1061
|
+
)
|
|
1062
|
+
end
|
|
1063
|
+
|
|
879
1064
|
# Best-effort cap check before committing to the SSE response; the registration inside
|
|
880
1065
|
# `listen_sse_body` re-checks atomically for the race between two concurrent listens
|
|
881
1066
|
# crossing the cap together.
|
|
@@ -883,7 +1068,43 @@ module MCP
|
|
|
883
1068
|
return too_many_listen_subscriptions_response(request_id)
|
|
884
1069
|
end
|
|
885
1070
|
|
|
886
|
-
|
|
1071
|
+
honored = honored_filter(filter)
|
|
1072
|
+
|
|
1073
|
+
# The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
|
|
1074
|
+
# so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
|
|
1075
|
+
# refused with a 400 instead of failing the first write after the stream was registered.
|
|
1076
|
+
unless (acknowledgement = listen_acknowledgement_json(request_id, honored))
|
|
1077
|
+
return json_rpc_error_response(
|
|
1078
|
+
status: 400,
|
|
1079
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
1080
|
+
message: "Invalid params: subscriptions/listen `notifications` must be valid UTF-8",
|
|
1081
|
+
id: request_id,
|
|
1082
|
+
)
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
[200, SSE_HEADERS.dup, listen_sse_body(request_id, honored, acknowledgement, auth_info)]
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
# The `notifications/subscriptions/acknowledged` frame for a listen stream as JSON, or `nil` when
|
|
1089
|
+
# the request id or the honored filter holds a string JSON cannot encode.
|
|
1090
|
+
def listen_acknowledgement_json(request_id, honored)
|
|
1091
|
+
{
|
|
1092
|
+
jsonrpc: "2.0",
|
|
1093
|
+
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
1094
|
+
params: {
|
|
1095
|
+
notifications: honored,
|
|
1096
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
1097
|
+
},
|
|
1098
|
+
}.to_json
|
|
1099
|
+
rescue JSON::GeneratorError
|
|
1100
|
+
nil
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def json_encodable?(value)
|
|
1104
|
+
value.to_json
|
|
1105
|
+
true
|
|
1106
|
+
rescue JSON::GeneratorError
|
|
1107
|
+
false
|
|
887
1108
|
end
|
|
888
1109
|
|
|
889
1110
|
def listen_subscriptions_full?
|
|
@@ -942,14 +1163,25 @@ module MCP
|
|
|
942
1163
|
# The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
|
|
943
1164
|
# the requesting client's own in-flight requests, and two clients that pick the same one must each get
|
|
944
1165
|
# their stream, stamped with the id they sent.
|
|
945
|
-
def listen_sse_body(request_id, honored)
|
|
1166
|
+
def listen_sse_body(request_id, honored, acknowledgement, auth_info = nil)
|
|
946
1167
|
ListenStreamBody.new do |stream|
|
|
947
1168
|
subscription_key = SecureRandom.uuid
|
|
948
1169
|
subscription = nil
|
|
1170
|
+
# The entry keeps the list-changed flags of the honored filter and the URIs as a frozen Set, both built ahead of the registry lock:
|
|
1171
|
+
# delivery looks each notification's URI up in constant time, so neither the matching cost nor the lock hold grows with
|
|
1172
|
+
# the number of URIs a stream named. The entry does not keep the Array the acknowledgement echoes; that one lives only as long as
|
|
1173
|
+
# the host keeps this response body.
|
|
1174
|
+
entry_filter = honored.reject { |name, _value| name == :resourceSubscriptions }
|
|
1175
|
+
resource_uris = Set.new(honored[:resourceSubscriptions] || []).freeze
|
|
1176
|
+
|
|
949
1177
|
@mutex.synchronize do
|
|
950
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.
|
|
951
1181
|
subscription = {
|
|
952
|
-
request_id: request_id, stream: stream, filter:
|
|
1182
|
+
request_id: request_id, stream: stream, filter: entry_filter, resource_uris: resource_uris, active: false,
|
|
1183
|
+
write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new,
|
|
1184
|
+
expires_at: stream_token_expiry(auth_info),
|
|
953
1185
|
}
|
|
954
1186
|
@listen_subscriptions[subscription_key] = subscription
|
|
955
1187
|
end
|
|
@@ -958,15 +1190,6 @@ module MCP
|
|
|
958
1190
|
if subscription.nil?
|
|
959
1191
|
close_stream_safely(stream)
|
|
960
1192
|
else
|
|
961
|
-
acknowledgement = {
|
|
962
|
-
jsonrpc: "2.0",
|
|
963
|
-
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
964
|
-
params: {
|
|
965
|
-
notifications: honored,
|
|
966
|
-
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
967
|
-
},
|
|
968
|
-
}
|
|
969
|
-
|
|
970
1193
|
begin
|
|
971
1194
|
acknowledged = subscription[:write_mutex].synchronize do
|
|
972
1195
|
next false if subscription[:closed]
|
|
@@ -984,14 +1207,11 @@ module MCP
|
|
|
984
1207
|
true
|
|
985
1208
|
end
|
|
986
1209
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
close_stream_safely(stream)
|
|
991
|
-
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
|
|
992
1213
|
rescue *STREAM_WRITE_ERRORS
|
|
993
|
-
remove_listen_subscription(subscription_key)
|
|
994
|
-
close_stream_safely(stream)
|
|
1214
|
+
close_removed_listen_stream(subscription) if remove_listen_subscription(subscription_key)
|
|
995
1215
|
end
|
|
996
1216
|
end
|
|
997
1217
|
end
|
|
@@ -1015,10 +1235,15 @@ module MCP
|
|
|
1015
1235
|
registered = @mutex.synchronize do
|
|
1016
1236
|
subscription = @listen_subscriptions[subscription_key]
|
|
1017
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])
|
|
1018
1241
|
|
|
1019
|
-
|
|
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]))
|
|
1020
1245
|
|
|
1021
|
-
@listen_subscriptions.key?(subscription_key)
|
|
1246
|
+
@listen_subscriptions.key?(subscription_key) && !token_expiry_passed?(subscription[:expires_at])
|
|
1022
1247
|
end
|
|
1023
1248
|
break unless registered
|
|
1024
1249
|
|
|
@@ -1032,11 +1257,9 @@ module MCP
|
|
|
1032
1257
|
# removed the entry already, and the report should still name the stream.
|
|
1033
1258
|
MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
|
|
1034
1259
|
ensure
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
end
|
|
1039
|
-
close_stream_safely(stream) if stream
|
|
1260
|
+
# Through the helper, so the entry is marked closed here as it is for every other removal.
|
|
1261
|
+
subscription = remove_listen_subscription(subscription_key)
|
|
1262
|
+
close_removed_listen_stream(subscription) if subscription
|
|
1040
1263
|
end
|
|
1041
1264
|
end
|
|
1042
1265
|
|
|
@@ -1056,6 +1279,40 @@ module MCP
|
|
|
1056
1279
|
end
|
|
1057
1280
|
end
|
|
1058
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
|
+
|
|
1059
1316
|
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
1060
1317
|
# and the acknowledgement only includes types the server actually supports
|
|
1061
1318
|
# (derived from its declared capabilities).
|
|
@@ -1068,12 +1325,36 @@ module MCP
|
|
|
1068
1325
|
|
|
1069
1326
|
subscriptions = filter[:resourceSubscriptions]
|
|
1070
1327
|
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
|
|
1071
|
-
honored[:resourceSubscriptions] = subscriptions
|
|
1328
|
+
honored[:resourceSubscriptions] = subscriptions.uniq
|
|
1072
1329
|
end
|
|
1073
1330
|
|
|
1074
1331
|
honored
|
|
1075
1332
|
end
|
|
1076
1333
|
|
|
1334
|
+
# Checks a listen filter's `resourceSubscriptions` against the schema, which types it as an optional array of strings,
|
|
1335
|
+
# so a member that is present but `null` is refused like any other non-array; against `MAX_RESOURCE_SUBSCRIPTION_URIS`;
|
|
1336
|
+
# and against `max_resource_subscription_bytes`, returning the problem to report or `nil`. Duplicates are dropped before
|
|
1337
|
+
# the bounds are measured, since they are dropped before storage too. The checks run whether or not the server declares
|
|
1338
|
+
# the `subscribe` capability: a request that violates the schema is invalid regardless of what would be honored,
|
|
1339
|
+
# and the bounds are limits on the request itself, like `max_request_bytes`, not on what the server would retain from it.
|
|
1340
|
+
def resource_subscriptions_problem(filter)
|
|
1341
|
+
return unless filter.key?(:resourceSubscriptions)
|
|
1342
|
+
|
|
1343
|
+
subscriptions = filter[:resourceSubscriptions]
|
|
1344
|
+
unless subscriptions.is_a?(Array) && subscriptions.all? { |uri| uri.is_a?(String) }
|
|
1345
|
+
return "`resourceSubscriptions` must be an array of strings"
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
distinct = subscriptions.uniq
|
|
1349
|
+
if distinct.size > MAX_RESOURCE_SUBSCRIPTION_URIS
|
|
1350
|
+
return "`resourceSubscriptions` exceeds #{MAX_RESOURCE_SUBSCRIPTION_URIS} URIs"
|
|
1351
|
+
end
|
|
1352
|
+
return unless @max_resource_subscription_bytes
|
|
1353
|
+
return if distinct.sum(&:bytesize) <= @max_resource_subscription_bytes
|
|
1354
|
+
|
|
1355
|
+
"`resourceSubscriptions` exceeds #{@max_resource_subscription_bytes} bytes"
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1077
1358
|
# Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
|
|
1078
1359
|
# in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
|
|
1079
1360
|
# `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
|
|
@@ -1091,24 +1372,34 @@ module MCP
|
|
|
1091
1372
|
field = LISTEN_FILTER_FIELDS[method]
|
|
1092
1373
|
return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
|
|
1093
1374
|
|
|
1094
|
-
#
|
|
1095
|
-
#
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1375
|
+
# Only the snapshot of active entries is taken under `@mutex`, one pass over the entries as before;
|
|
1376
|
+
# the matching and the stream writes happen outside it, so neither the number of URIs the streams named
|
|
1377
|
+
# nor a slow or stalled subscriber can hold the transport's lock, matching the legacy delivery paths.
|
|
1378
|
+
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1379
|
+
# delivering to it would put a notification ahead of the acknowledgement.
|
|
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
|
|
1101
1393
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
else
|
|
1105
|
-
uris = subscription[:filter][:resourceSubscriptions]
|
|
1106
|
-
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1107
|
-
uris.is_a?(Array) && uris.include?(uri)
|
|
1108
|
-
end
|
|
1394
|
+
expired_keys.each do |key|
|
|
1395
|
+
subscription = remove_listen_subscription(key)
|
|
1109
1396
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1397
|
+
close_removed_listen_stream(subscription) if subscription
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1401
|
+
matched = candidates.select do |_key, subscription|
|
|
1402
|
+
field ? subscription[:filter][field] : subscription[:resource_uris].include?(uri)
|
|
1112
1403
|
end
|
|
1113
1404
|
|
|
1114
1405
|
matched.each do |subscription_key, subscription|
|
|
@@ -1131,8 +1422,9 @@ module MCP
|
|
|
1131
1422
|
e,
|
|
1132
1423
|
{ subscription_id: subscription[:request_id], error: "Failed to send notification" },
|
|
1133
1424
|
)
|
|
1134
|
-
|
|
1135
|
-
|
|
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)
|
|
1136
1428
|
end
|
|
1137
1429
|
end
|
|
1138
1430
|
end
|
|
@@ -1140,15 +1432,36 @@ module MCP
|
|
|
1140
1432
|
def remove_listen_subscription(subscription_key)
|
|
1141
1433
|
@mutex.synchronize do
|
|
1142
1434
|
subscription = @listen_subscriptions.delete(subscription_key)
|
|
1143
|
-
|
|
1435
|
+
if subscription
|
|
1436
|
+
# Marked closed as teardown does, so a delivery or keepalive that took the entry before the removal skips it under
|
|
1437
|
+
# the write mutex instead of writing to a stream being closed. The flag is set without taking the write mutex,
|
|
1438
|
+
# which must never be taken inside `@mutex`: a write that checks the flag from here on skips, and one already
|
|
1439
|
+
# past its check lands before the stream closes, since `close_removed_listen_stream` waits for the write mutex.
|
|
1440
|
+
subscription[:closed] = true
|
|
1441
|
+
subscription[:keepalive_wakeup].signal
|
|
1442
|
+
end
|
|
1144
1443
|
|
|
1145
1444
|
subscription
|
|
1146
1445
|
end
|
|
1147
1446
|
end
|
|
1148
1447
|
|
|
1448
|
+
# Closes a subscription's stream while excluding concurrent writes. The write mutex, taken here outside
|
|
1449
|
+
# `@mutex`, makes the close wait for a write already past its `closed` check, so that write completes or fails
|
|
1450
|
+
# before the stream is closed, and a write that takes the mutex afterwards sees `closed` and skips. Teardown
|
|
1451
|
+
# gets the same ordering by setting `closed` under the write mutex itself.
|
|
1452
|
+
def close_removed_listen_stream(subscription)
|
|
1453
|
+
subscription[:write_mutex].synchronize { close_stream_safely(subscription[:stream]) }
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1149
1456
|
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
|
|
1150
|
-
# before the stream closes.
|
|
1151
|
-
|
|
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)
|
|
1152
1465
|
removed = @mutex.synchronize do
|
|
1153
1466
|
subscriptions = @listen_subscriptions.dup
|
|
1154
1467
|
@listen_subscriptions.clear
|
|
@@ -1159,35 +1472,72 @@ module MCP
|
|
|
1159
1472
|
|
|
1160
1473
|
subscriptions
|
|
1161
1474
|
end
|
|
1475
|
+
return if removed.empty?
|
|
1162
1476
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
|
1169
1487
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
|
1173
1493
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
|
1189
1528
|
end
|
|
1190
|
-
|
|
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
|
|
1191
1541
|
end
|
|
1192
1542
|
end
|
|
1193
1543
|
|
|
@@ -1296,13 +1646,31 @@ module MCP
|
|
|
1296
1646
|
end
|
|
1297
1647
|
|
|
1298
1648
|
if initialize_request?(body)
|
|
1649
|
+
# Checked before a session exists: the body's `clientInfo` and `capabilities` are what the session would keep,
|
|
1650
|
+
# so an oversized `initialize` is refused outright instead of being stored and capped later.
|
|
1651
|
+
if @max_initialize_request_bytes && body_string.bytesize > @max_initialize_request_bytes
|
|
1652
|
+
return initialize_too_large_response(body[:id], "body exceeds #{@max_initialize_request_bytes} bytes")
|
|
1653
|
+
end
|
|
1654
|
+
|
|
1655
|
+
if !@stateless && json_values_exceed?(body[:params], MAX_INITIALIZE_PARAMS_VALUES)
|
|
1656
|
+
return initialize_too_large_response(body[:id], "params exceed #{MAX_INITIALIZE_PARAMS_VALUES} JSON values")
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1299
1659
|
if !@stateless && session_id
|
|
1300
1660
|
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
|
|
1301
1661
|
# initialization attempt against a live session, or a retry against an unknown/expired
|
|
1302
1662
|
# one. In the live case, reject with `-32600` so the original session is not abandoned.
|
|
1303
1663
|
# In the unknown/expired case, return 404 so the client retries from scratch instead
|
|
1304
1664
|
# of silently inheriting a fresh session under the old ID.
|
|
1305
|
-
|
|
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
|
|
1306
1674
|
|
|
1307
1675
|
return session_not_found_response
|
|
1308
1676
|
end
|
|
@@ -1312,8 +1680,10 @@ module MCP
|
|
|
1312
1680
|
# Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
|
|
1313
1681
|
# and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
|
|
1314
1682
|
# otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
|
|
1315
|
-
if !@stateless && session_id
|
|
1316
|
-
|
|
1683
|
+
if !@stateless && session_id
|
|
1684
|
+
rejection = session_request_rejection(request, session_id)
|
|
1685
|
+
|
|
1686
|
+
return rejection if rejection
|
|
1317
1687
|
end
|
|
1318
1688
|
|
|
1319
1689
|
if notification?(body)
|
|
@@ -1322,14 +1692,14 @@ module MCP
|
|
|
1322
1692
|
# branches; without it a custom notification handler could run without a live session.
|
|
1323
1693
|
return session_not_found_response if !@stateless && !session_active?(session_id)
|
|
1324
1694
|
|
|
1325
|
-
dispatch_notification(body_string, session_id)
|
|
1695
|
+
dispatch_notification(body_string, session_id, auth_info: request.env[OAuth::ENV_KEY])
|
|
1326
1696
|
handle_accepted
|
|
1327
1697
|
elsif response?(body)
|
|
1328
1698
|
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
1329
1699
|
|
|
1330
1700
|
handle_response(body, session_id: session_id)
|
|
1331
1701
|
else
|
|
1332
|
-
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])
|
|
1333
1703
|
end
|
|
1334
1704
|
end
|
|
1335
1705
|
rescue StandardError => e
|
|
@@ -1353,16 +1723,20 @@ module MCP
|
|
|
1353
1723
|
|
|
1354
1724
|
return missing_session_id_response unless session_id
|
|
1355
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
|
+
|
|
1356
1731
|
error_response = validate_and_touch_session(session_id)
|
|
1357
1732
|
return error_response if error_response
|
|
1358
|
-
return forbidden_response unless validate_session_request(request, session_id)
|
|
1359
1733
|
|
|
1360
1734
|
protocol_version_error = validate_protocol_version_header(request)
|
|
1361
1735
|
return protocol_version_error if protocol_version_error
|
|
1362
1736
|
|
|
1363
|
-
return session_already_connected_response if
|
|
1737
|
+
return session_already_connected_response if live_get_stream?(session_id)
|
|
1364
1738
|
|
|
1365
|
-
setup_sse_stream(session_id)
|
|
1739
|
+
setup_sse_stream(session_id, request.env[OAuth::ENV_KEY])
|
|
1366
1740
|
end
|
|
1367
1741
|
|
|
1368
1742
|
def handle_delete(request)
|
|
@@ -1378,16 +1752,22 @@ module MCP
|
|
|
1378
1752
|
|
|
1379
1753
|
return missing_session_id_response unless (session_id = extract_session_id(request))
|
|
1380
1754
|
return session_not_found_response unless session_exists?(session_id)
|
|
1381
|
-
|
|
1755
|
+
rejection = session_request_rejection(request, session_id)
|
|
1756
|
+
return rejection if rejection
|
|
1382
1757
|
|
|
1383
1758
|
protocol_version_error = validate_protocol_version_header(request)
|
|
1384
1759
|
return protocol_version_error if protocol_version_error
|
|
1385
1760
|
|
|
1386
|
-
|
|
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)
|
|
1387
1765
|
|
|
1388
1766
|
success_response
|
|
1389
1767
|
end
|
|
1390
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.
|
|
1391
1771
|
def cleanup_session(session_id)
|
|
1392
1772
|
session = @mutex.synchronize do
|
|
1393
1773
|
cleanup_session_unsafe(session_id)
|
|
@@ -1397,6 +1777,8 @@ module MCP
|
|
|
1397
1777
|
close_stream_safely(session[:get_sse_stream])
|
|
1398
1778
|
close_post_request_streams(session)
|
|
1399
1779
|
end
|
|
1780
|
+
|
|
1781
|
+
session
|
|
1400
1782
|
end
|
|
1401
1783
|
|
|
1402
1784
|
# Removes a session from `@sessions` and returns it. Does not close the stream.
|
|
@@ -1442,23 +1824,48 @@ module MCP
|
|
|
1442
1824
|
|
|
1443
1825
|
# Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
|
|
1444
1826
|
# The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
|
|
1445
|
-
#
|
|
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:
|
|
1446
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.
|
|
1447
1835
|
# - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
|
|
1448
|
-
# 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,
|
|
1449
1837
|
# so non-browser clients that send no `Origin` are unaffected.
|
|
1450
|
-
# - The application-supplied `session_request_validator`, which can enforce
|
|
1451
|
-
#
|
|
1452
|
-
|
|
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)
|
|
1453
1843
|
session = @mutex.synchronize { @sessions[session_id] }
|
|
1454
|
-
return
|
|
1844
|
+
return unless session
|
|
1845
|
+
return session_not_found_response unless session_principal_matches?(session, request)
|
|
1455
1846
|
|
|
1456
1847
|
session_origin = session[:origin]
|
|
1457
1848
|
request_origin = request.env["HTTP_ORIGIN"]
|
|
1458
|
-
return
|
|
1459
|
-
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
|
|
1460
1850
|
|
|
1461
|
-
|
|
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]
|
|
1462
1869
|
end
|
|
1463
1870
|
|
|
1464
1871
|
def validate_accept_header(request, required_types)
|
|
@@ -1524,6 +1931,45 @@ module MCP
|
|
|
1524
1931
|
)
|
|
1525
1932
|
end
|
|
1526
1933
|
|
|
1934
|
+
def initialize_too_large_response(request_id, reason)
|
|
1935
|
+
json_rpc_error_response(
|
|
1936
|
+
status: 413,
|
|
1937
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1938
|
+
message: "Payload too large: initialize request #{reason}",
|
|
1939
|
+
id: request_id,
|
|
1940
|
+
)
|
|
1941
|
+
end
|
|
1942
|
+
|
|
1943
|
+
# Counts the JSON values in `value` (every object, array, object key, and scalar) and reports whether
|
|
1944
|
+
# they exceed `limit`. The walk is iterative. Every value still queued is at least one more value,
|
|
1945
|
+
# so `count + pending.size` never overstates the total, and a container is refused before its members are
|
|
1946
|
+
# queued if they would take that sum past the limit; the walk therefore stops as soon as the limit is
|
|
1947
|
+
# certain to be passed and never queues more than `limit` values, whatever the shape of the input.
|
|
1948
|
+
def json_values_exceed?(value, limit)
|
|
1949
|
+
count = 0
|
|
1950
|
+
pending = [value]
|
|
1951
|
+
|
|
1952
|
+
until pending.empty?
|
|
1953
|
+
current = pending.pop
|
|
1954
|
+
count += 1
|
|
1955
|
+
|
|
1956
|
+
case current
|
|
1957
|
+
when Hash
|
|
1958
|
+
count += current.size
|
|
1959
|
+
return true if count + pending.size + current.size > limit
|
|
1960
|
+
|
|
1961
|
+
pending.concat(current.values)
|
|
1962
|
+
when Array
|
|
1963
|
+
return true if count + pending.size + current.size > limit
|
|
1964
|
+
|
|
1965
|
+
pending.concat(current)
|
|
1966
|
+
end
|
|
1967
|
+
return true if count + pending.size > limit
|
|
1968
|
+
end
|
|
1969
|
+
|
|
1970
|
+
false
|
|
1971
|
+
end
|
|
1972
|
+
|
|
1527
1973
|
def parse_request_body(body_string)
|
|
1528
1974
|
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
|
|
1529
1975
|
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
|
|
@@ -1595,7 +2041,7 @@ module MCP
|
|
|
1595
2041
|
|
|
1596
2042
|
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
|
|
1597
2043
|
# `notifications/initialized`) through the server so it can update session state.
|
|
1598
|
-
def dispatch_notification(body_string, session_id)
|
|
2044
|
+
def dispatch_notification(body_string, session_id, auth_info: nil)
|
|
1599
2045
|
server_session = nil
|
|
1600
2046
|
if @stateless
|
|
1601
2047
|
server_session = ephemeral_session
|
|
@@ -1606,7 +2052,7 @@ module MCP
|
|
|
1606
2052
|
end
|
|
1607
2053
|
end
|
|
1608
2054
|
|
|
1609
|
-
dispatch_handle_json(body_string, server_session)
|
|
2055
|
+
dispatch_handle_json(body_string, server_session, auth_info: auth_info)
|
|
1610
2056
|
rescue => e
|
|
1611
2057
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
|
|
1612
2058
|
end
|
|
@@ -1635,6 +2081,7 @@ module MCP
|
|
|
1635
2081
|
|
|
1636
2082
|
def handle_initialization(request, body_string, body)
|
|
1637
2083
|
session_id = nil
|
|
2084
|
+
auth_info = request.env[OAuth::ENV_KEY]
|
|
1638
2085
|
|
|
1639
2086
|
if @stateless
|
|
1640
2087
|
server_session = ephemeral_session
|
|
@@ -1660,9 +2107,15 @@ module MCP
|
|
|
1660
2107
|
get_sse_stream: nil,
|
|
1661
2108
|
server_session: server_session,
|
|
1662
2109
|
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
1663
|
-
# Captured for the built-in Origin-consistency defense in `
|
|
2110
|
+
# Captured for the built-in Origin-consistency defense in `session_request_rejection`.
|
|
1664
2111
|
# Not authentication.
|
|
1665
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,
|
|
1666
2119
|
}
|
|
1667
2120
|
true
|
|
1668
2121
|
end
|
|
@@ -1671,7 +2124,7 @@ module MCP
|
|
|
1671
2124
|
return too_many_sessions_response unless inserted
|
|
1672
2125
|
end
|
|
1673
2126
|
|
|
1674
|
-
response = server_session.handle_json(body_string)
|
|
2127
|
+
response = server_session.handle_json(body_string, auth_info: auth_info)
|
|
1675
2128
|
|
|
1676
2129
|
# `initialize_request?` matches on the method alone, so an `initialize` sent without
|
|
1677
2130
|
# an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
|
|
@@ -1714,7 +2167,7 @@ module MCP
|
|
|
1714
2167
|
)
|
|
1715
2168
|
end
|
|
1716
2169
|
|
|
1717
|
-
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)
|
|
1718
2171
|
server_session = nil
|
|
1719
2172
|
|
|
1720
2173
|
if @stateless
|
|
@@ -1738,9 +2191,9 @@ module MCP
|
|
|
1738
2191
|
end
|
|
1739
2192
|
|
|
1740
2193
|
if session_id && !@stateless && !@enable_json_response
|
|
1741
|
-
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)
|
|
1742
2195
|
else
|
|
1743
|
-
response = dispatch_handle_json(body_string, server_session)
|
|
2196
|
+
response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
|
|
1744
2197
|
|
|
1745
2198
|
# `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
|
|
1746
2199
|
# Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
|
|
@@ -1754,7 +2207,7 @@ module MCP
|
|
|
1754
2207
|
# Returns the POST response as an SSE stream so the server can send
|
|
1755
2208
|
# JSON-RPC requests and notifications during request processing.
|
|
1756
2209
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
1757
|
-
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)
|
|
1758
2211
|
body = proc do |stream|
|
|
1759
2212
|
@mutex.synchronize do
|
|
1760
2213
|
session = @sessions[session_id]
|
|
@@ -1769,7 +2222,7 @@ module MCP
|
|
|
1769
2222
|
end
|
|
1770
2223
|
|
|
1771
2224
|
begin
|
|
1772
|
-
response = dispatch_handle_json(body_string, server_session)
|
|
2225
|
+
response = dispatch_handle_json(body_string, server_session, auth_info: auth_info)
|
|
1773
2226
|
|
|
1774
2227
|
send_to_stream(stream, response) if response
|
|
1775
2228
|
ensure
|
|
@@ -1799,20 +2252,25 @@ module MCP
|
|
|
1799
2252
|
# When `related_request_id` is given, returns only the POST response
|
|
1800
2253
|
# stream for that request (no fallback to GET SSE). This prevents
|
|
1801
2254
|
# request-scoped messages from leaking to the wrong stream.
|
|
1802
|
-
# When `related_request_id` is nil, returns the GET SSE stream
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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
|
|
1809
2267
|
end
|
|
1810
2268
|
|
|
1811
|
-
def dispatch_handle_json(body_string, server_session)
|
|
2269
|
+
def dispatch_handle_json(body_string, server_session, auth_info: nil)
|
|
1812
2270
|
if server_session
|
|
1813
|
-
server_session.handle_json(body_string)
|
|
2271
|
+
server_session.handle_json(body_string, auth_info: auth_info)
|
|
1814
2272
|
else
|
|
1815
|
-
@server.handle_json(body_string)
|
|
2273
|
+
@server.handle_json(body_string, auth_info: auth_info)
|
|
1816
2274
|
end
|
|
1817
2275
|
end
|
|
1818
2276
|
|
|
@@ -1843,8 +2301,26 @@ module MCP
|
|
|
1843
2301
|
response
|
|
1844
2302
|
end
|
|
1845
2303
|
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
|
1848
2324
|
end
|
|
1849
2325
|
|
|
1850
2326
|
def session_exists?(session_id)
|
|
@@ -1883,6 +2359,27 @@ module MCP
|
|
|
1883
2359
|
active
|
|
1884
2360
|
end
|
|
1885
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
|
+
|
|
1886
2383
|
# Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
|
|
1887
2384
|
# to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
|
|
1888
2385
|
# pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
|
|
@@ -2009,59 +2506,114 @@ module MCP
|
|
|
2009
2506
|
)
|
|
2010
2507
|
end
|
|
2011
2508
|
|
|
2012
|
-
def setup_sse_stream(session_id)
|
|
2013
|
-
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)
|
|
2014
2511
|
|
|
2015
2512
|
[200, SSE_HEADERS.dup, body]
|
|
2016
2513
|
end
|
|
2017
2514
|
|
|
2018
|
-
def create_sse_body(session_id)
|
|
2515
|
+
def create_sse_body(session_id, auth_info = nil)
|
|
2019
2516
|
proc do |stream|
|
|
2020
|
-
stored = store_stream_for_session(session_id, stream)
|
|
2021
|
-
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
|
|
2022
2519
|
end
|
|
2023
2520
|
end
|
|
2024
2521
|
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
|
2027
2526
|
session = @sessions[session_id]
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
# `stream.close` may return a truthy value depending on the stream class.
|
|
2034
|
-
# Explicitly return nil to guarantee a falsy return for callers.
|
|
2035
|
-
nil
|
|
2036
|
-
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
|
|
2037
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
|
|
2038
2543
|
end
|
|
2039
2544
|
|
|
2040
|
-
|
|
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)
|
|
2041
2551
|
Thread.new do
|
|
2042
|
-
|
|
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
|
+
|
|
2043
2564
|
sleep(30)
|
|
2044
|
-
|
|
2565
|
+
|
|
2566
|
+
send_keepalive_ping(session_id, stream)
|
|
2045
2567
|
end
|
|
2568
|
+
|
|
2569
|
+
ended = true
|
|
2046
2570
|
rescue StandardError => e
|
|
2047
2571
|
MCP.configuration.exception_reporter.call(e, { session_id: session_id })
|
|
2048
2572
|
ensure
|
|
2049
|
-
|
|
2573
|
+
drop_broken_stream(session_id, stream, nil) unless ended
|
|
2050
2574
|
end
|
|
2051
2575
|
end
|
|
2052
2576
|
|
|
2053
|
-
def
|
|
2054
|
-
@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
|
|
2055
2582
|
end
|
|
2056
2583
|
|
|
2057
|
-
def
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2584
|
+
def get_stream_token_expired?(session_id)
|
|
2585
|
+
expires_at = @mutex.synchronize do
|
|
2586
|
+
session = @sessions[session_id]
|
|
2587
|
+
session && session[:get_sse_stream_expires_at]
|
|
2588
|
+
end
|
|
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
|
|
2061
2596
|
session = @sessions[session_id]
|
|
2062
|
-
session && session[:get_sse_stream]
|
|
2597
|
+
next unless session && session[:get_sse_stream].equal?(stream)
|
|
2598
|
+
|
|
2599
|
+
detach_get_stream(session)
|
|
2063
2600
|
end
|
|
2064
|
-
|
|
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)
|
|
2065
2617
|
|
|
2066
2618
|
send_ping_to_stream(stream)
|
|
2067
2619
|
rescue *STREAM_WRITE_ERRORS => e
|