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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.env.sample +12 -0
  3. data/.env.test +4 -4
  4. data/CHANGELOG.md +545 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +6 -1
  7. data/README.md +167 -38
  8. data/Rakefile +56 -10
  9. data/docs/atlas_vector_search_guide.md +110 -9
  10. data/docs/mcp_guide.md +433 -0
  11. data/docs/mongodb_direct_guide.md +66 -1
  12. data/docs/mongodb_index_optimization_guide.md +22 -1
  13. data/docs/usage_guide.md +15 -0
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +90 -19
  16. data/lib/parse/agent/describe.rb +1 -0
  17. data/lib/parse/agent/errors.rb +16 -0
  18. data/lib/parse/agent/mcp_client.rb +9 -0
  19. data/lib/parse/agent/mcp_dispatcher.rb +139 -7
  20. data/lib/parse/agent/mcp_rack_app.rb +621 -17
  21. data/lib/parse/agent/mcp_subscriptions.rb +607 -0
  22. data/lib/parse/agent/metadata_dsl.rb +58 -0
  23. data/lib/parse/agent/metadata_registry.rb +141 -1
  24. data/lib/parse/agent/prompt_hardening.rb +213 -0
  25. data/lib/parse/agent/result_formatter.rb +18 -3
  26. data/lib/parse/agent/tools.rb +167 -24
  27. data/lib/parse/agent.rb +692 -21
  28. data/lib/parse/client/request.rb +55 -4
  29. data/lib/parse/client/response.rb +4 -0
  30. data/lib/parse/client.rb +205 -7
  31. data/lib/parse/model/classes/installation.rb +27 -10
  32. data/lib/parse/model/classes/user.rb +8 -0
  33. data/lib/parse/model/core/actions.rb +58 -4
  34. data/lib/parse/model/core/embed_managed.rb +19 -14
  35. data/lib/parse/model/core/indexing.rb +108 -16
  36. data/lib/parse/model/core/querying.rb +29 -0
  37. data/lib/parse/model/model.rb +34 -3
  38. data/lib/parse/model/object.rb +1 -0
  39. data/lib/parse/query.rb +90 -24
  40. data/lib/parse/retrieval/agent_tool.rb +369 -0
  41. data/lib/parse/retrieval/chunk.rb +74 -0
  42. data/lib/parse/retrieval/chunker.rb +208 -0
  43. data/lib/parse/retrieval/retriever.rb +274 -0
  44. data/lib/parse/retrieval.rb +10 -0
  45. data/lib/parse/schema.rb +69 -20
  46. data/lib/parse/stack/version.rb +2 -2
  47. data/parse-stack-next.gemspec +1 -1
  48. data/scripts/docker/docker-compose.atlas.yml +14 -10
  49. data/scripts/docker/docker-compose.test.yml +24 -20
  50. data/scripts/docker/mongo-init.js +3 -3
  51. data/scripts/start-parse.sh +10 -0
  52. data/scripts/start_mcp_server.rb +1 -1
  53. data/scripts/test_server_connection.rb +1 -1
  54. data/scripts/vector_prototype/create_vector_index.js +1 -1
  55. data/scripts/vector_prototype/fetch_embeddings.py +2 -2
  56. data/scripts/vector_prototype/query_prototype.rb +1 -1
  57. data/scripts/vector_prototype/run.sh +4 -4
  58. 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
- # Warn operators who enable streaming without a concurrency cap.
244
- # An unbounded SSE endpoint with orphaned dispatcher threads is
245
- # a practical DoS surface a slow or hostile client opening
246
- # connections faster than tools complete can exhaust the host's
247
- # thread pool and downstream Parse connection pool. Leaving the
248
- # default as `nil` (unlimited) preserves backward compatibility,
249
- # but we tell the operator once at construction.
250
- if streaming && @max_concurrent_dispatchers.nil?
251
- line = "[Parse::Agent::MCPRackApp] streaming: true with max_concurrent_dispatchers: nil (unlimited). " \
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
- unless body.is_a?(Hash) && body["method"].is_a?(String) && !body["method"].empty?
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(body: body, agent: agent, logger: @logger)
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: body,
604
- agent: agent,
605
- logger: logger,
606
- progress_callback: progress_callback,
607
- cancellation_token: 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 = {}