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
@@ -0,0 +1,607 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "errors"
6
+
7
+ module Parse
8
+ class Agent
9
+ # Resource-subscription bridge for the MCP server.
10
+ #
11
+ # MCP 2025-06-18 lets a client `resources/subscribe` to a resource URI and
12
+ # then receive unsolicited `notifications/resources/updated` messages over a
13
+ # server→client channel whenever the underlying data changes. This module
14
+ # bridges that surface onto Parse LiveQuery: a subscribed
15
+ # `parse://<Class>/count` or `parse://<Class>/samples` URI is backed by a
16
+ # LiveQuery subscription on `<Class>`; any matching create/update/delete/
17
+ # enter/leave event is debounced and projected to a single coarse
18
+ # `notifications/resources/updated` for that URI. The client re-reads the
19
+ # resource via `resources/read` to obtain the new value — the SDK never
20
+ # streams row payloads through the MCP resource surface.
21
+ #
22
+ # == Components
23
+ #
24
+ # * {Manager} — per-transport (per {Parse::Agent::MCPRackApp} instance)
25
+ # coordinator. Owns the session→subscription bookkeeping, derives
26
+ # LiveQuery credentials from the subscribing agent, starts/stops LiveQuery
27
+ # subscriptions, and routes debounced updates through the {Notifier}.
28
+ # * {LocalNotifier} — the in-process {Notifier} implementation. A listening
29
+ # SSE stream registers a delivery callback under its session id; the
30
+ # bridge publishes notifications to that session id. The {Notifier}
31
+ # contract is the clustered-ready seam: a Redis-backed implementation can
32
+ # drop in without touching {Manager} so a LiveQuery event observed on one
33
+ # worker process can reach a listening stream held on another.
34
+ #
35
+ # == Security: credential derivation (fail-closed)
36
+ #
37
+ # LiveQuery enforces ACL server-side via the session token supplied on the
38
+ # subscribe frame — exactly like the REST surface, and unlike the
39
+ # master-key-only REST `aggregate` endpoint. The bridge therefore mirrors
40
+ # the SDK's documented scope asymmetry (see CLAUDE.md "Critical Parse Server
41
+ # Behavior"):
42
+ #
43
+ # * **session-token agent** → subscribe with that token; Parse Server
44
+ # filters events to rows the user can read.
45
+ # * **master-key agent** (no session token, nil ACL scope) → subscribe with
46
+ # the master key; sees every event.
47
+ # * **`acl_user:` / `acl_role:` agent** → REFUSED. Those scopes are a
48
+ # mongo-direct-only construct with no REST and no LiveQuery affordance —
49
+ # Parse Server's LiveQuery has no "act as this user pointer / role"
50
+ # handshake. Bridging them would silently downgrade to either master-key
51
+ # (a row-level leak) or an unscoped session, so the bridge fails closed
52
+ # and raises {Parse::Agent::SecurityError} rather than open a
53
+ # mis-scoped channel.
54
+ #
55
+ # Only `count` and `samples` URIs are subscribable. `schema` changes are not
56
+ # LiveQuery events, so a `parse://<Class>/schema` subscribe is rejected with
57
+ # {Parse::Agent::ValidationError} rather than silently never firing.
58
+ module MCPSubscriptions
59
+ # Resource kinds that map to a LiveQuery-backed subscription. `schema`
60
+ # is intentionally excluded — class-schema changes do not surface as
61
+ # LiveQuery row events, so a schema subscription could never fire and
62
+ # advertising it would be a broken contract.
63
+ SUBSCRIBABLE_KINDS = %w[count samples].freeze
64
+
65
+ # URI grammar shared with {Parse::Agent::MCPDispatcher#handle_resources_read}.
66
+ # Captures (1) the Parse class name and (2) the resource kind.
67
+ URI_RE = %r{\Aparse://([A-Za-z_][A-Za-z0-9_]*)/(schema|count|samples)\z}.freeze
68
+
69
+ # Default trailing-debounce window, in seconds. A burst of LiveQuery
70
+ # events on the same `(session, uri)` within this window collapses to a
71
+ # single `notifications/resources/updated`. Bounds notification fan-out
72
+ # on a high-churn class to at most one update per window per subscription.
73
+ DEFAULT_DEBOUNCE_INTERVAL = 0.25
74
+
75
+ # Default ceiling on concurrent subscriptions per session. A client that
76
+ # subscribes but never opens (or drops) its GET listening stream leaves
77
+ # LiveQuery subscriptions running until the session is torn down; this cap
78
+ # bounds that footprint, matching the "cap everything" posture of the rest
79
+ # of the transport (`max_concurrent_dispatchers`, the pre-auth limiter).
80
+ DEFAULT_MAX_SUBSCRIPTIONS_PER_SESSION = 100
81
+
82
+ # Default ceiling on the number of DISTINCT sessions holding subscriptions.
83
+ # The per-session cap above bounds one session's footprint, but nothing
84
+ # bounded how many sessions accumulate — an authenticated client that
85
+ # subscribes (which can happen before the GET stream opens) and never sends
86
+ # DELETE leaves the session in `@sessions` for the process lifetime. This
87
+ # caps that growth, mirroring SessionOwnerRegistry::DEFAULT_MAX_ENTRIES.
88
+ DEFAULT_MAX_SESSIONS = 10_000
89
+
90
+ # Parse a resource URI into `[class_name, kind]`, enforcing that the kind
91
+ # is LiveQuery-backed.
92
+ #
93
+ # @param uri [String]
94
+ # @return [Array(String, String)] class name and kind.
95
+ # @raise [Parse::Agent::ValidationError] for a malformed URI or a
96
+ # well-formed-but-unsubscribable kind (e.g. `schema`).
97
+ def self.parse_subscribable_uri(uri)
98
+ match = URI_RE.match(uri.to_s)
99
+ unless match
100
+ raise Parse::Agent::ValidationError,
101
+ "Invalid resource URI: #{uri}. Expected parse://<Class>/{count|samples}."
102
+ end
103
+ class_name = match[1]
104
+ kind = match[2]
105
+ unless SUBSCRIBABLE_KINDS.include?(kind)
106
+ raise Parse::Agent::ValidationError,
107
+ "Resource kind '#{kind}' is not subscribable — only #{SUBSCRIBABLE_KINDS.join(' and ')} " \
108
+ "are backed by LiveQuery. Schema changes are not LiveQuery events."
109
+ end
110
+ [class_name, kind]
111
+ end
112
+
113
+ # Derive LiveQuery subscribe credentials from a subscribing agent.
114
+ #
115
+ # @param agent [Parse::Agent]
116
+ # @return [Hash] keyword fragment for `client.subscribe` — either
117
+ # `{ session_token: "..." }` or `{ use_master_key: true }`.
118
+ # @raise [Parse::Agent::SecurityError] when the agent's scope has no
119
+ # LiveQuery equivalent (`acl_user:` / `acl_role:` postures), to avoid
120
+ # opening a mis-scoped channel.
121
+ def self.live_query_credentials_for(agent)
122
+ token = agent.respond_to?(:session_token) ? agent.session_token : nil
123
+ return { session_token: token } if token && !token.to_s.empty?
124
+
125
+ acl_user = agent.respond_to?(:acl_user_scope) ? agent.acl_user_scope : nil
126
+ acl_role = agent.respond_to?(:acl_role_scope) ? agent.acl_role_scope : nil
127
+ if acl_user || acl_role
128
+ raise Parse::Agent::SecurityError,
129
+ "acl_user/acl_role agents cannot open a LiveQuery-backed resource subscription: " \
130
+ "Parse Server LiveQuery has no act-as-user/act-as-role handshake, so the channel " \
131
+ "would be mis-scoped. Subscribe with a session-token or master-key agent instead."
132
+ end
133
+
134
+ # Master-key posture: no session token and no acl_user/acl_role (both
135
+ # handled above), so `acl_scope` is nil. But "no scope" is NOT by
136
+ # itself authority to open an ADMIN, ACL-bypassing LiveQuery socket.
137
+ # `client_for` builds that socket via
138
+ # `Parse::LiveQuery::Client.new(use_master_key: true)` with no explicit
139
+ # key, and the constructor backfills the PROCESS-GLOBAL master key
140
+ # (`cfg.master_key || parse_client_value(:master_key)`) — a different
141
+ # authority source than this agent. An unprivileged / client-mode agent
142
+ # whose own client has no master key would otherwise borrow the global
143
+ # one and silently elevate every row on the socket past ACL/CLP. Bind
144
+ # the master-key branch to the agent's ACTUAL authority: its own client
145
+ # must carry a usable (non-blank String) master key — at least as strict
146
+ # as `Parse::LiveQuery::Client#admin_connection?` (which requires a
147
+ # non-empty String). Fail closed otherwise.
148
+ acl_scope = agent.respond_to?(:acl_scope) ? agent.acl_scope : nil
149
+ if acl_scope.nil?
150
+ mk = agent.respond_to?(:client) ? agent.client&.master_key : nil
151
+ return { use_master_key: true } if mk.is_a?(String) && !mk.strip.empty?
152
+
153
+ raise Parse::Agent::SecurityError,
154
+ "master-key posture but no master key on the agent's own client; refusing to " \
155
+ "open an admin (ACL-bypassing) LiveQuery socket for an unprivileged agent — it " \
156
+ "would otherwise borrow the process-global master key. Subscribe with a " \
157
+ "session-token agent, or give this agent a master-key client."
158
+ end
159
+
160
+ # A scoped posture we don't have a LiveQuery mapping for — fail closed.
161
+ raise Parse::Agent::SecurityError,
162
+ "This agent's scope cannot be safely bridged to LiveQuery; refusing to open a " \
163
+ "resource subscription."
164
+ end
165
+
166
+ # In-process {Notifier}. Routes a published notification straight to the
167
+ # listening-stream callback registered under the same session id. This is
168
+ # the single-process implementation; the contract it satisfies is the
169
+ # seam a clustered (e.g. Redis pub/sub) implementation slots into.
170
+ #
171
+ # Contract (any Notifier must honor):
172
+ # * `register(session_id) { |notification_hash| ... }` — install the
173
+ # delivery callback for a listening stream. Replacing an existing
174
+ # registration is allowed (last writer wins).
175
+ # * `unregister(session_id)` — remove it. Idempotent.
176
+ # * `publish(session_id, notification_hash)` — deliver to the registered
177
+ # callback if one exists; a no-op (dropped) when no listener is
178
+ # attached. Returns whether a listener received it.
179
+ #
180
+ # Thread-safety: `publish` may run on a LiveQuery dispatcher thread while
181
+ # `register`/`unregister` run on Rack I/O threads, so all three guard the
182
+ # listener table with a mutex. The delivery callback itself is invoked
183
+ # outside the lock so a slow consumer can't block registry mutation.
184
+ class LocalNotifier
185
+ def initialize
186
+ @listeners = {}
187
+ @mutex = Mutex.new
188
+ end
189
+
190
+ # @yieldparam notification_hash [Hash] the JSON-RPC notification.
191
+ def register(session_id, &callback)
192
+ return if session_id.nil? || callback.nil?
193
+ @mutex.synchronize { @listeners[session_id] = callback }
194
+ end
195
+
196
+ def unregister(session_id)
197
+ return if session_id.nil?
198
+ @mutex.synchronize { @listeners.delete(session_id) }
199
+ end
200
+
201
+ # @return [Boolean] true if a listener received the notification.
202
+ def publish(session_id, notification_hash)
203
+ callback = @mutex.synchronize { @listeners[session_id] }
204
+ return false unless callback
205
+ callback.call(notification_hash)
206
+ true
207
+ end
208
+
209
+ # @return [Boolean] whether a listening stream is attached.
210
+ def listener?(session_id)
211
+ @mutex.synchronize { @listeners.key?(session_id) }
212
+ end
213
+ end
214
+
215
+ # Trailing-debounce coalescer for one `(session, uri)` subscription.
216
+ #
217
+ # The first event in a quiet period arms a one-shot timer; events that
218
+ # arrive before it fires are coalesced (dropped) so the timer emits a
219
+ # single update. After the emit the coalescer rearms on the next event.
220
+ # This bounds emission to at most one notification per window regardless
221
+ # of event rate.
222
+ #
223
+ # The timer mechanism is injected (`timer:`) so tests can drive emission
224
+ # deterministically instead of sleeping. The default spawns a short-lived
225
+ # thread per burst (not per event); at most one timer thread is live per
226
+ # coalescer at a time.
227
+ #
228
+ # @api private
229
+ class Debouncer
230
+ # @param interval [Numeric] debounce window in seconds. `<= 0` emits
231
+ # synchronously on every trigger (no coalescing) — used by tests and
232
+ # callers that want immediate delivery.
233
+ # @param timer [#call] `timer.call(interval) { emit }` schedules a
234
+ # one-shot emit. Default spawns a thread.
235
+ # @yield the emit action invoked once per coalesced burst.
236
+ def initialize(interval:, timer: nil, &emit)
237
+ @interval = interval
238
+ @emit = emit
239
+ @timer = timer || method(:default_timer)
240
+ @armed = false
241
+ @mutex = Mutex.new
242
+ end
243
+
244
+ # Record an event; arm the timer if not already armed.
245
+ def trigger
246
+ if @interval <= 0
247
+ @emit.call
248
+ return
249
+ end
250
+ should_arm = @mutex.synchronize do
251
+ next false if @armed
252
+ @armed = true
253
+ end
254
+ return unless should_arm
255
+ @timer.call(@interval) do
256
+ @mutex.synchronize { @armed = false }
257
+ @emit.call
258
+ end
259
+ end
260
+
261
+ private
262
+
263
+ def default_timer(interval, &fire)
264
+ Thread.new do
265
+ sleep interval
266
+ fire.call
267
+ end
268
+ end
269
+ end
270
+
271
+ # Per-transport subscription coordinator.
272
+ #
273
+ # One {Manager} is owned by each {Parse::Agent::MCPRackApp} that enables
274
+ # resource subscriptions. It is shared across that app's requests and SSE
275
+ # streams, so every public method is thread-safe.
276
+ #
277
+ # Lifecycle:
278
+ # 1. A GET listening stream opens → {#attach_listener} registers the
279
+ # stream's delivery callback under its session id.
280
+ # 2. A `resources/subscribe` POST → {#subscribe} validates the URI,
281
+ # derives credentials from the agent, and starts a LiveQuery
282
+ # subscription whose events publish debounced updates.
283
+ # 3. `resources/unsubscribe` → {#unsubscribe} stops that one LiveQuery
284
+ # subscription.
285
+ # 4. The listening stream closes (client disconnect / DELETE session) →
286
+ # {#detach_listener} tears down every LiveQuery subscription for the
287
+ # session.
288
+ class Manager
289
+ # @param logger [#warn, nil]
290
+ # @param debounce_interval [Numeric] see {Debouncer}.
291
+ # @param notifier [#register, #unregister, #publish] delivery seam.
292
+ # Defaults to {LocalNotifier}.
293
+ # @param live_query_client [Object, nil] a single client used for BOTH
294
+ # master- and session-scoped subscriptions, overriding the
295
+ # admin/scoped split below. Mainly a test injection point. When set,
296
+ # `live_query_admin_client` / `live_query_scoped_client` are ignored.
297
+ # @param live_query_admin_client [Object, nil] the client used for
298
+ # master-key-posture subscriptions. Must be an ADMIN connection
299
+ # (`Parse::LiveQuery::Client.new(use_master_key: true)`) so the socket
300
+ # bypasses ACL and the subscription actually sees every matching
301
+ # object — Parse Server has no per-subscription master key, so a
302
+ # non-admin connection would silently deliver only publicly-readable
303
+ # rows. Defaults to a lazily-constructed admin client.
304
+ # @param live_query_scoped_client [Object, nil] the client used for
305
+ # session-token subscriptions. A normal (non-admin) connection; the
306
+ # per-subscription `session_token` scopes results to that user.
307
+ # Defaults to the process-wide `Parse::LiveQuery.client`.
308
+ # @param supported [Boolean, nil] override the {#supported?} result.
309
+ # When nil (default), {#supported?} reflects the live LiveQuery
310
+ # enable/availability toggles. Tests pass `true` alongside a fake
311
+ # client.
312
+ # @param timer [#call, nil] debounce timer mechanism (see {Debouncer}).
313
+ # @param max_subscriptions_per_session [Integer] ceiling on concurrent
314
+ # subscriptions for one session. {#subscribe} raises
315
+ # {Parse::Agent::ValidationError} past this. See
316
+ # {DEFAULT_MAX_SUBSCRIPTIONS_PER_SESSION}.
317
+ # @param max_sessions [Integer] global ceiling on the number of distinct
318
+ # sessions holding subscriptions. {#subscribe} raises
319
+ # {Parse::Agent::ValidationError} when a NEW session would exceed it.
320
+ # See {DEFAULT_MAX_SESSIONS}.
321
+ def initialize(logger: nil, debounce_interval: DEFAULT_DEBOUNCE_INTERVAL,
322
+ notifier: nil, live_query_client: nil, supported: nil,
323
+ timer: nil,
324
+ live_query_admin_client: nil, live_query_scoped_client: nil,
325
+ max_subscriptions_per_session: DEFAULT_MAX_SUBSCRIPTIONS_PER_SESSION,
326
+ max_sessions: DEFAULT_MAX_SESSIONS)
327
+ @logger = logger
328
+ @debounce_interval = debounce_interval
329
+ @notifier = notifier || LocalNotifier.new
330
+ @both_client = live_query_client
331
+ @admin_client = live_query_admin_client
332
+ @scoped_client = live_query_scoped_client
333
+ @supported_override = supported
334
+ @timer = timer
335
+ @max_per_session = max_subscriptions_per_session
336
+ @max_sessions = max_sessions
337
+ @client_mutex = Mutex.new
338
+ # session_id => { uri => { sub:, debouncer: } }
339
+ @sessions = Hash.new { |h, k| h[k] = {} }
340
+ @mutex = Mutex.new
341
+ end
342
+
343
+ attr_reader :notifier
344
+
345
+ # Whether this transport can honor resource subscriptions. Drives the
346
+ # `resources.subscribe` capability the dispatcher advertises — we never
347
+ # advertise the capability unless we can actually deliver.
348
+ #
349
+ # @return [Boolean]
350
+ def supported?
351
+ return @supported_override unless @supported_override.nil?
352
+ return false unless defined?(Parse::LiveQuery)
353
+ Parse.respond_to?(:live_query_enabled?) && Parse.live_query_enabled? &&
354
+ Parse::LiveQuery.available?
355
+ end
356
+
357
+ # Register a listening stream's delivery callback for a session.
358
+ #
359
+ # @param session_id [String]
360
+ # @yieldparam notification_hash [Hash] JSON-RPC notification to deliver.
361
+ # @return [void]
362
+ def attach_listener(session_id, &callback)
363
+ @notifier.register(session_id, &callback)
364
+ end
365
+
366
+ # Whether a listening stream is currently attached for the session.
367
+ # @return [Boolean]
368
+ def listener?(session_id)
369
+ @notifier.respond_to?(:listener?) ? @notifier.listener?(session_id) : false
370
+ end
371
+
372
+ # Push an arbitrary JSON-RPC message (notification OR a
373
+ # server-initiated request carrying an `id`, e.g.
374
+ # `elicitation/create`) onto the session's listening stream.
375
+ # Returns false when no stream is attached.
376
+ #
377
+ # @param session_id [String]
378
+ # @param message_hash [Hash]
379
+ # @return [Boolean]
380
+ def publish(session_id, message_hash)
381
+ return false unless @notifier.respond_to?(:publish)
382
+ @notifier.publish(session_id, message_hash)
383
+ end
384
+
385
+ # Tear down a session: unregister its listener and stop every LiveQuery
386
+ # subscription it opened. Called when the listening stream closes or the
387
+ # session is terminated (DELETE).
388
+ #
389
+ # @param session_id [String]
390
+ # @return [Integer] number of LiveQuery subscriptions stopped.
391
+ def detach_listener(session_id)
392
+ @notifier.unregister(session_id)
393
+ subs = @mutex.synchronize { @sessions.delete(session_id) } || {}
394
+ subs.each_value { |entry| safe_unsubscribe(entry[:sub]) }
395
+ subs.size
396
+ end
397
+
398
+ # Open a LiveQuery-backed subscription for a resource URI.
399
+ #
400
+ # @param session_id [String] the Mcp-Session-Id keying the listener.
401
+ # @param uri [String] `parse://<Class>/{count|samples}`.
402
+ # @param agent [Parse::Agent] the subscribing agent (credential source).
403
+ # @return [Boolean] true on success (or already-subscribed no-op).
404
+ # @raise [Parse::Agent::ValidationError] bad/unsubscribable URI, or no
405
+ # session id.
406
+ # @raise [Parse::Agent::SecurityError] agent scope has no LiveQuery
407
+ # equivalent.
408
+ def subscribe(session_id:, uri:, agent:)
409
+ if session_id.nil? || session_id.to_s.empty?
410
+ raise Parse::Agent::ValidationError,
411
+ "resources/subscribe requires an established session (Mcp-Session-Id). " \
412
+ "Complete initialize first, then open the GET listening stream."
413
+ end
414
+ class_name, resource = MCPSubscriptions.parse_subscribable_uri(uri)
415
+
416
+ # Authorization parity with the read path (resources/read →
417
+ # agent.execute → assert_class_accessible!). Enforce agent_hidden, the
418
+ # per-agent `classes:` allowlist, AND CLP BEFORE deriving credentials
419
+ # or opening any socket. Parse Server LiveQuery enforces row ACL/CLP
420
+ # for session-token subscriptions, but agent_hidden / classes: are
421
+ # SDK-only constructs it knows nothing about — and a master-key socket
422
+ # bypasses ACL/CLP entirely. Without this gate,
423
+ # `resources/subscribe parse://_Session/count` (or any operator-hidden
424
+ # PII class) becomes a change/timing oracle on a class the tool
425
+ # surface refuses to even list. The CLP op mirrors the read path
426
+ # exactly — `count` resources gate on `:count`, `samples` on `:find` —
427
+ # so a subscribe is never stricter than the equivalent read. Raises
428
+ # AccessDenied / ValidationError, which the dispatcher maps to
429
+ # JSON-RPC -32602. Called unconditionally (not behind a
430
+ # `defined?(Tools)` guard) so the gate fails CLOSED — if `Tools` were
431
+ # somehow unloaded the call raises rather than silently skipping
432
+ # authorization. `Parse::Agent::Tools` is a hard dependency of the
433
+ # agent stack that mounts this bridge.
434
+ op = resource == "count" ? :count : :find
435
+ Parse::Agent::Tools.assert_class_accessible!(class_name, agent: agent, op: op)
436
+
437
+ creds = MCPSubscriptions.live_query_credentials_for(agent)
438
+
439
+ # Idempotent: a repeat subscribe to the same URI is a no-op rather
440
+ # than a second LiveQuery socket subscription. Enforce the per-session
441
+ # cap in the same critical section so a burst of distinct-URI
442
+ # subscribes can't race past it.
443
+ @mutex.synchronize do
444
+ # Global cap: bound the number of DISTINCT sessions so an
445
+ # authenticated client opening many sessions (subscribe-without-GET-
446
+ # stream, never DELETE) can't grow `@sessions` without limit. Checked
447
+ # BEFORE indexing `@sessions[session_id]`, which would auto-vivify the
448
+ # entry (`Hash.new { {} }`) and defeat the size check. This is a
449
+ # rejection cap (fails closed); the tradeoff is that a flood of orphan
450
+ # sessions could lock out NEW sessions until they are torn down or the
451
+ # process restarts — acceptable because every session requires a valid
452
+ # authenticated agent and the per-session cap still bounds each one.
453
+ if @max_sessions && !@sessions.key?(session_id) && @sessions.size >= @max_sessions
454
+ raise Parse::Agent::ValidationError,
455
+ "Global subscription session limit reached (#{@max_sessions}). Try again later."
456
+ end
457
+ subs = @sessions[session_id]
458
+ return true if subs.key?(uri)
459
+ if @max_per_session && subs.size >= @max_per_session
460
+ raise Parse::Agent::ValidationError,
461
+ "Session subscription limit reached (#{@max_per_session}). " \
462
+ "Unsubscribe from a resource before adding another."
463
+ end
464
+ end
465
+
466
+ debouncer = Debouncer.new(interval: @debounce_interval, timer: @timer) do
467
+ publish_update(session_id, uri)
468
+ end
469
+
470
+ sub = client_for(creds).subscribe(class_name, **creds)
471
+ Parse::LiveQuery::EVENTS.each do |event|
472
+ sub.on(event) { debouncer.trigger }
473
+ end
474
+
475
+ # Authoritative commit under the lock. The pre-check above is only a
476
+ # fast path — the network subscribe just ran with the lock RELEASED,
477
+ # so in the meantime the session may have been torn down
478
+ # (detach_listener), a racing subscribe may have claimed this URI, or
479
+ # a concurrent burst may have pushed the session to its cap. Re-check
480
+ # before storing, and gate on `@sessions.key?(session_id)` BEFORE
481
+ # indexing: `@sessions` auto-vivifies (`Hash.new { {} }`), so a bare
482
+ # `@sessions[session_id][uri] = …` would silently RESURRECT a detached
483
+ # session and leak its LiveQuery socket for the process lifetime.
484
+ #
485
+ # A subscribe may legitimately arrive before the GET listening stream
486
+ # opens (the session entry exists, just no listener yet); updates
487
+ # published before a listener attaches are dropped by the notifier
488
+ # and start delivering once the stream is up.
489
+ outcome = @mutex.synchronize do
490
+ if !@sessions.key?(session_id)
491
+ :session_gone
492
+ elsif @sessions[session_id].key?(uri)
493
+ :duplicate
494
+ elsif @max_per_session && @sessions[session_id].size >= @max_per_session
495
+ :over_cap
496
+ else
497
+ @sessions[session_id][uri] = { sub: sub, debouncer: debouncer }
498
+ :stored
499
+ end
500
+ end
501
+
502
+ case outcome
503
+ when :stored
504
+ true
505
+ when :duplicate
506
+ # A concurrent subscribe to the same URI won; keep theirs and drop
507
+ # the socket we just opened so we don't leak a duplicate.
508
+ safe_unsubscribe(sub)
509
+ true
510
+ when :session_gone
511
+ # The listening stream closed while we were subscribing — don't
512
+ # resurrect it; tear the just-opened socket back down.
513
+ safe_unsubscribe(sub)
514
+ false
515
+ when :over_cap
516
+ safe_unsubscribe(sub)
517
+ raise Parse::Agent::ValidationError,
518
+ "Session subscription limit reached (#{@max_per_session}). " \
519
+ "Unsubscribe from a resource before adding another."
520
+ end
521
+ end
522
+
523
+ # Stop the LiveQuery subscription for one resource URI. Idempotent.
524
+ #
525
+ # @return [Boolean] true if a subscription was removed.
526
+ def unsubscribe(session_id:, uri:)
527
+ entry = @mutex.synchronize do
528
+ subs = @sessions[session_id]
529
+ e = subs.delete(uri)
530
+ @sessions.delete(session_id) if subs.empty?
531
+ e
532
+ end
533
+ return false unless entry
534
+ safe_unsubscribe(entry[:sub])
535
+ true
536
+ end
537
+
538
+ # @return [Integer] number of active (session, uri) subscriptions.
539
+ def subscription_count
540
+ @mutex.synchronize { @sessions.values.sum(&:size) }
541
+ end
542
+
543
+ private
544
+
545
+ # Pick the LiveQuery connection appropriate to the derived credentials.
546
+ #
547
+ # Parse Server has no per-subscription master key: ACL-bypass is fixed
548
+ # at connect time. So a master-key-posture subscription MUST ride an
549
+ # admin connection (one socket authenticated with the master key) to
550
+ # see ACL-restricted rows; a session-token subscription rides a normal
551
+ # connection and is scoped by the per-subscription token. We therefore
552
+ # keep two clients and route by credential. An injected single client
553
+ # (tests) overrides the split.
554
+ def client_for(creds)
555
+ return @both_client if @both_client
556
+ if creds[:use_master_key]
557
+ @client_mutex.synchronize do
558
+ @admin_client ||= Parse::LiveQuery::Client.new(use_master_key: true)
559
+ end
560
+ else
561
+ # A session-token subscription MUST ride an ACL-scoped connection.
562
+ # `Parse::LiveQuery.client` inherits `config.use_master_key`, so a
563
+ # global `Parse::LiveQuery.configure { |c| c.use_master_key = true }`
564
+ # would make this shared client an ADMIN (ACL-bypassing) socket —
565
+ # and because Parse Server fixes ACL-bypass per-connection at connect
566
+ # time (no per-subscription master key), every session-token
567
+ # subscription on it would then deliver change events for rows the
568
+ # user cannot read. Fail closed rather than open a mis-scoped
569
+ # channel, mirroring the master-key branch's authority gate.
570
+ sc = @client_mutex.synchronize { @scoped_client ||= Parse::LiveQuery.client }
571
+ if sc.respond_to?(:admin_connection?) && sc.admin_connection?
572
+ raise Parse::Agent::SecurityError,
573
+ "the scoped LiveQuery client is an admin (master-key) connection " \
574
+ "(config.use_master_key = true); refusing to bridge a session-token " \
575
+ "subscription over an ACL-bypassing socket. Configure a non-admin " \
576
+ "LiveQuery client for scoped subscriptions."
577
+ end
578
+ sc
579
+ end
580
+ end
581
+
582
+ # Emit one coarse resources/updated for the URI through the notifier.
583
+ def publish_update(session_id, uri)
584
+ notification = {
585
+ "jsonrpc" => "2.0",
586
+ "method" => "notifications/resources/updated",
587
+ "params" => { "uri" => uri },
588
+ }
589
+ @notifier.publish(session_id, notification)
590
+ rescue StandardError => e
591
+ warn_logger("publish error for #{uri}: #{e.class}: #{e.message}")
592
+ end
593
+
594
+ def safe_unsubscribe(sub)
595
+ sub&.unsubscribe
596
+ rescue StandardError => e
597
+ warn_logger("LiveQuery unsubscribe error: #{e.class}: #{e.message}")
598
+ end
599
+
600
+ def warn_logger(line)
601
+ full = "[Parse::Agent::MCPSubscriptions::Manager] #{line}"
602
+ @logger ? @logger.warn(full) : warn(full)
603
+ end
604
+ end
605
+ end
606
+ end
607
+ end
@@ -594,6 +594,64 @@ module Parse
594
594
  Parse::Agent::MetadataRegistry.register_tenant_scope_bypass(parse_class_name, block)
595
595
  end
596
596
 
597
+ # Opt a class in to the `semantic_search` agent tool.
598
+ #
599
+ # Declares which `:vector` property the tool searches and which
600
+ # fields an LLM may constrain via the tool's `filter:` /
601
+ # `vector_filter:` inputs. Per-field opt-in is required:
602
+ # multimodal classes can carry several vector fields, and
603
+ # `agent_searchable` opens exactly the one named.
604
+ #
605
+ # @example
606
+ # class KnowledgeArticle < Parse::Object
607
+ # property :title, :string
608
+ # property :body, :string
609
+ # property :embedding, :vector, dimensions: 1536, provider: :openai
610
+ # embed :title, :body, into: :embedding
611
+ # agent_searchable field: :embedding, filter_fields: %i[published category]
612
+ # end
613
+ #
614
+ # # Two embed text sources, so semantic_search needs text_field: to
615
+ # # choose which one to chunk and return as content:
616
+ # # semantic_search(class_name: "KnowledgeArticle", query: "...",
617
+ # # text_field: "body")
618
+ #
619
+ # @param field [Symbol] the `:vector` property the tool searches.
620
+ # @param filter_fields [Array<Symbol>] fields the agent may pass
621
+ # in `filter:` / `vector_filter:`. Anything not listed is
622
+ # refused at the tool boundary. Defaults to `[]` — an empty
623
+ # allowlist, which is fail-closed by design: until you enumerate
624
+ # fields here the agent can run only an unfiltered query plus the
625
+ # enforced tenant scope. This is intentional (no field is
626
+ # filterable until explicitly opted in), not a silent off-switch.
627
+ # @raise [ArgumentError] when `field` is not a declared `:vector`
628
+ # property on the class.
629
+ def agent_searchable(field:, filter_fields: [])
630
+ parse_class_name = respond_to?(:parse_class) ? parse_class : name
631
+ field_sym = field.to_sym
632
+ if respond_to?(:vector_properties) && !vector_properties.key?(field_sym)
633
+ raise ArgumentError,
634
+ "agent_searchable field: :#{field_sym} is not a declared :vector property " \
635
+ "on #{parse_class_name} (declared: #{vector_properties.keys.inspect})."
636
+ end
637
+ filters = Array(filter_fields).map(&:to_sym)
638
+ @agent_searchable_field = field_sym
639
+ @agent_searchable_filter_fields = filters
640
+ Parse::Agent::MetadataRegistry.register_searchable(
641
+ parse_class_name, field: field_sym, filter_fields: filters,
642
+ )
643
+ end
644
+
645
+ # @return [Symbol, nil] the vector field declared via {#agent_searchable}.
646
+ def agent_searchable_field
647
+ @agent_searchable_field
648
+ end
649
+
650
+ # @return [Array<Symbol>] filter fields declared via {#agent_searchable}.
651
+ def agent_searchable_filter_fields
652
+ @agent_searchable_filter_fields || []
653
+ end
654
+
597
655
  # Check if this model has any agent metadata defined.
598
656
  #
599
657
  # @return [Boolean] true if any metadata is present