parse-stack-next 5.1.1 → 5.2.1
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 +630 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +6 -1
- data/README.md +226 -39
- data/Rakefile +56 -10
- data/docs/atlas_vector_search_guide.md +110 -9
- data/docs/mcp_guide.md +504 -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 +65 -13
- 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 +42 -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/lib/parse/webhooks/payload.rb +62 -34
- data/lib/parse/webhooks.rb +15 -3
- 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
|
@@ -757,6 +757,15 @@ non-master scope so this enforcement always runs. See the
|
|
|
757
757
|
[Atlas Vector Search Guide](./atlas_vector_search_guide.md) for the
|
|
758
758
|
full API surface.
|
|
759
759
|
|
|
760
|
+
`Parse::Retrieval.retrieve` and the `semantic_search` agent tool (v5.2)
|
|
761
|
+
build directly on `find_similar`, so they inherit this exact Layer 1-5
|
|
762
|
+
mongo-direct enforcement. The earlier RAG plan's "two-stage" REST
|
|
763
|
+
re-query was intentionally NOT adopted — there is no REST vector path,
|
|
764
|
+
and `acl_user:` / `acl_role:` scopes have no REST equivalent, so the
|
|
765
|
+
post-`$vectorSearch` `_rperm` `$match` is the single enforcement
|
|
766
|
+
boundary. The retrieval layer adds a tenant-scope fold into the Atlas
|
|
767
|
+
pre-filter on top of this, never a substitute for it.
|
|
768
|
+
|
|
760
769
|
### Timeouts
|
|
761
770
|
|
|
762
771
|
```ruby
|
|
@@ -947,7 +956,7 @@ three independent flags that every mutation re-checks per call.
|
|
|
947
956
|
Requires `clusterMonitor` privilege on the reader; returns `{}`
|
|
948
957
|
when not granted so callers degrade gracefully.
|
|
949
958
|
|
|
950
|
-
### Model DSL: `mongo_index` / `mongo_geo_index` / `mongo_relation_index`
|
|
959
|
+
### Model DSL: `mongo_index` / `unique_index_on` / `mongo_geo_index` / `mongo_relation_index`
|
|
951
960
|
|
|
952
961
|
Index declarations are class-level metadata on `Parse::Object`
|
|
953
962
|
subclasses. They run validation at registration time so a typo,
|
|
@@ -967,6 +976,7 @@ class Car < Parse::Object
|
|
|
967
976
|
|
|
968
977
|
mongo_index :make, :model, :year # compound
|
|
969
978
|
mongo_index :vin, unique: true
|
|
979
|
+
unique_index_on :registration # dedup floor; unique { registration: 1 }
|
|
970
980
|
mongo_index :owner # pointer auto-rewrites to _p_owner
|
|
971
981
|
mongo_geo_index :location # 2dsphere on GeoJSON Point
|
|
972
982
|
mongo_index :tags # array field
|
|
@@ -1007,6 +1017,61 @@ class Author < Parse::Object
|
|
|
1007
1017
|
end
|
|
1008
1018
|
```
|
|
1009
1019
|
|
|
1020
|
+
### `unique_index_on` — the `first_or_create!` correctness floor
|
|
1021
|
+
|
|
1022
|
+
`unique_index_on(*fields, sparse: false, partial: nil, name: nil)` declares
|
|
1023
|
+
a unique index on the exact dedup tuple that `first_or_create!` and
|
|
1024
|
+
`create_or_update!` key on. It is thin sugar over
|
|
1025
|
+
`mongo_index(*fields, unique: true, …)` — same registration, same validation
|
|
1026
|
+
(sensitive-field guard, pointer auto-rewrite, parallel-array / relation /
|
|
1027
|
+
`_id` rejection), same `apply_indexes!` writer path — but the name states the
|
|
1028
|
+
intent: these fields are the create-or-update identity.
|
|
1029
|
+
|
|
1030
|
+
```ruby
|
|
1031
|
+
class Subscription < Parse::Object
|
|
1032
|
+
property :email, :string
|
|
1033
|
+
belongs_to :tenant, as: :user
|
|
1034
|
+
|
|
1035
|
+
unique_index_on :email, :tenant # key: { email: 1, _p_tenant: 1 } unique
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
Subscription.apply_indexes! # provisions the index via the writer gate
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
**Why it matters.** The Redis-backed `synchronize:` lock on `first_or_create!`
|
|
1042
|
+
is a *latency optimization*: in the common path it collapses concurrent
|
|
1043
|
+
callers so only one issues the create. The unique index is the *correctness
|
|
1044
|
+
floor* that survives the lock being bypassed — a Redis outage, a TTL expiring
|
|
1045
|
+
between the existence check and the write, a caller passing
|
|
1046
|
+
`synchronize: false`, or two app servers whose lock secrets disagree. When a
|
|
1047
|
+
race slips past the lock, the loser's insert fails with `DuplicateValue`
|
|
1048
|
+
(Parse error 137), which `first_or_create!` rescues and resolves to the
|
|
1049
|
+
winning row. Lock plus index make the net invariant — *exactly one row, every
|
|
1050
|
+
caller sees the same id* — hold under any race, not just the happy path.
|
|
1051
|
+
|
|
1052
|
+
**Defaults are non-sparse, on purpose.** The index key is kept identical to
|
|
1053
|
+
the query `first_or_create!` re-runs on recovery (`_scoped_first` on the same
|
|
1054
|
+
`query_attrs`), so a 137 always corresponds to a row the recovery query can
|
|
1055
|
+
find. A sparse or partial index that fires on a condition the recovery query
|
|
1056
|
+
doesn't reproduce would surface a 137 the rescue can't resolve, and the error
|
|
1057
|
+
would re-raise. `sparse:` only changes behavior for a document missing *every*
|
|
1058
|
+
field in the tuple — a compound sparse index indexes a doc when it has at
|
|
1059
|
+
least one key, and `first_or_create!` always writes the full tuple, so sparse
|
|
1060
|
+
never weakens the floor. Leave it off unless out-of-band writers create
|
|
1061
|
+
tuple-less rows you want excluded.
|
|
1062
|
+
|
|
1063
|
+
For "unique within a subset" — unique email per tenant, but rows with no
|
|
1064
|
+
tenant may repeat — a partial filter is the right tool, **not** `sparse:`
|
|
1065
|
+
(a compound sparse index still collides two rows that share the present
|
|
1066
|
+
fields). You own the filter's lifecycle and must keep the recovery query
|
|
1067
|
+
consistent with it:
|
|
1068
|
+
|
|
1069
|
+
```ruby
|
|
1070
|
+
# Unique email per tenant; tenant-less rows are not constrained.
|
|
1071
|
+
unique_index_on :email, :tenant,
|
|
1072
|
+
partial: { "_p_tenant" => { "$exists" => true } }
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1010
1075
|
### Migrator: `indexes_plan` (dry-run) / `apply_indexes!` (mutate)
|
|
1011
1076
|
|
|
1012
1077
|
`Parse::Schema::IndexMigrator` reconciles declared indexes against the
|
|
@@ -66,6 +66,7 @@ paying for an index nobody uses.
|
|
|
66
66
|
| Regular B-tree | `mongo_index :field` | Equality, range, sort on a scalar field |
|
|
67
67
|
| Compound | `mongo_index :a, :b, :c` | Multi-field queries with a common prefix |
|
|
68
68
|
| Unique | `mongo_index :field, unique: true` | Enforce uniqueness at the DB layer |
|
|
69
|
+
| Unique dedup floor | `unique_index_on :a, :b` | Name the `first_or_create!` / `create_or_update!` dedup tuple; sugar for `unique: true` (non-sparse) |
|
|
69
70
|
| Sparse | `mongo_index :field, sparse: true` | Field present on only some documents |
|
|
70
71
|
| Partial | `mongo_index :field, partial: { … }` | Index only documents matching a filter |
|
|
71
72
|
| TTL | `mongo_index :field, expire_after: N` | Auto-delete documents N seconds after the timestamp |
|
|
@@ -318,7 +319,27 @@ fails.
|
|
|
318
319
|
|
|
319
320
|
**Better:** `unique: true, sparse: true` for "unique when present".
|
|
320
321
|
This is exactly what `parse_reference` auto-registers, and it's the
|
|
321
|
-
right pattern for any optional uniqueness constraint
|
|
322
|
+
right pattern for any optional uniqueness constraint *on a single
|
|
323
|
+
field*.
|
|
324
|
+
|
|
325
|
+
**Sparse does NOT generalize to compound keys.** A compound sparse
|
|
326
|
+
index excludes a document only when it is missing *every* indexed
|
|
327
|
+
field; a document that has at least one key is still indexed. So for a
|
|
328
|
+
two-field tuple, two rows that share the present field and both omit
|
|
329
|
+
the other still collide under `sparse: true`. For "unique within a
|
|
330
|
+
subset" — e.g. unique `email` per `tenant`, but tenant-less rows may
|
|
331
|
+
repeat — use a **partial filter**, not sparse:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
unique_index_on :email, :tenant,
|
|
335
|
+
partial: { "_p_tenant" => { "$exists" => true } }
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
For the `first_or_create!` / `create_or_update!` dedup tuple, prefer
|
|
339
|
+
`unique_index_on` (sugar for `unique: true`, **non-sparse** by default
|
|
340
|
+
so the index key matches the query the upsert re-runs on recovery). It
|
|
341
|
+
is the durable correctness floor behind the synchronize-create lock —
|
|
342
|
+
see the MongoDB Direct guide for the full rationale.
|
|
322
343
|
|
|
323
344
|
### Geo without proper coordinate order
|
|
324
345
|
|
data/docs/usage_guide.md
CHANGED
|
@@ -133,6 +133,21 @@ song = Song.first_or_create!({ title: "My Song" }, { artist: "Unknown" })
|
|
|
133
133
|
song = Song.create_or_update!({ title: "My Song" }, { plays: 100 })
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
Under concurrency these have a TOCTOU window. Pass `synchronize: true` to
|
|
137
|
+
serialize the find→create→save through a Moneta-backed lock, and declare a
|
|
138
|
+
`unique_index_on` on the dedup tuple as the durable correctness floor — the lock
|
|
139
|
+
optimizes latency, the unique index guarantees a single row even if the lock is
|
|
140
|
+
bypassed:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
class Song < Parse::Object
|
|
144
|
+
property :title, :string
|
|
145
|
+
unique_index_on :title # provisioned via Song.apply_indexes!
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Song.first_or_create!({ title: "My Song" }, { artist: "Unknown" }, synchronize: true)
|
|
149
|
+
```
|
|
150
|
+
|
|
136
151
|
## ACLs (Access Control)
|
|
137
152
|
|
|
138
153
|
```ruby
|
|
Binary file
|
|
@@ -170,7 +170,11 @@ module Parse
|
|
|
170
170
|
# These enable bcrypt-hash and session-token oracle attacks via
|
|
171
171
|
# count deltas even when operators are otherwise clean.
|
|
172
172
|
assert_where_key_permitted!(key)
|
|
173
|
-
result[columnize(key)] =
|
|
173
|
+
result[columnize(key)] = if key == "$relatedTo"
|
|
174
|
+
translate_related_to_value(value, depth: 0, agent: agent)
|
|
175
|
+
else
|
|
176
|
+
translate_value(value, depth: 0, agent: agent)
|
|
177
|
+
end
|
|
174
178
|
end
|
|
175
179
|
end
|
|
176
180
|
|
|
@@ -213,30 +217,48 @@ module Parse
|
|
|
213
217
|
# Check if it's a Parse type (Pointer, Date, File, GeoPoint)
|
|
214
218
|
return hash if parse_type?(hash)
|
|
215
219
|
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
220
|
+
# Classify each key INDEPENDENTLY rather than branching on whether
|
|
221
|
+
# *every* key is an operator. A hash that mixes an operator key with a
|
|
222
|
+
# non-operator (field) sibling — reachable as a `$or`/`$and`/`$nor`
|
|
223
|
+
# array element — must still validate and dispatch the operator. The
|
|
224
|
+
# previous all-or-nothing `keys.all?(operator)` gate routed any mixed
|
|
225
|
+
# hash to the field branch, which skipped `validate_operator!` AND the
|
|
226
|
+
# cross-class / `$relatedTo` accessibility checks: a blocked operator
|
|
227
|
+
# (`$where`) or an off-allowlist cross-class / relation reference could
|
|
228
|
+
# smuggle through alongside a throwaway field key. Per-key dispatch
|
|
229
|
+
# closes that hole while preserving behavior for pure-operator and
|
|
230
|
+
# pure-field hashes.
|
|
231
|
+
hash.transform_keys(&:to_s).each_with_object({}) do |(k, v), result|
|
|
232
|
+
if k.start_with?("$")
|
|
233
|
+
result[k] = translate_operator_value(k, v, depth: depth, agent: agent)
|
|
234
|
+
else
|
|
235
|
+
# Field-name key: enforce the internal-field denylist at every
|
|
236
|
+
# nesting level (so a key nested inside `$and`/`$or`/`$nor` cannot
|
|
237
|
+
# bypass the top-level check), then columnize and recurse.
|
|
234
238
|
assert_where_key_permitted!(k)
|
|
235
239
|
result[columnize(k)] = translate_value(v, depth: depth + 1, agent: agent)
|
|
236
240
|
end
|
|
237
241
|
end
|
|
238
242
|
end
|
|
239
243
|
|
|
244
|
+
# Validate and translate a single operator (`$`-prefixed) key/value pair.
|
|
245
|
+
# Centralized so the operator denylist/whitelist and the cross-class /
|
|
246
|
+
# `$relatedTo` accessibility checks run for operators in pure-operator
|
|
247
|
+
# hashes AND for operators mixed with field-key siblings.
|
|
248
|
+
def translate_operator_value(op, val, depth:, agent: nil)
|
|
249
|
+
validate_operator!(op)
|
|
250
|
+
# NEW-TOOLS-7: validate $regex / $options operands before
|
|
251
|
+
# forwarding to MongoDB.
|
|
252
|
+
assert_regex_operand_safe!(op, val) if op == "$regex" || op == "$options"
|
|
253
|
+
if CROSS_CLASS_OPERATORS.include?(op)
|
|
254
|
+
translate_cross_class_value(op, val, depth: depth + 1, agent: agent)
|
|
255
|
+
elsif op == "$relatedTo"
|
|
256
|
+
translate_related_to_value(val, depth: depth + 1, agent: agent)
|
|
257
|
+
else
|
|
258
|
+
translate_value(val, depth: depth + 1, agent: agent)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
240
262
|
# Translate the value of a cross-class operator
|
|
241
263
|
# (+$inQuery+/+$notInQuery+/+$select+/+$dontSelect+). The value
|
|
242
264
|
# carries an embedded +className+ that must be validated against
|
|
@@ -299,6 +321,55 @@ module Parse
|
|
|
299
321
|
translate_value(val, depth: depth, agent: agent)
|
|
300
322
|
end
|
|
301
323
|
|
|
324
|
+
# Validate the owning-object class named by a +$relatedTo+ constraint.
|
|
325
|
+
#
|
|
326
|
+
# +$relatedTo+ has the shape +{ object: <Pointer>, key: <relation field> }+.
|
|
327
|
+
# Unlike +$inQuery+ / +$select+ it carries no +className+ / inner +where+,
|
|
328
|
+
# so it is NOT a {CROSS_CLASS_OPERATORS} entry — but it DOES reach across
|
|
329
|
+
# to a second class: the owning object whose relation is being read. Left
|
|
330
|
+
# unvalidated, an agent narrowed to one class (or with a class globally
|
|
331
|
+
# +agent_hidden+) could still name a relation anchored on an off-allowlist
|
|
332
|
+
# class via the +object+ pointer. That is the SDK-surface analog of
|
|
333
|
+
# GHSA-wmwx-jr2p-4j4r, where Parse Server's own +$relatedTo+ bypassed the
|
|
334
|
+
# owning object's ACL. Run the owning class through the same accessibility
|
|
335
|
+
# policy as every other cross-class hop, then translate the value normally.
|
|
336
|
+
#
|
|
337
|
+
# Fails closed when the owning class cannot be resolved from +object+: an
|
|
338
|
+
# unresolvable pointer is exactly the shape that would otherwise slip the
|
|
339
|
+
# check, so refuse the constraint rather than skip it.
|
|
340
|
+
def translate_related_to_value(val, depth:, agent: nil)
|
|
341
|
+
owning_class = related_to_owning_class(val)
|
|
342
|
+
if owning_class.nil? || owning_class.to_s.empty?
|
|
343
|
+
raise ConstraintSecurityError.new(
|
|
344
|
+
"SECURITY: $relatedTo requires a resolvable owning-object class; " \
|
|
345
|
+
"none could be determined from its `object` pointer.",
|
|
346
|
+
operator: "$relatedTo",
|
|
347
|
+
reason: :cross_class_denied,
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
assert_embedded_class_accessible!("$relatedTo", owning_class, agent: agent)
|
|
351
|
+
translate_value(val, depth: depth, agent: agent)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Extract the Parse class name of a +$relatedTo+ constraint's owning
|
|
355
|
+
# object from its +object+ slot, which may be a Parse::Pointer, a Parse
|
|
356
|
+
# pointer/relation hash (+{__type:, className:, objectId:}+, string or
|
|
357
|
+
# symbol keys), or a storage-form string (+"ClassName$objectId"+).
|
|
358
|
+
# Returns nil when no class can be resolved so the caller can fail closed.
|
|
359
|
+
def related_to_owning_class(val)
|
|
360
|
+
return nil unless val.is_a?(Hash)
|
|
361
|
+
obj = val["object"] || val[:object]
|
|
362
|
+
return obj.parse_class if obj.respond_to?(:parse_class)
|
|
363
|
+
case obj
|
|
364
|
+
when Hash
|
|
365
|
+
o = obj.transform_keys(&:to_s)
|
|
366
|
+
cn = o["className"]
|
|
367
|
+
cn.nil? || cn.to_s.empty? ? nil : cn
|
|
368
|
+
when String
|
|
369
|
+
obj.include?("$") ? obj.split("$", 2).first : nil
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
302
373
|
# Hook into the agent-side accessibility check when the agent
|
|
303
374
|
# module is loaded; in pure-unit contexts where +Parse::Agent::Tools+
|
|
304
375
|
# has not been loaded, default to a no-op rather than raising —
|
data/lib/parse/agent/describe.rb
CHANGED
data/lib/parse/agent/errors.rb
CHANGED
|
@@ -129,5 +129,21 @@ module Parse
|
|
|
129
129
|
# agent-callable" (a Parse::Error) or "the tier doesn't allow it"
|
|
130
130
|
# (a +:permission_denied+).
|
|
131
131
|
class MethodFiltered < AgentError; end
|
|
132
|
+
|
|
133
|
+
# Raised at semantic-search dispatch time when at least one class in
|
|
134
|
+
# the model registry declares +agent_tenant_scope+ but the class
|
|
135
|
+
# being searched does not. In a tenant-aware deployment an
|
|
136
|
+
# un-scoped searchable surface would let an agent retrieve across
|
|
137
|
+
# tenant boundaries, so the gate is a hard refusal, not a warning.
|
|
138
|
+
# Enforced at dispatch (when all classes are loaded) rather than at
|
|
139
|
+
# +agent_searchable+ declaration time so class-load order can't
|
|
140
|
+
# produce a false negative.
|
|
141
|
+
class MissingTenantScope < AgentError; end
|
|
142
|
+
|
|
143
|
+
# Raised when a tool result contains an operator-registered
|
|
144
|
+
# prompt-injection canary phrase AND `Parse::Agent.canary_action` is
|
|
145
|
+
# `:refuse`. A SecurityError subclass so it routes through execute's
|
|
146
|
+
# security rescue and is never swallowed.
|
|
147
|
+
class PromptInjectionDetected < SecurityError; end
|
|
132
148
|
end
|
|
133
149
|
end
|
|
@@ -528,7 +528,16 @@ module Parse
|
|
|
528
528
|
# @api private
|
|
529
529
|
def wrap_tool_content_for_llm(content)
|
|
530
530
|
s = content.to_s
|
|
531
|
+
# Idempotency first: our own already-wrapped output (marker at the
|
|
532
|
+
# head) passes through untouched, so re-wrapping across turns does
|
|
533
|
+
# not grow or mangle the marker.
|
|
531
534
|
return s if s.start_with?(UNTRUSTED_TOOL_RESULT_MARKER)
|
|
535
|
+
# Fresh content: neutralize any embedded wrapper/marker strings
|
|
536
|
+
# (e.g. a stored `</schema_description>` or a forged marker) BEFORE
|
|
537
|
+
# prepending the real marker, so a stored value can't impersonate or
|
|
538
|
+
# close the wrapper. The prefix we add afterward is therefore never
|
|
539
|
+
# seen as embedded by the scrub.
|
|
540
|
+
s = Parse::Agent::PromptHardening.scrub_marker_injection(s)
|
|
532
541
|
"#{UNTRUSTED_TOOL_RESULT_MARKER}\n#{s}"
|
|
533
542
|
end
|
|
534
543
|
|
|
@@ -132,7 +132,16 @@ module Parse
|
|
|
132
132
|
# are translated into a JSON-RPC `isError` content envelope by
|
|
133
133
|
# {#handle_tools_call}. Cleared from the agent in an ensure block
|
|
134
134
|
# before this method returns.
|
|
135
|
-
|
|
135
|
+
# @param subscription_manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
136
|
+
# the per-transport resource-subscription coordinator. When present and
|
|
137
|
+
# {Parse::Agent::MCPSubscriptions::Manager#supported? supported}, the
|
|
138
|
+
# `initialize` handshake advertises the `resources.subscribe` capability
|
|
139
|
+
# and `resources/subscribe` / `resources/unsubscribe` are routed to it.
|
|
140
|
+
# nil (the default, and the only option on non-streaming transports like
|
|
141
|
+
# the WEBrick MCPServer) leaves the capability unadvertised and those
|
|
142
|
+
# methods returning a "not supported" error.
|
|
143
|
+
def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil,
|
|
144
|
+
subscription_manager: nil, approval_gate: nil)
|
|
136
145
|
# Snapshot any prior callback/token already on the agent (e.g. a
|
|
137
146
|
# token a parent dispatcher installed before a tool handler
|
|
138
147
|
# invoked us recursively, or values pre-set by the application).
|
|
@@ -143,6 +152,7 @@ module Parse
|
|
|
143
152
|
# token.
|
|
144
153
|
prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
|
|
145
154
|
prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
|
|
155
|
+
prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
|
|
146
156
|
|
|
147
157
|
# Install the progress callback and cancellation token on the
|
|
148
158
|
# agent for the duration of the dispatch. Cleared in the ensure
|
|
@@ -156,6 +166,10 @@ module Parse
|
|
|
156
166
|
# is documented to return a fresh agent per request.
|
|
157
167
|
agent.progress_callback = progress_callback if progress_callback && agent.respond_to?(:progress_callback=)
|
|
158
168
|
agent.cancellation_token = cancellation_token if cancellation_token && agent.respond_to?(:cancellation_token=)
|
|
169
|
+
# Install the per-session approval gate (MCP elicitation) so
|
|
170
|
+
# agent.execute can request human approval for destructive tools.
|
|
171
|
+
# Restored in the ensure block like the other per-request state.
|
|
172
|
+
agent.approval_gate = approval_gate if approval_gate && agent.respond_to?(:approval_gate=)
|
|
159
173
|
|
|
160
174
|
# Guard: body must be a Hash with a "method" key.
|
|
161
175
|
unless body.is_a?(Hash) && body.key?("method")
|
|
@@ -175,7 +189,7 @@ module Parse
|
|
|
175
189
|
return { status: 200, body: jsonrpc_error(id, -32600, "Invalid Request: notifications must not carry an id") }
|
|
176
190
|
end
|
|
177
191
|
|
|
178
|
-
result_hash = dispatch(method, params, agent, id, logger)
|
|
192
|
+
result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
|
|
179
193
|
{ status: result_hash[:status], body: result_hash[:body] }
|
|
180
194
|
|
|
181
195
|
rescue Parse::Agent::Unauthorized => e
|
|
@@ -197,6 +211,9 @@ module Parse
|
|
|
197
211
|
if agent.respond_to?(:cancellation_token=)
|
|
198
212
|
agent.cancellation_token = prev_cancellation_token
|
|
199
213
|
end
|
|
214
|
+
if agent.respond_to?(:approval_gate=)
|
|
215
|
+
agent.approval_gate = prev_approval_gate
|
|
216
|
+
end
|
|
200
217
|
end
|
|
201
218
|
|
|
202
219
|
# Emit an internal-error diagnostic. The class+message are operator-only;
|
|
@@ -219,10 +236,10 @@ module Parse
|
|
|
219
236
|
# envelope, and return { status:, body: }.
|
|
220
237
|
#
|
|
221
238
|
# @api private
|
|
222
|
-
def self.dispatch(method, params, agent, id, logger = nil)
|
|
239
|
+
def self.dispatch(method, params, agent, id, logger = nil, subscription_manager = nil)
|
|
223
240
|
result = case method
|
|
224
241
|
when "initialize"
|
|
225
|
-
handle_initialize(params)
|
|
242
|
+
handle_initialize(params, subscription_manager)
|
|
226
243
|
when "tools/list"
|
|
227
244
|
handle_tools_list(params, agent)
|
|
228
245
|
when "tools/call"
|
|
@@ -233,6 +250,10 @@ module Parse
|
|
|
233
250
|
handle_resources_templates_list(params, agent)
|
|
234
251
|
when "resources/read"
|
|
235
252
|
handle_resources_read(params, agent)
|
|
253
|
+
when "resources/subscribe"
|
|
254
|
+
handle_resources_subscribe(params, agent, subscription_manager)
|
|
255
|
+
when "resources/unsubscribe"
|
|
256
|
+
handle_resources_unsubscribe(params, agent, subscription_manager)
|
|
236
257
|
when "prompts/list"
|
|
237
258
|
handle_prompts_list(params)
|
|
238
259
|
when "prompts/get"
|
|
@@ -278,6 +299,12 @@ module Parse
|
|
|
278
299
|
|
|
279
300
|
rescue Parse::Agent::Unauthorized => e
|
|
280
301
|
{ status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
|
|
302
|
+
rescue Parse::Agent::AccessDenied
|
|
303
|
+
# Class-authorization denial (agent_hidden / classes: allowlist), e.g.
|
|
304
|
+
# from the resources/subscribe gate. Map to -32602 with a generic
|
|
305
|
+
# message — do NOT echo the class name, so a denied subscribe can't be
|
|
306
|
+
# used to probe which hidden classes exist.
|
|
307
|
+
{ status: 200, body: jsonrpc_error(id, -32602, "Invalid params") }
|
|
281
308
|
rescue Parse::Agent::SecurityError
|
|
282
309
|
{ status: 200, body: jsonrpc_error(id, -32602, "Invalid params") }
|
|
283
310
|
rescue Parse::Agent::ValidationError => e
|
|
@@ -307,8 +334,11 @@ module Parse
|
|
|
307
334
|
# returning the server's preferred version locks those clients
|
|
308
335
|
# out.
|
|
309
336
|
#
|
|
337
|
+
# @param subscription_manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
338
|
+
# when supported, flips the advertised `resources.subscribe` capability
|
|
339
|
+
# to true. See {#capabilities_for}.
|
|
310
340
|
# @return [Hash] protocol version, capabilities, and server info.
|
|
311
|
-
def self.handle_initialize(params)
|
|
341
|
+
def self.handle_initialize(params, subscription_manager = nil)
|
|
312
342
|
requested = params.is_a?(Hash) ? params["protocolVersion"] : nil
|
|
313
343
|
negotiated =
|
|
314
344
|
if requested.is_a?(String) && SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
|
|
@@ -318,7 +348,7 @@ module Parse
|
|
|
318
348
|
end
|
|
319
349
|
{
|
|
320
350
|
"protocolVersion" => negotiated,
|
|
321
|
-
"capabilities" =>
|
|
351
|
+
"capabilities" => capabilities_for(subscription_manager),
|
|
322
352
|
"serverInfo" => {
|
|
323
353
|
"name" => "parse-stack-mcp",
|
|
324
354
|
"version" => Parse::Stack::VERSION,
|
|
@@ -327,6 +357,27 @@ module Parse
|
|
|
327
357
|
end
|
|
328
358
|
private_class_method :handle_initialize
|
|
329
359
|
|
|
360
|
+
# Compute the advertised capability object for this transport.
|
|
361
|
+
#
|
|
362
|
+
# `resources.subscribe` is advertised as `true` ONLY when a subscription
|
|
363
|
+
# manager is wired AND reports itself supported (LiveQuery enabled +
|
|
364
|
+
# available, on a streaming transport that can hold a listening channel).
|
|
365
|
+
# Advertising a capability is a contract: we never claim `subscribe: true`
|
|
366
|
+
# unless the server can actually deliver `notifications/resources/updated`.
|
|
367
|
+
# On the WEBrick MCPServer (no streaming) and on the Rack app when
|
|
368
|
+
# subscriptions are disabled, this falls back to the base CAPABILITIES
|
|
369
|
+
# with `subscribe: false`.
|
|
370
|
+
#
|
|
371
|
+
# @param manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
372
|
+
# @return [Hash]
|
|
373
|
+
def self.capabilities_for(manager)
|
|
374
|
+
return CAPABILITIES unless manager.respond_to?(:supported?) && manager.supported?
|
|
375
|
+
CAPABILITIES.merge(
|
|
376
|
+
"resources" => CAPABILITIES["resources"].merge("subscribe" => true),
|
|
377
|
+
)
|
|
378
|
+
end
|
|
379
|
+
private_class_method :capabilities_for
|
|
380
|
+
|
|
330
381
|
# Handle `tools/list`.
|
|
331
382
|
#
|
|
332
383
|
# Accepts an optional non-standard `category` param (Parse Stack
|
|
@@ -438,12 +489,24 @@ module Parse
|
|
|
438
489
|
envelope
|
|
439
490
|
end
|
|
440
491
|
else
|
|
441
|
-
|
|
492
|
+
# Forward the structured error metadata the agent already computed
|
|
493
|
+
# (error_code, retry_after, details such as suggested_rewrite /
|
|
494
|
+
# allowed_fields) so a client can branch deterministically and
|
|
495
|
+
# honor retry_after — instead of re-parsing the prose message. Goes
|
|
496
|
+
# in `_meta` (spec-allowed arbitrary metadata) under a `parse.`
|
|
497
|
+
# prefix; the human-readable text content is unchanged.
|
|
498
|
+
envelope = {
|
|
442
499
|
"content" => [
|
|
443
500
|
{ "type" => "text", "text" => result[:error].to_s },
|
|
444
501
|
],
|
|
445
502
|
"isError" => true,
|
|
446
503
|
}
|
|
504
|
+
meta = {}
|
|
505
|
+
meta["parse.error_code"] = result[:error_code].to_s if result[:error_code]
|
|
506
|
+
meta["parse.retry_after"] = result[:retry_after] if result[:retry_after]
|
|
507
|
+
meta["parse.details"] = result[:details] if result[:details].is_a?(Hash) && result[:details].any?
|
|
508
|
+
envelope["_meta"] = meta unless meta.empty?
|
|
509
|
+
envelope
|
|
447
510
|
end
|
|
448
511
|
end
|
|
449
512
|
private_class_method :handle_tools_call
|
|
@@ -938,6 +1001,75 @@ module Parse
|
|
|
938
1001
|
end
|
|
939
1002
|
private_class_method :handle_resources_read
|
|
940
1003
|
|
|
1004
|
+
# Handle `resources/subscribe` (MCP 2025-06-18).
|
|
1005
|
+
#
|
|
1006
|
+
# Registers a LiveQuery-backed subscription for `params["uri"]` keyed by
|
|
1007
|
+
# the agent's session identity (`correlation_id`, sourced from the
|
|
1008
|
+
# `Mcp-Session-Id` header by the transport). Subsequent data changes are
|
|
1009
|
+
# debounced and delivered as `notifications/resources/updated` over the
|
|
1010
|
+
# session's GET listening stream.
|
|
1011
|
+
#
|
|
1012
|
+
# Per the MCP spec a successful subscribe returns an empty result. Errors
|
|
1013
|
+
# propagate as JSON-RPC errors:
|
|
1014
|
+
# - manager absent / unsupported → -32601 (capability not offered)
|
|
1015
|
+
# - malformed or non-subscribable URI → -32602 (ValidationError)
|
|
1016
|
+
# - agent scope with no LiveQuery equivalent → -32602 (SecurityError)
|
|
1017
|
+
#
|
|
1018
|
+
# @param manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
1019
|
+
# @return [Hash] empty result, or an `:error` hash when unsupported.
|
|
1020
|
+
def self.handle_resources_subscribe(params, agent, manager)
|
|
1021
|
+
return subscriptions_unsupported_error unless manager.respond_to?(:supported?) && manager.supported?
|
|
1022
|
+
ok = manager.subscribe(
|
|
1023
|
+
session_id: agent_session_id(agent),
|
|
1024
|
+
uri: params["uri"].to_s,
|
|
1025
|
+
agent: agent,
|
|
1026
|
+
)
|
|
1027
|
+
return {} if ok
|
|
1028
|
+
# subscribe returned false: the session's listening stream was torn down
|
|
1029
|
+
# (detach_listener) while the network subscribe was in flight, so no
|
|
1030
|
+
# subscription exists and no notifications/resources/updated will arrive.
|
|
1031
|
+
# Surface an error rather than a false empty-success ack (per the MCP
|
|
1032
|
+
# contract an empty result == subscribed) so the client reopens its GET
|
|
1033
|
+
# stream and retries instead of waiting forever for updates.
|
|
1034
|
+
{ error: { "code" => -32602,
|
|
1035
|
+
"message" => "resources/subscribe: the session no longer has an open " \
|
|
1036
|
+
"listening stream; reopen the GET stream and retry" } }
|
|
1037
|
+
end
|
|
1038
|
+
private_class_method :handle_resources_subscribe
|
|
1039
|
+
|
|
1040
|
+
# Handle `resources/unsubscribe` (MCP 2025-06-18). Idempotent — stops the
|
|
1041
|
+
# LiveQuery subscription for the URI if one exists, returns an empty
|
|
1042
|
+
# result regardless.
|
|
1043
|
+
#
|
|
1044
|
+
# @param manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
1045
|
+
# @return [Hash] empty result, or an `:error` hash when unsupported.
|
|
1046
|
+
def self.handle_resources_unsubscribe(params, agent, manager)
|
|
1047
|
+
return subscriptions_unsupported_error unless manager.respond_to?(:supported?) && manager.supported?
|
|
1048
|
+
manager.unsubscribe(
|
|
1049
|
+
session_id: agent_session_id(agent),
|
|
1050
|
+
uri: params["uri"].to_s,
|
|
1051
|
+
)
|
|
1052
|
+
{}
|
|
1053
|
+
end
|
|
1054
|
+
private_class_method :handle_resources_unsubscribe
|
|
1055
|
+
|
|
1056
|
+
# The session identity used to key resource subscriptions. The transport
|
|
1057
|
+
# populates `agent.correlation_id` from `Mcp-Session-Id`.
|
|
1058
|
+
# @return [String, nil]
|
|
1059
|
+
def self.agent_session_id(agent)
|
|
1060
|
+
agent.respond_to?(:correlation_id) ? agent.correlation_id : nil
|
|
1061
|
+
end
|
|
1062
|
+
private_class_method :agent_session_id
|
|
1063
|
+
|
|
1064
|
+
# Error hash returned when a subscribe/unsubscribe arrives but this
|
|
1065
|
+
# transport does not offer the capability. -32601 (method not found) is
|
|
1066
|
+
# the correct code per JSON-RPC for an unoffered method.
|
|
1067
|
+
# @return [Hash]
|
|
1068
|
+
def self.subscriptions_unsupported_error
|
|
1069
|
+
{ error: { "code" => -32601, "message" => "Resource subscriptions are not supported by this server" } }
|
|
1070
|
+
end
|
|
1071
|
+
private_class_method :subscriptions_unsupported_error
|
|
1072
|
+
|
|
941
1073
|
# Handle `prompts/list`.
|
|
942
1074
|
#
|
|
943
1075
|
# Delegates to `Parse::Agent::Prompts.list`, which returns an Array of
|