parse-stack-next 5.1.1 → 5.2.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/.env.sample +12 -0
- data/.env.test +4 -4
- data/CHANGELOG.md +545 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +6 -1
- data/README.md +167 -38
- data/Rakefile +56 -10
- data/docs/atlas_vector_search_guide.md +110 -9
- data/docs/mcp_guide.md +433 -0
- data/docs/mongodb_direct_guide.md +66 -1
- data/docs/mongodb_index_optimization_guide.md +22 -1
- data/docs/usage_guide.md +15 -0
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +90 -19
- data/lib/parse/agent/describe.rb +1 -0
- data/lib/parse/agent/errors.rb +16 -0
- data/lib/parse/agent/mcp_client.rb +9 -0
- data/lib/parse/agent/mcp_dispatcher.rb +139 -7
- data/lib/parse/agent/mcp_rack_app.rb +621 -17
- data/lib/parse/agent/mcp_subscriptions.rb +607 -0
- data/lib/parse/agent/metadata_dsl.rb +58 -0
- data/lib/parse/agent/metadata_registry.rb +141 -1
- data/lib/parse/agent/prompt_hardening.rb +213 -0
- data/lib/parse/agent/result_formatter.rb +18 -3
- data/lib/parse/agent/tools.rb +167 -24
- data/lib/parse/agent.rb +692 -21
- data/lib/parse/client/request.rb +55 -4
- data/lib/parse/client/response.rb +4 -0
- data/lib/parse/client.rb +205 -7
- data/lib/parse/model/classes/installation.rb +27 -10
- data/lib/parse/model/classes/user.rb +8 -0
- data/lib/parse/model/core/actions.rb +58 -4
- data/lib/parse/model/core/embed_managed.rb +19 -14
- data/lib/parse/model/core/indexing.rb +108 -16
- data/lib/parse/model/core/querying.rb +29 -0
- data/lib/parse/model/model.rb +34 -3
- data/lib/parse/model/object.rb +1 -0
- data/lib/parse/query.rb +90 -24
- data/lib/parse/retrieval/agent_tool.rb +369 -0
- data/lib/parse/retrieval/chunk.rb +74 -0
- data/lib/parse/retrieval/chunker.rb +208 -0
- data/lib/parse/retrieval/retriever.rb +274 -0
- data/lib/parse/retrieval.rb +10 -0
- data/lib/parse/schema.rb +69 -20
- data/lib/parse/stack/version.rb +2 -2
- data/parse-stack-next.gemspec +1 -1
- data/scripts/docker/docker-compose.atlas.yml +14 -10
- data/scripts/docker/docker-compose.test.yml +24 -20
- data/scripts/docker/mongo-init.js +3 -3
- data/scripts/start-parse.sh +10 -0
- data/scripts/start_mcp_server.rb +1 -1
- data/scripts/test_server_connection.rb +1 -1
- data/scripts/vector_prototype/create_vector_index.js +1 -1
- data/scripts/vector_prototype/fetch_embeddings.py +2 -2
- data/scripts/vector_prototype/query_prototype.rb +1 -1
- data/scripts/vector_prototype/run.sh +4 -4
- metadata +10 -2
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "securerandom"
|
|
6
|
+
require "digest"
|
|
6
7
|
require_relative "errors"
|
|
7
8
|
require_relative "mcp_dispatcher"
|
|
9
|
+
require_relative "mcp_subscriptions"
|
|
8
10
|
require_relative "cancellation_token"
|
|
9
11
|
|
|
10
12
|
module Parse
|
|
@@ -81,6 +83,12 @@ module Parse
|
|
|
81
83
|
# Default heartbeat interval in seconds when streaming is enabled.
|
|
82
84
|
DEFAULT_HEARTBEAT_INTERVAL = 2
|
|
83
85
|
|
|
86
|
+
# Seconds to wait for a human's elicitation reply before failing
|
|
87
|
+
# closed (refusing the destructive op). Generous by default — a
|
|
88
|
+
# human-in-the-loop approver needs time the tool timeout doesn't
|
|
89
|
+
# allow. Tune via `approval_timeout:`.
|
|
90
|
+
DEFAULT_APPROVAL_TIMEOUT = 300
|
|
91
|
+
|
|
84
92
|
# Standard Content-Type for all JSON responses. Frozen template — call
|
|
85
93
|
# {#json_headers} to obtain a per-response mutable copy that composes
|
|
86
94
|
# with Rack middleware that decorates response headers (e.g. Sinatra's
|
|
@@ -96,6 +104,12 @@ module Parse
|
|
|
96
104
|
"X-Accel-Buffering" => "no",
|
|
97
105
|
}.freeze
|
|
98
106
|
|
|
107
|
+
# Process-wide live-listening-stream counter (see
|
|
108
|
+
# {.active_listening_stream_count}). Class-instance state shared across all
|
|
109
|
+
# MCPRackApp instances in the process.
|
|
110
|
+
@listening_stream_count = 0
|
|
111
|
+
@listening_stream_mutex = Mutex.new
|
|
112
|
+
|
|
99
113
|
# Drop env keys that would have come from underscore-form HTTP header
|
|
100
114
|
# names. The Rack-spec-compliant interpretation of HTTP headers maps
|
|
101
115
|
# `X-MCP-API-Key` and `X_MCP_API_KEY` to the same env key
|
|
@@ -203,6 +217,23 @@ module Parse
|
|
|
203
217
|
# by design. `nil` (default) disables the endpoint entirely;
|
|
204
218
|
# empty-string values are coerced to `nil`. Any non-GET method
|
|
205
219
|
# on the path falls through to the standard 405 handler.
|
|
220
|
+
# @param resource_subscriptions [Boolean] enable MCP resource
|
|
221
|
+
# subscriptions (`resources/subscribe` + `notifications/resources/updated`)
|
|
222
|
+
# bridged onto Parse LiveQuery. Defaults to false. When true, this app
|
|
223
|
+
# accepts a `GET` with `Accept: text/event-stream` and an
|
|
224
|
+
# `Mcp-Session-Id` header as a long-lived server→client listening
|
|
225
|
+
# stream, and advertises the `resources.subscribe` capability on
|
|
226
|
+
# `initialize` — but ONLY while LiveQuery is enabled
|
|
227
|
+
# (`Parse.live_query_enabled = true`) and available (a `live_query_url`
|
|
228
|
+
# is configured). Requires a streaming-capable Rack server (Puma,
|
|
229
|
+
# Falcon); WEBrick buffers responses and cannot hold the listening
|
|
230
|
+
# stream open. See docs/mcp_guide.md for the credential-scoping and
|
|
231
|
+
# single-process caveats.
|
|
232
|
+
# @param subscription_manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
233
|
+
# inject a pre-built manager (tests, or to share a clustered-notifier
|
|
234
|
+
# adapter). Takes precedence over `resource_subscriptions:`. When nil
|
|
235
|
+
# and `resource_subscriptions: true`, a default in-process manager is
|
|
236
|
+
# constructed.
|
|
206
237
|
# @raise [ArgumentError] if both or neither of agent_factory/block are given.
|
|
207
238
|
def initialize(agent_factory: nil, max_body_size: DEFAULT_MAX_BODY_SIZE,
|
|
208
239
|
logger: nil, streaming: false,
|
|
@@ -211,6 +242,11 @@ module Parse
|
|
|
211
242
|
pre_auth_rate_limiter: nil,
|
|
212
243
|
allowed_origins: nil,
|
|
213
244
|
require_custom_header: nil,
|
|
245
|
+
resource_subscriptions: false,
|
|
246
|
+
subscription_manager: nil,
|
|
247
|
+
notifications: false,
|
|
248
|
+
approval_timeout: DEFAULT_APPROVAL_TIMEOUT,
|
|
249
|
+
principal_resolver: nil,
|
|
214
250
|
health_path: nil, &block)
|
|
215
251
|
if agent_factory && block
|
|
216
252
|
raise ArgumentError, "Provide agent_factory: OR a block, not both"
|
|
@@ -240,15 +276,62 @@ module Parse
|
|
|
240
276
|
# a process, nor multiple processes in a clustered deployment.
|
|
241
277
|
@cancellation_registry = CancellationRegistry.new
|
|
242
278
|
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
279
|
+
# Elicitation (human-in-the-loop approval) state, shared across
|
|
280
|
+
# this app's requests and its GET listening streams. The
|
|
281
|
+
# capability registry records (per session) whether the client
|
|
282
|
+
# advertised `elicitation` at initialize; the pending registry
|
|
283
|
+
# holds server→client requests awaiting a reply. Both are cheap
|
|
284
|
+
# and always present; they only do work when
|
|
285
|
+
# Parse::Agent.require_approval_for opts a tier in.
|
|
286
|
+
@elicitation_capabilities = Parse::Agent::ClientCapabilityRegistry.new
|
|
287
|
+
@pending_elicitations = Parse::Agent::PendingElicitationRegistry.new
|
|
288
|
+
@approval_timeout = approval_timeout
|
|
289
|
+
|
|
290
|
+
# Binds each MCP session id to the principal that established it so a
|
|
291
|
+
# listening stream can't be hijacked by another authenticated caller.
|
|
292
|
+
# Same per-instance / single-process scope as @cancellation_registry.
|
|
293
|
+
@session_owners = SessionOwnerRegistry.new
|
|
294
|
+
if principal_resolver && !principal_resolver.respond_to?(:call)
|
|
295
|
+
raise ArgumentError, "principal_resolver must respond to #call"
|
|
296
|
+
end
|
|
297
|
+
@principal_resolver = principal_resolver
|
|
298
|
+
|
|
299
|
+
# Listening-stream coordinator (the server→client broadcast bus
|
|
300
|
+
# backing resource subscriptions, MCP elicitation, and
|
|
301
|
+
# general-purpose server-initiated notifications). An injected
|
|
302
|
+
# manager wins. Otherwise:
|
|
303
|
+
# - `resource_subscriptions: true` builds a LiveQuery-backed
|
|
304
|
+
# manager whose `supported?` resolves live (advertises
|
|
305
|
+
# `resources.subscribe` and serves subscribe POSTs).
|
|
306
|
+
# - `notifications: true` (without resource subscriptions) builds
|
|
307
|
+
# a manager in `supported: false` posture: the GET listening
|
|
308
|
+
# stream + `#notify` bus work, but `resources.subscribe` stays
|
|
309
|
+
# unadvertised and subscribe POSTs fail closed. This is the
|
|
310
|
+
# decoupling lever — a server can push arbitrary notifications
|
|
311
|
+
# without enabling LiveQuery resource subscriptions.
|
|
312
|
+
# nil disables the GET listening stream entirely.
|
|
313
|
+
@subscription_manager =
|
|
314
|
+
if subscription_manager
|
|
315
|
+
subscription_manager
|
|
316
|
+
elsif resource_subscriptions
|
|
317
|
+
Parse::Agent::MCPSubscriptions::Manager.new(logger: @logger)
|
|
318
|
+
elsif notifications
|
|
319
|
+
Parse::Agent::MCPSubscriptions::Manager.new(logger: @logger, supported: false)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Warn operators who enable a streaming surface without a concurrency
|
|
323
|
+
# cap. Both request-scoped SSE (streaming:) and the long-lived GET
|
|
324
|
+
# listening stream (resource_subscriptions:/notifications:, which set
|
|
325
|
+
# @subscription_manager) spawn per-connection threads; an unbounded
|
|
326
|
+
# endpoint is a practical DoS surface — a slow or hostile client opening
|
|
327
|
+
# connections faster than they close can exhaust the host thread pool and
|
|
328
|
+
# downstream Parse connection pool. The cap bounds each surface
|
|
329
|
+
# SEPARATELY, so the effective ceiling is up to 2x max_concurrent_dispatchers
|
|
330
|
+
# across both. Leaving the default `nil` (unlimited) preserves backward
|
|
331
|
+
# compatibility, but we tell the operator once at construction.
|
|
332
|
+
if (streaming || @subscription_manager) && @max_concurrent_dispatchers.nil?
|
|
333
|
+
surface = streaming ? "streaming: true" : "resource_subscriptions/notifications"
|
|
334
|
+
line = "[Parse::Agent::MCPRackApp] #{surface} with max_concurrent_dispatchers: nil (unlimited). " \
|
|
252
335
|
"Set a finite cap (e.g. 100, or 2x your Puma max_threads) to bound the orphan-thread DoS surface. " \
|
|
253
336
|
"See docs/mcp_guide.md for sizing guidance."
|
|
254
337
|
if @logger
|
|
@@ -259,6 +342,44 @@ module Parse
|
|
|
259
342
|
end
|
|
260
343
|
end
|
|
261
344
|
|
|
345
|
+
# The listening-stream coordinator backing this app's server→client
|
|
346
|
+
# bus, or nil when neither resource subscriptions nor notifications
|
|
347
|
+
# are enabled. Exposed so a clustered/Redis notifier adapter or an
|
|
348
|
+
# out-of-band publisher can reach the bus directly. Direct
|
|
349
|
+
# `#publish` accepts arbitrary messages (notifications OR id-bearing
|
|
350
|
+
# requests); prefer {#notify} for the validated notification path.
|
|
351
|
+
# @return [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
352
|
+
attr_reader :subscription_manager
|
|
353
|
+
|
|
354
|
+
# Push a server-initiated JSON-RPC NOTIFICATION to a session's open
|
|
355
|
+
# listening stream. This is the public front door for application
|
|
356
|
+
# code to deliver unsolicited `notifications/*` events (the GET
|
|
357
|
+
# stream must be open for the session — open it client-side with a
|
|
358
|
+
# `GET` carrying `Accept: text/event-stream` + `Mcp-Session-Id`).
|
|
359
|
+
#
|
|
360
|
+
# The envelope is built server-side as a notification — it never
|
|
361
|
+
# carries an `id`, which is what distinguishes it from the
|
|
362
|
+
# server-initiated *request* path (e.g. elicitation/create). A
|
|
363
|
+
# caller wanting an id-bearing request uses the internal
|
|
364
|
+
# `subscription_manager.publish` seam, not this method.
|
|
365
|
+
#
|
|
366
|
+
# @param session_id [String] the target session (Mcp-Session-Id).
|
|
367
|
+
# @param method [String] a non-empty JSON-RPC method, e.g.
|
|
368
|
+
# `"notifications/custom"`.
|
|
369
|
+
# @param params [Hash, nil] optional params object.
|
|
370
|
+
# @return [Boolean] true if a listening stream received it; false
|
|
371
|
+
# when notifications are disabled or no stream is attached.
|
|
372
|
+
# @raise [ArgumentError] when `method` is blank or not a String.
|
|
373
|
+
def notify(session_id, method:, params: nil)
|
|
374
|
+
unless method.is_a?(String) && !method.empty?
|
|
375
|
+
raise ArgumentError, "notify: method must be a non-empty String"
|
|
376
|
+
end
|
|
377
|
+
return false unless @subscription_manager
|
|
378
|
+
envelope = { "jsonrpc" => "2.0", "method" => method }
|
|
379
|
+
envelope["params"] = params unless params.nil?
|
|
380
|
+
!!@subscription_manager.publish(session_id, envelope)
|
|
381
|
+
end
|
|
382
|
+
|
|
262
383
|
# Returns the number of currently live dispatcher threads spawned by any
|
|
263
384
|
# SSEBody across all MCPRackApp instances in this process. Threads are
|
|
264
385
|
# counted by the `:parse_mcp_dispatcher` thread-local tag set when each
|
|
@@ -269,6 +390,25 @@ module Parse
|
|
|
269
390
|
Thread.list.count { |t| t[:parse_mcp_dispatcher] }
|
|
270
391
|
end
|
|
271
392
|
|
|
393
|
+
# Process-wide count of currently-open GET listening streams across all
|
|
394
|
+
# MCPRackApp instances. A listening stream is long-lived (the server→client
|
|
395
|
+
# notification channel) — each pins a server worker thread in #each plus a
|
|
396
|
+
# heartbeat thread — so it is bounded SEPARATELY from request-scoped SSE
|
|
397
|
+
# dispatchers (which #each, dispatch once, then close). Used as the soft
|
|
398
|
+
# cap in {#serve_listening_stream}. Maintained by {ListeningStreamBody}
|
|
399
|
+
# via {.adjust_listening_stream_count}; unlike a Thread.list scan this is
|
|
400
|
+
# an explicit counter because the heartbeat thread is intentionally not
|
|
401
|
+
# tagged as a dispatcher.
|
|
402
|
+
def self.active_listening_stream_count
|
|
403
|
+
@listening_stream_mutex.synchronize { @listening_stream_count }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# @api private — bump the live listening-stream counter by `delta`
|
|
407
|
+
# (+1 when a stream begins iterating, -1 when it closes).
|
|
408
|
+
def self.adjust_listening_stream_count(delta)
|
|
409
|
+
@listening_stream_mutex.synchronize { @listening_stream_count += delta }
|
|
410
|
+
end
|
|
411
|
+
|
|
272
412
|
# Rack interface.
|
|
273
413
|
#
|
|
274
414
|
# @param env [Hash] Rack environment
|
|
@@ -337,9 +477,35 @@ module Parse
|
|
|
337
477
|
return [400, json_headers, [json_rpc_error(-32_600, "Invalid Mcp-Session-Id")]]
|
|
338
478
|
end
|
|
339
479
|
@cancellation_registry.cancel_all_for(clean_sid, reason: :session_terminated)
|
|
480
|
+
# Wake any tool thread blocked on an elicitation reply for this
|
|
481
|
+
# session (it returns `unavailable` → fail closed) and drop the
|
|
482
|
+
# session's cached elicitation capability.
|
|
483
|
+
@pending_elicitations.abort_all_for(clean_sid, :session_terminated)
|
|
484
|
+
@elicitation_capabilities.forget(clean_sid)
|
|
485
|
+
# Tear down any resource subscriptions and the listening stream
|
|
486
|
+
# bound to this session so a terminated session leaves no LiveQuery
|
|
487
|
+
# sockets behind.
|
|
488
|
+
@subscription_manager&.detach_listener(clean_sid)
|
|
489
|
+
# Drop the owner binding so the id can be reclaimed after explicit
|
|
490
|
+
# termination (only here — not on mere stream close, so a reconnect
|
|
491
|
+
# keeps its claim).
|
|
492
|
+
@session_owners.forget(clean_sid)
|
|
340
493
|
return [204, json_headers, [""]]
|
|
341
494
|
end
|
|
342
495
|
|
|
496
|
+
# 0d. GET listening stream — the MCP 2025-06-18 Streamable HTTP
|
|
497
|
+
# server→client channel that carries unsolicited
|
|
498
|
+
# `notifications/resources/updated`. Only when resource
|
|
499
|
+
# subscriptions are enabled, the client opted into SSE, and a
|
|
500
|
+
# valid Mcp-Session-Id is present. Authenticated via the same
|
|
501
|
+
# agent_factory as POST: the session id is a server-issued
|
|
502
|
+
# bearer capability (returned on initialize), so possession of
|
|
503
|
+
# it plus a valid agent gates the stream.
|
|
504
|
+
if env["REQUEST_METHOD"] == "GET" && @subscription_manager &&
|
|
505
|
+
env["HTTP_ACCEPT"].to_s.include?("text/event-stream")
|
|
506
|
+
return serve_listening_stream(env)
|
|
507
|
+
end
|
|
508
|
+
|
|
343
509
|
# 1. Method check — only POST is accepted.
|
|
344
510
|
unless env["REQUEST_METHOD"] == "POST"
|
|
345
511
|
return [405,
|
|
@@ -402,7 +568,12 @@ module Parse
|
|
|
402
568
|
# amplifies into a Parse Server load problem. Empty-object
|
|
403
569
|
# and missing-method requests cannot possibly be valid
|
|
404
570
|
# JSON-RPC, so we shortcut to -32600 (Invalid Request).
|
|
405
|
-
|
|
571
|
+
# A method-less JSON-RPC RESPONSE ({jsonrpc,id,result|error}) is
|
|
572
|
+
# NOT malformed: it is the client's reply to a server-issued
|
|
573
|
+
# elicitation/create request. Let it through here; it is routed
|
|
574
|
+
# (session-bound) after the agent_factory resolves the session.
|
|
575
|
+
unless (body.is_a?(Hash) && body["method"].is_a?(String) && !body["method"].empty?) ||
|
|
576
|
+
elicitation_reply?(body)
|
|
406
577
|
return [400, json_headers, [json_rpc_error(-32_600, "Invalid Request")]]
|
|
407
578
|
end
|
|
408
579
|
|
|
@@ -421,7 +592,8 @@ module Parse
|
|
|
421
592
|
# initialize against this transport instance (e.g. a
|
|
422
593
|
# reconnecting client cancelling a pre-disconnect request).
|
|
423
594
|
unless body["method"] == "initialize" ||
|
|
424
|
-
body["method"] == "notifications/cancelled"
|
|
595
|
+
body["method"] == "notifications/cancelled" ||
|
|
596
|
+
elicitation_reply?(body)
|
|
425
597
|
requested = env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
426
598
|
if requested.is_a?(String) && !requested.empty? &&
|
|
427
599
|
!Parse::Agent::MCPDispatcher::SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
|
|
@@ -447,6 +619,16 @@ module Parse
|
|
|
447
619
|
return [500, json_headers, [json_rpc_error(-32_603, "Internal error")]]
|
|
448
620
|
end
|
|
449
621
|
|
|
622
|
+
# 5a-i. Surface the silent-ungated-writes footgun. A write/admin agent
|
|
623
|
+
# served over MCP with no approval tier configured runs every
|
|
624
|
+
# destructive tool without a human gate; warn once per process so
|
|
625
|
+
# the operator notices (mirrors the unrestricted-endpoints warning).
|
|
626
|
+
if agent.respond_to?(:permissions) &&
|
|
627
|
+
%i[write admin].include?(agent.permissions) &&
|
|
628
|
+
Parse::Agent.require_approval_for.empty?
|
|
629
|
+
Parse::Agent.warn_mcp_writes_unguarded!
|
|
630
|
+
end
|
|
631
|
+
|
|
450
632
|
# 5b. Thread the conversation correlation id through. Source
|
|
451
633
|
# header: the MCP 2025-06-18 Streamable HTTP spec-canonical
|
|
452
634
|
# `Mcp-Session-Id` (Rack env key `HTTP_MCP_SESSION_ID`).
|
|
@@ -478,6 +660,32 @@ module Parse
|
|
|
478
660
|
agent.correlation_id = SecureRandom.uuid
|
|
479
661
|
end
|
|
480
662
|
|
|
663
|
+
# 5b-ii. Capture the client's elicitation capability at initialize.
|
|
664
|
+
# The server reads (does not advertise) the client's
|
|
665
|
+
# `capabilities.elicitation`; the approval gate consults this
|
|
666
|
+
# per session before attempting a server→client prompt.
|
|
667
|
+
if body.is_a?(Hash) && body["method"] == "initialize" &&
|
|
668
|
+
agent.respond_to?(:correlation_id) && agent.correlation_id
|
|
669
|
+
supported = !!(body.dig("params", "capabilities", "elicitation"))
|
|
670
|
+
@elicitation_capabilities.set(agent.correlation_id, supported)
|
|
671
|
+
# Authoritatively bind this session to the initializing principal so
|
|
672
|
+
# only the same principal can later attach a listening stream for it
|
|
673
|
+
# (owner-binding; see SessionOwnerRegistry).
|
|
674
|
+
@session_owners.bind(agent.correlation_id, principal_fingerprint(agent, env))
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# 5b-iii. Elicitation reply ingress. A method-less JSON-RPC
|
|
678
|
+
# RESPONSE is the client's answer to a server-issued
|
|
679
|
+
# elicitation/create. Route it into the pending registry,
|
|
680
|
+
# session-bound by the same `correlation_id` the cancellation
|
|
681
|
+
# path uses, so one session can never answer another's prompt.
|
|
682
|
+
# Failures (no correlation_id, no match) are silent 202 no-ops
|
|
683
|
+
# to avoid a probe oracle — exactly like notifications/cancelled.
|
|
684
|
+
if elicitation_reply?(body)
|
|
685
|
+
route_elicitation_reply(agent, body)
|
|
686
|
+
return [202, json_headers, [""]]
|
|
687
|
+
end
|
|
688
|
+
|
|
481
689
|
# 5c. notifications/cancelled — special-cased BEFORE the dispatcher.
|
|
482
690
|
# A JSON-RPC notification has no `id`, expects no response
|
|
483
691
|
# body, and must trip the in-flight request whose
|
|
@@ -522,8 +730,70 @@ module Parse
|
|
|
522
730
|
# @param body [Hash] parsed JSON-RPC request body.
|
|
523
731
|
# @param agent [Parse::Agent] authenticated agent.
|
|
524
732
|
# @return [Array] Rack triple with Array<String> body.
|
|
733
|
+
# True when `body` is a JSON-RPC RESPONSE (no "method"; carries an
|
|
734
|
+
# "id" plus "result" or "error") — the client's reply to a
|
|
735
|
+
# server-issued elicitation/create request.
|
|
736
|
+
def elicitation_reply?(body)
|
|
737
|
+
body.is_a?(Hash) && !body.key?("method") && body.key?("id") &&
|
|
738
|
+
(body.key?("result") || body.key?("error"))
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Route an elicitation reply into the pending registry, bound to the
|
|
742
|
+
# answering session's correlation id. Silent no-op on any miss.
|
|
743
|
+
def route_elicitation_reply(agent, body)
|
|
744
|
+
correlation_id = agent.respond_to?(:correlation_id) ? agent.correlation_id : nil
|
|
745
|
+
elic_id = body["id"]
|
|
746
|
+
return if correlation_id.nil? || elic_id.nil?
|
|
747
|
+
@pending_elicitations.deliver(correlation_id, elic_id, map_elicitation_action(body))
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Map an elicitation reply envelope to an approval action symbol.
|
|
751
|
+
# An `error` reply, an unknown/declined action, or an `accept` whose
|
|
752
|
+
# `content.approve` is explicitly false all map toward refusal.
|
|
753
|
+
def map_elicitation_action(body)
|
|
754
|
+
return :cancel if body.key?("error")
|
|
755
|
+
result = body["result"]
|
|
756
|
+
return :cancel unless result.is_a?(Hash)
|
|
757
|
+
case result["action"]
|
|
758
|
+
when "accept"
|
|
759
|
+
content = result["content"]
|
|
760
|
+
if content.is_a?(Hash) && content.key?("approve") && content["approve"] == false
|
|
761
|
+
:decline
|
|
762
|
+
else
|
|
763
|
+
:accept
|
|
764
|
+
end
|
|
765
|
+
when "decline"
|
|
766
|
+
:decline
|
|
767
|
+
else
|
|
768
|
+
:cancel
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Build the per-request MCP elicitation approval gate, or nil when no
|
|
773
|
+
# tier opts in (the common path). The gate self-fails-closed: with no
|
|
774
|
+
# subscription manager (no GET stream / non-streaming transport) its
|
|
775
|
+
# listener check returns false, so a required approval is REFUSED
|
|
776
|
+
# rather than silently executed.
|
|
777
|
+
def build_approval_gate(agent)
|
|
778
|
+
return nil if Parse::Agent.require_approval_for.empty?
|
|
779
|
+
return nil unless agent.respond_to?(:correlation_id)
|
|
780
|
+
mgr = @subscription_manager
|
|
781
|
+
Parse::Agent::MCPElicitationGate.new(
|
|
782
|
+
correlation_id: agent.correlation_id,
|
|
783
|
+
pending: @pending_elicitations,
|
|
784
|
+
publish: ->(cid, req) { mgr ? !!mgr.publish(cid, req) : false },
|
|
785
|
+
capability_check: ->(cid) { @elicitation_capabilities.get(cid) },
|
|
786
|
+
listener_check: ->(cid) { mgr ? mgr.listener?(cid) : false },
|
|
787
|
+
timeout: @approval_timeout,
|
|
788
|
+
)
|
|
789
|
+
end
|
|
790
|
+
|
|
525
791
|
def serve_json(body, agent)
|
|
526
|
-
result = Parse::Agent::MCPDispatcher.call(
|
|
792
|
+
result = Parse::Agent::MCPDispatcher.call(
|
|
793
|
+
body: body, agent: agent, logger: @logger,
|
|
794
|
+
subscription_manager: @subscription_manager,
|
|
795
|
+
approval_gate: build_approval_gate(agent),
|
|
796
|
+
)
|
|
527
797
|
headers = json_headers
|
|
528
798
|
merge_session_header!(headers, body, agent)
|
|
529
799
|
# When the dispatcher returns body: nil (a JSON-RPC notification
|
|
@@ -600,11 +870,13 @@ module Parse
|
|
|
600
870
|
on_close: -> { registry.deregister(correlation_id, req_id, registry_entry_id) if registry_entry_id },
|
|
601
871
|
) do |progress_callback|
|
|
602
872
|
Parse::Agent::MCPDispatcher.call(
|
|
603
|
-
body:
|
|
604
|
-
agent:
|
|
605
|
-
logger:
|
|
606
|
-
progress_callback:
|
|
607
|
-
cancellation_token:
|
|
873
|
+
body: body,
|
|
874
|
+
agent: agent,
|
|
875
|
+
logger: logger,
|
|
876
|
+
progress_callback: progress_callback,
|
|
877
|
+
cancellation_token: cancellation_token,
|
|
878
|
+
subscription_manager: @subscription_manager,
|
|
879
|
+
approval_gate: build_approval_gate(agent),
|
|
608
880
|
)
|
|
609
881
|
end
|
|
610
882
|
|
|
@@ -613,6 +885,126 @@ module Parse
|
|
|
613
885
|
[200, headers, sse_body]
|
|
614
886
|
end
|
|
615
887
|
|
|
888
|
+
# Serve a long-lived GET listening SSE stream for resource-subscription
|
|
889
|
+
# delivery (MCP 2025-06-18 Streamable HTTP server→client channel).
|
|
890
|
+
#
|
|
891
|
+
# Unlike {#serve_sse} (response-scoped: one dispatch then close), this
|
|
892
|
+
# stream outlives any single request — it stays open emitting
|
|
893
|
+
# `notifications/resources/updated` for the session's subscriptions until
|
|
894
|
+
# the client disconnects or the session is terminated via DELETE.
|
|
895
|
+
#
|
|
896
|
+
# Authenticated via the same `agent_factory` as POST. The `Mcp-Session-Id`
|
|
897
|
+
# header keys the listener; it is a server-issued capability (returned on
|
|
898
|
+
# `initialize`), so possession + a valid agent gates the stream. The agent
|
|
899
|
+
# itself is not retained — subscriptions (and their credentials) are
|
|
900
|
+
# created by the `resources/subscribe` POST, not here.
|
|
901
|
+
#
|
|
902
|
+
# @param env [Hash] Rack env.
|
|
903
|
+
# @return [Array] Rack triple with a {ListeningStreamBody}, or an error.
|
|
904
|
+
def serve_listening_stream(env)
|
|
905
|
+
begin
|
|
906
|
+
agent = @agent_factory.call(env)
|
|
907
|
+
rescue Parse::Agent::Unauthorized
|
|
908
|
+
@logger&.warn("[Parse::Agent::MCPRackApp] Unauthorized listening stream")
|
|
909
|
+
return [401, json_headers, [unauthorized_body]]
|
|
910
|
+
rescue StandardError => e
|
|
911
|
+
@logger&.warn("[Parse::Agent::MCPRackApp] Factory error (listening): #{e.class.name}")
|
|
912
|
+
return [500, json_headers, [json_rpc_error(-32_603, "Internal error")]]
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
session_id = sanitize_session_id(env["HTTP_MCP_SESSION_ID"].to_s)
|
|
916
|
+
if session_id.nil? || session_id.empty?
|
|
917
|
+
return [400, json_headers, [json_rpc_error(-32_600, "Missing or invalid Mcp-Session-Id")]]
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# The origin allowlist (when configured) guards the listening stream
|
|
921
|
+
# the same way it guards POST — a browser-driven cross-origin GET to
|
|
922
|
+
# an SSE endpoint is the analogous CSRF surface.
|
|
923
|
+
if @allowed_origins
|
|
924
|
+
origin = env["HTTP_ORIGIN"].to_s.strip
|
|
925
|
+
unless origin.empty? || origin_allowed?(origin)
|
|
926
|
+
return [403, json_headers, [json_rpc_error(-32_700, "Origin not allowed")]]
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Owner-binding: only the principal that established this session (or,
|
|
931
|
+
# for an id that never went through initialize, the first principal to
|
|
932
|
+
# attach) may open its listening stream. A different authenticated
|
|
933
|
+
# caller who knows/guesses the id is refused, closing the
|
|
934
|
+
# cross-session subscribe/evict hijack.
|
|
935
|
+
#
|
|
936
|
+
# We return a distinguishable 403 on mismatch (vs 200 when the id is
|
|
937
|
+
# unclaimed). That is a deliberate, narrow existence oracle —
|
|
938
|
+
# acceptable because server-assigned ids are SecureRandom.uuid and so
|
|
939
|
+
# infeasible to enumerate. (Contrast the cancellation/elicitation
|
|
940
|
+
# paths, which return a uniform 202 because their ids are
|
|
941
|
+
# client-chosen and guessable.)
|
|
942
|
+
unless @session_owners.authorize_attach(session_id, principal_fingerprint(agent, env))
|
|
943
|
+
@logger&.warn("[Parse::Agent::MCPRackApp] Listening stream denied: session owned by another principal")
|
|
944
|
+
return [403, json_headers, [json_rpc_error(-32_600, "Mcp-Session-Id is owned by another principal")]]
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# Soft cap on concurrent listening streams, mirroring serve_sse's
|
|
948
|
+
# dispatcher cap. Listening streams are bounded SEPARATELY from
|
|
949
|
+
# request-scoped SSE dispatchers and reuse the same configured ceiling,
|
|
950
|
+
# so total streaming thread exposure can reach 2x max_concurrent_dispatchers
|
|
951
|
+
# (up to N request SSE + N listening streams), not N. Like serve_sse the
|
|
952
|
+
# check is best-effort (not lock-protected against the per-stream
|
|
953
|
+
# increment in #each), so a burst can briefly overshoot — acceptable for
|
|
954
|
+
# a soft cap.
|
|
955
|
+
if @max_concurrent_dispatchers &&
|
|
956
|
+
MCPRackApp.active_listening_stream_count >= @max_concurrent_dispatchers
|
|
957
|
+
return [503, json_headers, [json_rpc_error(-32_000, "server busy")]]
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
body = ListeningStreamBody.new(@subscription_manager, session_id, @heartbeat_interval, @logger)
|
|
961
|
+
[200, sse_headers, body]
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# Derive a stable, privacy-preserving principal fingerprint for the
|
|
965
|
+
# authenticated agent, used to owner-bind MCP sessions.
|
|
966
|
+
#
|
|
967
|
+
# An operator `principal_resolver:` callable wins (it lets a
|
|
968
|
+
# master-key-everywhere deployment that authenticates users upstream
|
|
969
|
+
# supply a real per-user identity). Otherwise the agent's own scope is
|
|
970
|
+
# used: a hashed session_token, then acl_user, then acl_role. A bare
|
|
971
|
+
# master-key agent with no scope falls back to the shared "mk"
|
|
972
|
+
# fingerprint — owner-binding is then a no-op (documented), since all
|
|
973
|
+
# such agents are indistinguishable admins.
|
|
974
|
+
#
|
|
975
|
+
# @param agent [Parse::Agent]
|
|
976
|
+
# @param env [Hash] the Rack env (passed to the resolver).
|
|
977
|
+
# @return [String]
|
|
978
|
+
def principal_fingerprint(agent, env)
|
|
979
|
+
if @principal_resolver
|
|
980
|
+
resolved = @principal_resolver.call(agent, env)
|
|
981
|
+
return "op:#{Digest::SHA256.hexdigest(resolved.to_s)[0, 32]}" unless resolved.nil? || resolved.to_s.empty?
|
|
982
|
+
end
|
|
983
|
+
if agent.respond_to?(:session_token) && !agent.session_token.to_s.empty?
|
|
984
|
+
return "st:#{Digest::SHA256.hexdigest(agent.session_token.to_s)[0, 32]}"
|
|
985
|
+
end
|
|
986
|
+
# acl_user / acl_role scopes are the RAW constructor input, which may be
|
|
987
|
+
# a Parse::User / Parse::Pointer / Parse::Role object whose bare #to_s
|
|
988
|
+
# is a per-instance `#<...:0x...>` string — using that directly would
|
|
989
|
+
# give the initialize and GET agents different fingerprints and
|
|
990
|
+
# false-reject the legitimate owner. Derive a stable id the same way
|
|
991
|
+
# #auth_context does: objectId for user/pointer scopes, role name for
|
|
992
|
+
# role scopes. (These are unverified constructor assertions, so the
|
|
993
|
+
# fingerprint is only as trustworthy as the factory's identity
|
|
994
|
+
# assignment — same caveat as the "mk" case; session_token is verified.)
|
|
995
|
+
if agent.respond_to?(:acl_user_scope) && agent.acl_user_scope
|
|
996
|
+
s = agent.acl_user_scope
|
|
997
|
+
id = s.respond_to?(:id) ? s.id : s.to_s
|
|
998
|
+
return "au:#{id}" unless id.nil? || id.to_s.empty?
|
|
999
|
+
end
|
|
1000
|
+
if agent.respond_to?(:acl_role_scope) && agent.acl_role_scope
|
|
1001
|
+
s = agent.acl_role_scope
|
|
1002
|
+
name = s.respond_to?(:name) ? s.name : s.to_s.sub(/\Arole:/, "")
|
|
1003
|
+
return "ar:#{name}" unless name.nil? || name.to_s.empty?
|
|
1004
|
+
end
|
|
1005
|
+
"mk"
|
|
1006
|
+
end
|
|
1007
|
+
|
|
616
1008
|
# ---------------------------------------------------------------------------
|
|
617
1009
|
# SSE body class
|
|
618
1010
|
# ---------------------------------------------------------------------------
|
|
@@ -1043,6 +1435,120 @@ module Parse
|
|
|
1043
1435
|
end
|
|
1044
1436
|
end
|
|
1045
1437
|
|
|
1438
|
+
# ---------------------------------------------------------------------------
|
|
1439
|
+
# Listening stream body (resource subscriptions)
|
|
1440
|
+
# ---------------------------------------------------------------------------
|
|
1441
|
+
|
|
1442
|
+
# Rack body for the long-lived GET listening stream that carries
|
|
1443
|
+
# `notifications/resources/updated` to a subscribing client.
|
|
1444
|
+
#
|
|
1445
|
+
# On {#each} it registers a delivery callback with the
|
|
1446
|
+
# {Parse::Agent::MCPSubscriptions::Manager} keyed by the session id, then
|
|
1447
|
+
# blocks reading from an internal queue and yields SSE-formatted
|
|
1448
|
+
# notification events as they are published by the LiveQuery bridge. A
|
|
1449
|
+
# periodic SSE comment heartbeat keeps the connection warm and surfaces a
|
|
1450
|
+
# dead socket as a write error so the Rack server invokes {#close}.
|
|
1451
|
+
#
|
|
1452
|
+
# {#close} detaches the listener and tears down every LiveQuery
|
|
1453
|
+
# subscription bound to the session — so a dropped stream leaves no
|
|
1454
|
+
# LiveQuery sockets behind. Re-opening the stream requires the client to
|
|
1455
|
+
# re-issue its `resources/subscribe` calls (subscriptions do not survive a
|
|
1456
|
+
# listening-stream disconnect in this single-process implementation).
|
|
1457
|
+
#
|
|
1458
|
+
# The publish callback runs on a LiveQuery dispatcher / debounce thread
|
|
1459
|
+
# and only pushes to the thread-safe queue; all `yield`s happen on the
|
|
1460
|
+
# Rack I/O thread driving {#each}, mirroring {SSEBody}'s threading model.
|
|
1461
|
+
#
|
|
1462
|
+
# @api private
|
|
1463
|
+
class ListeningStreamBody
|
|
1464
|
+
DONE = :__listening_done__
|
|
1465
|
+
|
|
1466
|
+
# @param manager [Parse::Agent::MCPSubscriptions::Manager]
|
|
1467
|
+
# @param session_id [String] sanitized Mcp-Session-Id.
|
|
1468
|
+
# @param heartbeat_interval [Numeric] SSE comment heartbeat period in
|
|
1469
|
+
# seconds; `<= 0` disables heartbeats.
|
|
1470
|
+
# @param logger [#warn, nil]
|
|
1471
|
+
def initialize(manager, session_id, heartbeat_interval, logger)
|
|
1472
|
+
@manager = manager
|
|
1473
|
+
@session_id = session_id
|
|
1474
|
+
@heartbeat_interval = heartbeat_interval
|
|
1475
|
+
@logger = logger
|
|
1476
|
+
@queue = Queue.new
|
|
1477
|
+
@heartbeat = nil
|
|
1478
|
+
@closed = false
|
|
1479
|
+
@counted = false
|
|
1480
|
+
@close_mutex = Mutex.new
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
# Rack body interface — called once by the Rack server.
|
|
1484
|
+
# @yield [String] SSE-formatted event / comment strings.
|
|
1485
|
+
def each
|
|
1486
|
+
queue = @queue
|
|
1487
|
+
# Count this stream against the concurrent-listening-stream soft cap.
|
|
1488
|
+
# Incrementing here (in #each, not the constructor) means a body the
|
|
1489
|
+
# Rack server never iterates — or a client that disconnects before
|
|
1490
|
+
# iteration — never inflates the counter; the matching decrement is in
|
|
1491
|
+
# #close, which #each's `ensure` always runs.
|
|
1492
|
+
MCPRackApp.adjust_listening_stream_count(1)
|
|
1493
|
+
@counted = true
|
|
1494
|
+
@manager.attach_listener(@session_id) do |notification|
|
|
1495
|
+
queue << format_event(notification)
|
|
1496
|
+
end
|
|
1497
|
+
# Initial comment flushes response headers and confirms the stream.
|
|
1498
|
+
yield ": connected\n\n"
|
|
1499
|
+
start_heartbeat
|
|
1500
|
+
loop do
|
|
1501
|
+
msg = @queue.pop
|
|
1502
|
+
break if msg == DONE
|
|
1503
|
+
yield msg
|
|
1504
|
+
end
|
|
1505
|
+
ensure
|
|
1506
|
+
close
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
# Terminate the stream: stop heartbeats, detach the listener, and tear
|
|
1510
|
+
# down the session's LiveQuery subscriptions. Idempotent.
|
|
1511
|
+
def close
|
|
1512
|
+
@close_mutex.synchronize do
|
|
1513
|
+
return if @closed
|
|
1514
|
+
@closed = true
|
|
1515
|
+
end
|
|
1516
|
+
# Balance the #each increment exactly once (close is idempotent via
|
|
1517
|
+
# @closed, and only #each sets @counted).
|
|
1518
|
+
MCPRackApp.adjust_listening_stream_count(-1) if @counted
|
|
1519
|
+
@heartbeat&.kill
|
|
1520
|
+
@heartbeat = nil
|
|
1521
|
+
begin
|
|
1522
|
+
@manager.detach_listener(@session_id)
|
|
1523
|
+
rescue StandardError => e
|
|
1524
|
+
line = "[Parse::Agent::MCPRackApp::ListeningStreamBody] detach error: #{e.class}: #{e.message}"
|
|
1525
|
+
@logger ? @logger.warn(line) : warn(line)
|
|
1526
|
+
end
|
|
1527
|
+
@queue << DONE rescue nil
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
private
|
|
1531
|
+
|
|
1532
|
+
def start_heartbeat
|
|
1533
|
+
return unless @heartbeat_interval && @heartbeat_interval > 0
|
|
1534
|
+
queue = @queue
|
|
1535
|
+
interval = @heartbeat_interval
|
|
1536
|
+
@heartbeat = Thread.new do
|
|
1537
|
+
loop do
|
|
1538
|
+
sleep interval
|
|
1539
|
+
queue << ": keep-alive\n\n"
|
|
1540
|
+
end
|
|
1541
|
+
end
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
# SSE wire form for a server→client notification. Event name "message"
|
|
1545
|
+
# (not "progress"/"response", which are reserved for the request-scoped
|
|
1546
|
+
# SSE path).
|
|
1547
|
+
def format_event(notification)
|
|
1548
|
+
"event: message\ndata: #{JSON.generate(notification)}\n\n"
|
|
1549
|
+
end
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1046
1552
|
# ---------------------------------------------------------------------------
|
|
1047
1553
|
# Cancellation registry
|
|
1048
1554
|
# ---------------------------------------------------------------------------
|
|
@@ -1074,6 +1580,104 @@ module Parse
|
|
|
1074
1580
|
# in a clustered deployment.
|
|
1075
1581
|
#
|
|
1076
1582
|
# @api private
|
|
1583
|
+
# Binds an MCP session id to the principal that established it, so a
|
|
1584
|
+
# listening stream (the server→client notification channel) can only be
|
|
1585
|
+
# attached by the same principal — closing the cross-session hijack where
|
|
1586
|
+
# any authenticated caller who knows/guesses another session's id could
|
|
1587
|
+
# subscribe to its notifications or evict its listener via overwrite.
|
|
1588
|
+
#
|
|
1589
|
+
# Trust model and limitations (mirrored in the docs):
|
|
1590
|
+
#
|
|
1591
|
+
# - **Initialize-bound vs TOFU.** A session established through an
|
|
1592
|
+
# `initialize` POST is bound to that caller's principal authoritatively.
|
|
1593
|
+
# A session id that was never seen by `initialize` (the decoupled
|
|
1594
|
+
# `notifications:` bus, where app code pushes to arbitrary ids) is
|
|
1595
|
+
# claimed trust-on-first-use by whoever attaches a listener first;
|
|
1596
|
+
# subsequent attaches by a different principal are refused. TOFU is
|
|
1597
|
+
# strictly better than the prior bearer model (eviction-after-claim is
|
|
1598
|
+
# closed) but a first-mover attacker can still claim an unused id — so
|
|
1599
|
+
# notification-bus ids should be high-entropy.
|
|
1600
|
+
# - **Per-instance / single-process**, exactly like CancellationRegistry:
|
|
1601
|
+
# it does not span Puma workers or survive restart. In a cluster the
|
|
1602
|
+
# GET stream and the initialize POST may land on different workers, so
|
|
1603
|
+
# the initialize-binding degrades to TOFU there.
|
|
1604
|
+
# - **Principal fidelity depends on the factory.** The fingerprint is
|
|
1605
|
+
# derived from the agent the factory builds (session_token → acl_user →
|
|
1606
|
+
# acl_role), or an operator-supplied `principal_resolver`. A
|
|
1607
|
+
# master-key-everywhere factory yields one shared "mk" principal, so
|
|
1608
|
+
# owner-binding is a no-op unless a `principal_resolver` (or
|
|
1609
|
+
# per-user impersonation) supplies a real identity.
|
|
1610
|
+
#
|
|
1611
|
+
# LRU-bounded so an initialize-without-DELETE stream of sessions can't
|
|
1612
|
+
# grow it without limit; evicting an active owner just downgrades it to
|
|
1613
|
+
# TOFU on the next attach.
|
|
1614
|
+
class SessionOwnerRegistry
|
|
1615
|
+
DEFAULT_MAX_ENTRIES = 10_000
|
|
1616
|
+
|
|
1617
|
+
def initialize(max_entries: DEFAULT_MAX_ENTRIES)
|
|
1618
|
+
@owners = {} # session_id => principal fingerprint (insertion-ordered for LRU)
|
|
1619
|
+
@max = max_entries
|
|
1620
|
+
@mutex = Mutex.new
|
|
1621
|
+
end
|
|
1622
|
+
|
|
1623
|
+
# Authoritatively bind a session to a principal (initialize). A
|
|
1624
|
+
# re-initialize by the same caller refreshes the binding.
|
|
1625
|
+
def bind(session_id, fingerprint)
|
|
1626
|
+
return if blank?(session_id) || blank?(fingerprint)
|
|
1627
|
+
@mutex.synchronize do
|
|
1628
|
+
@owners.delete(session_id)
|
|
1629
|
+
@owners[session_id] = fingerprint
|
|
1630
|
+
evict_lru!
|
|
1631
|
+
end
|
|
1632
|
+
end
|
|
1633
|
+
|
|
1634
|
+
# Authorize a listening-stream attach. Returns true when the session is
|
|
1635
|
+
# unclaimed (claims it TOFU for this principal) or already owned by this
|
|
1636
|
+
# principal (refreshing its LRU position); false on a principal
|
|
1637
|
+
# mismatch. Blank inputs fail closed.
|
|
1638
|
+
def authorize_attach(session_id, fingerprint)
|
|
1639
|
+
return false if blank?(session_id) || blank?(fingerprint)
|
|
1640
|
+
@mutex.synchronize do
|
|
1641
|
+
owner = @owners[session_id]
|
|
1642
|
+
if owner.nil?
|
|
1643
|
+
@owners[session_id] = fingerprint
|
|
1644
|
+
evict_lru!
|
|
1645
|
+
true
|
|
1646
|
+
elsif owner == fingerprint
|
|
1647
|
+
@owners.delete(session_id)
|
|
1648
|
+
@owners[session_id] = owner
|
|
1649
|
+
true
|
|
1650
|
+
else
|
|
1651
|
+
false
|
|
1652
|
+
end
|
|
1653
|
+
end
|
|
1654
|
+
end
|
|
1655
|
+
|
|
1656
|
+
# Drop a session's owner binding (explicit DELETE termination). Not
|
|
1657
|
+
# called on mere stream close, so a reconnecting owner keeps its claim
|
|
1658
|
+
# and an attacker can't grab the id during a brief disconnect.
|
|
1659
|
+
def forget(session_id)
|
|
1660
|
+
return if blank?(session_id)
|
|
1661
|
+
@mutex.synchronize { @owners.delete(session_id) }
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
# @return [Integer] current number of bound sessions (tests/metrics).
|
|
1665
|
+
def size
|
|
1666
|
+
@mutex.synchronize { @owners.size }
|
|
1667
|
+
end
|
|
1668
|
+
|
|
1669
|
+
private
|
|
1670
|
+
|
|
1671
|
+
# Hash preserves insertion order; #shift drops the oldest (LRU) entry.
|
|
1672
|
+
def evict_lru!
|
|
1673
|
+
@owners.shift while @owners.size > @max
|
|
1674
|
+
end
|
|
1675
|
+
|
|
1676
|
+
def blank?(value)
|
|
1677
|
+
value.nil? || value.to_s.empty?
|
|
1678
|
+
end
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1077
1681
|
class CancellationRegistry
|
|
1078
1682
|
def initialize
|
|
1079
1683
|
@entries = {}
|