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
|
@@ -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
|