parse-stack-next 4.5.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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
class Agent
|
|
8
|
+
# Registry module that enriches server schemas with local model metadata.
|
|
9
|
+
# Merges class descriptions, property descriptions, and agent-allowed methods
|
|
10
|
+
# from registered Parse::Object models into the schema data returned by the agent.
|
|
11
|
+
#
|
|
12
|
+
# @example Enriching a schema
|
|
13
|
+
# server_schema = { "className" => "Song", "fields" => { ... } }
|
|
14
|
+
# enriched = MetadataRegistry.enriched_schema("Song", server_schema)
|
|
15
|
+
# # enriched now includes :description and :agent_methods if defined
|
|
16
|
+
#
|
|
17
|
+
module MetadataRegistry
|
|
18
|
+
extend self
|
|
19
|
+
|
|
20
|
+
# Thread-safe storage for visible classes
|
|
21
|
+
@visible_classes = []
|
|
22
|
+
@visible_mutex = Mutex.new
|
|
23
|
+
|
|
24
|
+
# Thread-safe storage for hidden classes — opt-in PII / sensitive
|
|
25
|
+
# classes that are denied to every agent tool surface.
|
|
26
|
+
@hidden_classes = []
|
|
27
|
+
@hidden_mutex = Mutex.new
|
|
28
|
+
|
|
29
|
+
# Per-class exception scopes for `agent_hidden(except: ...)`. Maps a class
|
|
30
|
+
# object to the scope it permits (currently only :master_key). Absence
|
|
31
|
+
# from this hash means the class is unconditionally hidden to every
|
|
32
|
+
# agent regardless of auth context. Guarded by `@hidden_mutex` (no
|
|
33
|
+
# separate mutex — every read/write happens alongside a hidden-set
|
|
34
|
+
# access, so reusing the lock avoids the lock-order coupling of two).
|
|
35
|
+
@hidden_exceptions = {}
|
|
36
|
+
|
|
37
|
+
# Thread-safe storage for per-class tenant scope rules.
|
|
38
|
+
# Maps parse_class_name => { field: Symbol, from: Proc }
|
|
39
|
+
@tenant_scope_rules = {}
|
|
40
|
+
@tenant_scope_mutex = Mutex.new
|
|
41
|
+
|
|
42
|
+
# Thread-safe storage for per-class tenant scope bypass procs.
|
|
43
|
+
# Maps parse_class_name => Proc
|
|
44
|
+
@tenant_scope_bypasses = {}
|
|
45
|
+
@tenant_scope_bypass_mutex = Mutex.new
|
|
46
|
+
|
|
47
|
+
# Register a class as visible to agents.
|
|
48
|
+
# @param klass [Class] the model class
|
|
49
|
+
def register_visible_class(klass)
|
|
50
|
+
@visible_mutex.synchronize do
|
|
51
|
+
@visible_classes << klass unless @visible_classes.include?(klass)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Register a class as hidden from agent tools (opt-in PII denial).
|
|
56
|
+
# @param klass [Class] the model class
|
|
57
|
+
# @param except [Symbol, nil] when `:master_key`, the class is still
|
|
58
|
+
# reachable by master-key agents but refused for session-bound agents.
|
|
59
|
+
# When nil (default), the class is hidden from every agent regardless
|
|
60
|
+
# of auth context. Re-calling `register_hidden_class` with a different
|
|
61
|
+
# `except:` value updates the scope (last-write-wins) — this is what
|
|
62
|
+
# lets an application re-mark `Parse::Session` with the relaxed scope
|
|
63
|
+
# after the parse-stack default marked it with the strict one.
|
|
64
|
+
def register_hidden_class(klass, except: nil)
|
|
65
|
+
@hidden_mutex.synchronize do
|
|
66
|
+
@hidden_classes << klass unless @hidden_classes.include?(klass)
|
|
67
|
+
if except.nil?
|
|
68
|
+
@hidden_exceptions.delete(klass)
|
|
69
|
+
else
|
|
70
|
+
@hidden_exceptions[klass] = except
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Reverse a prior `register_hidden_class` call. Used by `agent_unhidden`
|
|
76
|
+
# to re-expose a class that was marked hidden by an upstream declaration
|
|
77
|
+
# (typically a parse-stack built-in like `Parse::Product` or a base class
|
|
78
|
+
# in an application's own model hierarchy). Removing the class from the
|
|
79
|
+
# registry is what actually allows `query_class` / `aggregate` / schema
|
|
80
|
+
# enumeration etc. to address it again — the per-class `@agent_hidden`
|
|
81
|
+
# ivar alone is not consulted by the tool surface.
|
|
82
|
+
# @param klass [Class] the model class
|
|
83
|
+
def unregister_hidden_class(klass)
|
|
84
|
+
@hidden_mutex.synchronize do
|
|
85
|
+
@hidden_classes.delete(klass)
|
|
86
|
+
@hidden_exceptions.delete(klass)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Look up the per-class hidden-exception scope (`:master_key` or nil) for
|
|
91
|
+
# a Parse class name. Returns nil when the class is not hidden at all
|
|
92
|
+
# OR when it is hidden with no exception. Caller must compare against
|
|
93
|
+
# the agent's auth context to decide whether the exception applies.
|
|
94
|
+
# @param class_name [String, Symbol]
|
|
95
|
+
# @return [Symbol, nil]
|
|
96
|
+
def hidden_exception_for(class_name)
|
|
97
|
+
return nil if class_name.nil?
|
|
98
|
+
target = class_name.to_s
|
|
99
|
+
@hidden_mutex.synchronize do
|
|
100
|
+
@hidden_classes.each do |klass|
|
|
101
|
+
next unless hidden_name_variants_for(klass).include?(target)
|
|
102
|
+
return @hidden_exceptions[klass]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Class names (Parse class names) that are hidden from every agent tool.
|
|
109
|
+
# @return [Array<String>]
|
|
110
|
+
def hidden_class_names
|
|
111
|
+
@hidden_mutex.synchronize { @hidden_classes.dup }.map do |klass|
|
|
112
|
+
klass.respond_to?(:parse_class) ? klass.parse_class : klass.name
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check whether a class name is denied to agent tools.
|
|
117
|
+
#
|
|
118
|
+
# An LLM writing aggregations against Parse-on-Mongo will naturally
|
|
119
|
+
# type system classes by their alias form (`"User"`, `"Role"`,
|
|
120
|
+
# `"Installation"`, `"Session"`) even though the canonical
|
|
121
|
+
# `parse_class` is the `_`-prefixed form (`"_User"`, etc.). Similarly,
|
|
122
|
+
# a class declared with `parse_class "Foo"` lives in the registry as
|
|
123
|
+
# `"Foo"` but a caller might pass the Ruby class name.
|
|
124
|
+
#
|
|
125
|
+
# {.hidden_name_variants_for} expands each registered hidden class to
|
|
126
|
+
# every form a caller might submit; this predicate is a pure string
|
|
127
|
+
# match against that expanded set. Closes the oracle where an LLM
|
|
128
|
+
# could write `$lookup: { from: "User" }` and bypass an
|
|
129
|
+
# `agent_hidden`-on-`Parse::User` because the registry only knew
|
|
130
|
+
# `"_User"`.
|
|
131
|
+
#
|
|
132
|
+
# @param class_name [String, Symbol]
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def hidden?(class_name)
|
|
135
|
+
return false if class_name.nil?
|
|
136
|
+
hidden_name_set.include?(class_name.to_s)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# All hidden-class name variants a caller might submit. Includes the
|
|
140
|
+
# canonical `parse_class`, the un-prefixed alias when `parse_class`
|
|
141
|
+
# starts with `_` (system-class form), and the Ruby class name when
|
|
142
|
+
# it differs from `parse_class` (`parse_class "Foo"` override). The
|
|
143
|
+
# `hidden_name_variants_for` helper MUST NOT take `@hidden_mutex` —
|
|
144
|
+
# it's called from inside the synchronize block here, and recursive
|
|
145
|
+
# locking would deadlock.
|
|
146
|
+
# @return [Array<String>]
|
|
147
|
+
def hidden_name_set
|
|
148
|
+
@hidden_mutex.synchronize do
|
|
149
|
+
@hidden_classes.flat_map { |klass| hidden_name_variants_for(klass) }.uniq
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Compute the set of names a caller might use to reference `klass`.
|
|
154
|
+
#
|
|
155
|
+
# Variants emitted:
|
|
156
|
+
#
|
|
157
|
+
# - `parse_class` (canonical, always).
|
|
158
|
+
# - `parse_class` stripped of a leading `_` (system-class alias form;
|
|
159
|
+
# e.g. `_User` -> `User`).
|
|
160
|
+
# - Ruby class name when it differs from `parse_class`.
|
|
161
|
+
#
|
|
162
|
+
# **Known limitation — collision direction is safe but technically
|
|
163
|
+
# over-broad.** If application code declares one class with
|
|
164
|
+
# `parse_class "_Foo"` and *also* a separate class with
|
|
165
|
+
# `parse_class "Foo"`, hiding the `_Foo` class implicitly causes
|
|
166
|
+
# `hidden?("Foo")` to return true as well, refusing reads on the
|
|
167
|
+
# un-prefixed sibling. The refusal direction is the safer one
|
|
168
|
+
# (false positive on the gate, not a leak), and the collision is
|
|
169
|
+
# contrived enough — `_`-prefixed parse_class names are reserved
|
|
170
|
+
# in practice for Parse's own system classes — that we accept the
|
|
171
|
+
# trade-off. Applications that genuinely need both can either rename
|
|
172
|
+
# one, or call `agent_hidden` on both explicitly.
|
|
173
|
+
#
|
|
174
|
+
# @param klass [Class]
|
|
175
|
+
# @return [Array<String>]
|
|
176
|
+
def hidden_name_variants_for(klass)
|
|
177
|
+
variants = []
|
|
178
|
+
if klass.respond_to?(:parse_class) && klass.parse_class
|
|
179
|
+
pc = klass.parse_class.to_s
|
|
180
|
+
variants << pc
|
|
181
|
+
variants << pc.sub(/\A_/, "") if pc.start_with?("_")
|
|
182
|
+
end
|
|
183
|
+
if klass.respond_to?(:name) && klass.name && !klass.name.include?("::") && !variants.include?(klass.name)
|
|
184
|
+
# Skip names containing `::` -- those are Ruby constant paths
|
|
185
|
+
# (e.g. `"Parse::User"`) that no LLM would write in a `$lookup`,
|
|
186
|
+
# and including them only adds noise to `hidden_name_set`.
|
|
187
|
+
variants << klass.name
|
|
188
|
+
end
|
|
189
|
+
variants
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Check whether a class name is accessible to agent tools.
|
|
193
|
+
# Inverse of {#hidden?}. Use at tool-dispatch time to refuse access
|
|
194
|
+
# before any query hits Parse Server.
|
|
195
|
+
# @param class_name [String, Symbol]
|
|
196
|
+
# @return [Boolean]
|
|
197
|
+
def accessible?(class_name)
|
|
198
|
+
!hidden?(class_name)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Get all registered visible classes.
|
|
202
|
+
# @return [Array<Class>]
|
|
203
|
+
def visible_classes
|
|
204
|
+
@visible_mutex.synchronize { @visible_classes.dup }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Get visible class names (Parse class names).
|
|
208
|
+
# @return [Array<String>]
|
|
209
|
+
def visible_class_names
|
|
210
|
+
visible_classes.map do |klass|
|
|
211
|
+
klass.respond_to?(:parse_class) ? klass.parse_class : klass.name
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Check if any classes are registered as visible.
|
|
216
|
+
# @return [Boolean]
|
|
217
|
+
def has_visible_classes?
|
|
218
|
+
@visible_mutex.synchronize { @visible_classes.any? }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Filter schemas to only include visible classes.
|
|
222
|
+
# If no classes are marked visible, returns all schemas.
|
|
223
|
+
#
|
|
224
|
+
# @param schemas [Array<Hash>] schemas from Parse Server
|
|
225
|
+
# @return [Array<Hash>] filtered schemas
|
|
226
|
+
def filter_visible_schemas(schemas)
|
|
227
|
+
return schemas unless has_visible_classes?
|
|
228
|
+
|
|
229
|
+
visible_names = visible_class_names
|
|
230
|
+
schemas.select { |s| visible_names.include?(s["className"]) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Fields that always pass through the agent_fields allowlist filter.
|
|
234
|
+
# These carry semantic meaning the LLM needs even when not explicitly
|
|
235
|
+
# listed as analytics-relevant.
|
|
236
|
+
ALWAYS_KEEP_FIELDS = %w[objectId createdAt updatedAt].freeze
|
|
237
|
+
|
|
238
|
+
# Per-field metadata keys that bloat the agent schema response without
|
|
239
|
+
# helping analytics queries. Dropped before the schema reaches the LLM.
|
|
240
|
+
NOISY_FIELD_METADATA = %w[indexed].freeze
|
|
241
|
+
|
|
242
|
+
# Enrich a server schema with local model metadata.
|
|
243
|
+
#
|
|
244
|
+
# @param class_name [String] the Parse class name
|
|
245
|
+
# @param server_schema [Hash] the schema from Parse Server
|
|
246
|
+
# @param agent_permission [Symbol] the agent's permission level for method filtering
|
|
247
|
+
# @param edges [Array<Hash>, nil] pre-built relation edges from
|
|
248
|
+
# {RelationGraph.build}. When omitted, edges are built on demand for
|
|
249
|
+
# this single class; pass a pre-built array when enriching many
|
|
250
|
+
# schemas in a row to avoid the N+1 traversal.
|
|
251
|
+
# @return [Hash] the enriched schema
|
|
252
|
+
def enriched_schema(class_name, server_schema, agent_permission: :readonly, edges: nil)
|
|
253
|
+
klass = find_model_class(class_name)
|
|
254
|
+
return server_schema unless klass&.respond_to?(:has_agent_metadata?) && klass.has_agent_metadata?
|
|
255
|
+
|
|
256
|
+
schema = deep_dup(server_schema)
|
|
257
|
+
|
|
258
|
+
# Add class description
|
|
259
|
+
if klass.agent_description
|
|
260
|
+
schema["description"] = klass.agent_description
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Add class-level analytics usage hint (distinct from description)
|
|
264
|
+
if klass.respond_to?(:agent_usage) && klass.agent_usage
|
|
265
|
+
schema["usage"] = klass.agent_usage
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Enrich fields with property descriptions
|
|
269
|
+
if schema["fields"] && klass.property_descriptions.any?
|
|
270
|
+
schema["fields"] = enrich_fields(schema["fields"], klass)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Filter fields to the declared allowlist (plus always-on system fields).
|
|
274
|
+
# When no allowlist is declared, leave the field set alone.
|
|
275
|
+
# Delegates to field_allowlist so allowlist symbols declared as Ruby
|
|
276
|
+
# property names (snake_case) are normalized to the wire-format column
|
|
277
|
+
# names (camelCase or explicit `field:` alias) before comparing against
|
|
278
|
+
# Parse Server's schema keys. Without this normalization a model with
|
|
279
|
+
# `agent_fields :device_type` filters against `"device_type"`, but the
|
|
280
|
+
# server schema carries `"deviceType"` and the field is silently
|
|
281
|
+
# stripped.
|
|
282
|
+
if schema["fields"] && (allowed = field_allowlist(class_name))
|
|
283
|
+
schema["fields"] = schema["fields"].select { |name, _| allowed.include?(name) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Strip noisy per-field metadata regardless of allowlist
|
|
287
|
+
if schema["fields"]
|
|
288
|
+
schema["fields"] = schema["fields"].transform_values do |config|
|
|
289
|
+
next config unless config.is_a?(Hash)
|
|
290
|
+
cleaned = config.reject { |k, _| NOISY_FIELD_METADATA.include?(k) }
|
|
291
|
+
# Drop defaultValue if it's effectively empty (nil/empty string carry no signal)
|
|
292
|
+
cleaned = cleaned.reject { |k, v| k == "defaultValue" && (v.nil? || v == "") }
|
|
293
|
+
cleaned
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Add agent-allowed methods (filtered by permission)
|
|
298
|
+
available_methods = klass.agent_methods_for(agent_permission)
|
|
299
|
+
if available_methods.any?
|
|
300
|
+
schema["agent_methods"] = format_methods(available_methods)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Surface the canonical "valid state" filter so an LLM that opts
|
|
304
|
+
# out via `apply_canonical_filter: false` on a query can
|
|
305
|
+
# reproduce the same predicate manually. The filter is applied
|
|
306
|
+
# BY DEFAULT on `query_class`/`count_objects`/`aggregate`.
|
|
307
|
+
canonical = klass.respond_to?(:agent_canonical_filter_for_apply) ?
|
|
308
|
+
klass.agent_canonical_filter_for_apply : nil
|
|
309
|
+
if canonical && canonical.any?
|
|
310
|
+
schema["canonical_filter"] = canonical.dup
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Echo the wire-format `agent_fields` allowlist explicitly. The
|
|
314
|
+
# registry already enforces the allowlist by stripping non-allowed
|
|
315
|
+
# fields from `schema["fields"]`, but enforcement-by-omission left
|
|
316
|
+
# an LLM guessing what it could write in `keys:` and led to
|
|
317
|
+
# repeated refusals on storage-form column names (`_p_author`,
|
|
318
|
+
# etc.). Listing the wire names alongside the trimmed fields hash
|
|
319
|
+
# closes that gap. `ALWAYS_KEEP_FIELDS` (objectId/createdAt/
|
|
320
|
+
# updatedAt) is filtered out — those are always available and
|
|
321
|
+
# would only noise up the echo.
|
|
322
|
+
allowed = field_allowlist(class_name)
|
|
323
|
+
if allowed && (allowed - ALWAYS_KEEP_FIELDS).any?
|
|
324
|
+
schema["agent_fields"] = (allowed - ALWAYS_KEEP_FIELDS)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Echo the narrower join projection (wire-format) when declared.
|
|
328
|
+
# Tells the LLM "when I'm included as a pointer on another class's
|
|
329
|
+
# query, you'll see these fields and nothing else" so it can plan
|
|
330
|
+
# the include path without a follow-up `get_schema`.
|
|
331
|
+
join_proj = join_projection_fields(class_name)
|
|
332
|
+
if join_proj && (join_proj[:project] - ALWAYS_KEEP_FIELDS).any?
|
|
333
|
+
schema["agent_join_fields"] = (join_proj[:project] - ALWAYS_KEEP_FIELDS)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Embed this class's relationship edges (incoming/outgoing) so the LLM
|
|
337
|
+
# sees pointer/relation context alongside fields. Keeps each schema
|
|
338
|
+
# response self-contained without the cost of the full graph.
|
|
339
|
+
per_class = Parse::Agent::RelationGraph.edges_for(class_name, edges)
|
|
340
|
+
if per_class[:outgoing].any? || per_class[:incoming].any?
|
|
341
|
+
schema["relations"] = {
|
|
342
|
+
"outgoing" => per_class[:outgoing].map { |e| edge_summary(e) },
|
|
343
|
+
"incoming" => per_class[:incoming].map { |e| edge_summary(e) },
|
|
344
|
+
}
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
schema
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Resolve the agent_fields allowlist for a Parse class name. Returns an
|
|
351
|
+
# array of field-name strings including the always-keep system fields,
|
|
352
|
+
# or nil when the model has no allowlist declared (callers should treat
|
|
353
|
+
# nil as "no filtering — return everything").
|
|
354
|
+
#
|
|
355
|
+
# @param class_name [String] the Parse class name
|
|
356
|
+
# @return [Array<String>, nil] allowlist or nil
|
|
357
|
+
def field_allowlist(class_name)
|
|
358
|
+
klass = find_model_class(class_name)
|
|
359
|
+
return nil unless klass&.respond_to?(:agent_field_allowlist)
|
|
360
|
+
allowlist = klass.agent_field_allowlist
|
|
361
|
+
return nil if allowlist.empty?
|
|
362
|
+
# Translate each allowlist entry to its wire-format column name.
|
|
363
|
+
# Priority: the class's field_map (Ruby symbol -> wire symbol) so
|
|
364
|
+
# explicit `field:` aliases (`property :external_id, field: "ExtId"`)
|
|
365
|
+
# resolve to the actual column. Fallback: `String#columnize` so plain
|
|
366
|
+
# snake_case Ruby names (`:device_type` -> `"deviceType"`) match
|
|
367
|
+
# Parse Server's lowerCamelCase wire format. Without this translation
|
|
368
|
+
# the allowlist filter was case-sensitive against snake_case strings
|
|
369
|
+
# and silently stripped legitimate camelCase columns from schema
|
|
370
|
+
# enrichment, `keys:` projection, and pipeline policy enforcement.
|
|
371
|
+
fmap = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
372
|
+
resolved = allowlist.map do |name|
|
|
373
|
+
mapped = fmap[name.to_sym]
|
|
374
|
+
# When field_map carries an explicit wire name (e.g. a `property
|
|
375
|
+
# :external_id, field: :ExternalReferenceCode` alias), use it
|
|
376
|
+
# verbatim — columnize would lowercase the first character and
|
|
377
|
+
# break the alias. Without a mapping, columnize the Ruby symbol
|
|
378
|
+
# to convert snake_case to lowerCamelCase wire format.
|
|
379
|
+
mapped ? mapped.to_s : name.to_s.columnize
|
|
380
|
+
end
|
|
381
|
+
# Defense-in-depth: refuse to surface Parse Server internal columns
|
|
382
|
+
# (`_hashed_password`, `_session_token`, `_rperm`/`_wperm`, etc.) on
|
|
383
|
+
# the agent surface, regardless of whether a developer accidentally
|
|
384
|
+
# mapped a `property :pw, field: :_hashed_password` and listed it in
|
|
385
|
+
# `agent_fields`. The columnize fallback already strips the leading
|
|
386
|
+
# underscore for snake_case entries; this drop targets the wire-name
|
|
387
|
+
# path that bypasses columnize.
|
|
388
|
+
resolved.reject! { |wire| Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(wire) }
|
|
389
|
+
resolved | ALWAYS_KEEP_FIELDS
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Resolve the wire-format projection set used when this class appears
|
|
393
|
+
# as an included pointer on another class's query. Drives the
|
|
394
|
+
# auto-projection that turns `keys: ["user"] + include: ["user"]`
|
|
395
|
+
# into `keys: "user,user.firstName,user.email,..."` server-side.
|
|
396
|
+
#
|
|
397
|
+
# Resolution order (first match wins):
|
|
398
|
+
#
|
|
399
|
+
# 1. `agent_join_fields` → those entries (wire-format).
|
|
400
|
+
# 2. `agent_fields` declared → `agent_fields - agent_large_fields`.
|
|
401
|
+
# 3. Only `agent_large_fields` declared → all `field_map` properties
|
|
402
|
+
# minus the large set.
|
|
403
|
+
# 4. None of the above → nil (no auto-projection; caller gets the
|
|
404
|
+
# full included record exactly as Parse Server returns it).
|
|
405
|
+
#
|
|
406
|
+
# The returned array always includes `ALWAYS_KEEP_FIELDS` (objectId /
|
|
407
|
+
# createdAt / updatedAt). Internal Parse Server columns
|
|
408
|
+
# (`_hashed_password`, `_session_token`, `_rperm`, etc.) are filtered
|
|
409
|
+
# at the end as a defense-in-depth pass, identical to
|
|
410
|
+
# {#field_allowlist}, so an accidental `property :pw, field:
|
|
411
|
+
# :_hashed_password` cannot leak through the join surface.
|
|
412
|
+
#
|
|
413
|
+
# @param class_name [String] the joined Parse class name
|
|
414
|
+
# @return [Hash, nil] {project: Array<String>, dropped: Array<String>,
|
|
415
|
+
# source: Symbol} or nil. `project` is the positive wire-format
|
|
416
|
+
# field list. `dropped` is the wire names this projection actively
|
|
417
|
+
# omits (used to populate the `truncated_include_fields` envelope).
|
|
418
|
+
# `source` is one of :join_fields, :allowlist_minus_large,
|
|
419
|
+
# :field_map_minus_large for diagnostics / testing.
|
|
420
|
+
def join_projection_fields(class_name)
|
|
421
|
+
klass = find_model_class(class_name)
|
|
422
|
+
return nil unless klass
|
|
423
|
+
fmap = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
424
|
+
to_wire = ->(sym) {
|
|
425
|
+
mapped = fmap[sym.to_sym]
|
|
426
|
+
mapped ? mapped.to_s : sym.to_s.columnize
|
|
427
|
+
}
|
|
428
|
+
large_wire = if klass.respond_to?(:agent_large_field_list)
|
|
429
|
+
klass.agent_large_field_list.map(&to_wire)
|
|
430
|
+
else
|
|
431
|
+
[]
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
join_list = klass.respond_to?(:agent_join_field_list) ? klass.agent_join_field_list : []
|
|
435
|
+
if join_list.any?
|
|
436
|
+
project = join_list.map(&to_wire)
|
|
437
|
+
source = :join_fields
|
|
438
|
+
# dropped: large fields that are NOT in the join projection.
|
|
439
|
+
# The caller asked us to project to a narrow set; report large
|
|
440
|
+
# fields they didn't include so they can re-ask explicitly.
|
|
441
|
+
dropped = large_wire - project
|
|
442
|
+
return finalize_join_projection(project, dropped, source)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
allow_list = klass.respond_to?(:agent_field_allowlist) ? klass.agent_field_allowlist : []
|
|
446
|
+
if allow_list.any?
|
|
447
|
+
allow_wire = allow_list.map(&to_wire)
|
|
448
|
+
project = allow_wire - large_wire
|
|
449
|
+
# If everything in the allowlist is also large, fall through
|
|
450
|
+
# rather than projecting to an empty set (would surface a useless
|
|
451
|
+
# `{}` user object).
|
|
452
|
+
unless project.empty?
|
|
453
|
+
dropped = large_wire & allow_wire
|
|
454
|
+
return finalize_join_projection(project, dropped, :allowlist_minus_large)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
if large_wire.any?
|
|
459
|
+
# Strip mode: no positive allowlist, but we know which fields are
|
|
460
|
+
# heavy. Project to (declared properties - large fields). Limited
|
|
461
|
+
# to fields the Ruby model knows about — server-side columns not
|
|
462
|
+
# declared as `property` won't come back, but that's an honest
|
|
463
|
+
# trade-off (we can only project what we can name).
|
|
464
|
+
known_wire = fmap.values.map(&:to_s)
|
|
465
|
+
project = known_wire - large_wire
|
|
466
|
+
return nil if project.empty?
|
|
467
|
+
dropped = large_wire & known_wire
|
|
468
|
+
return finalize_join_projection(project, dropped, :field_map_minus_large)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
nil
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# @api private
|
|
475
|
+
def finalize_join_projection(project, dropped, source)
|
|
476
|
+
project = (project | ALWAYS_KEEP_FIELDS)
|
|
477
|
+
project.reject! { |wire| Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(wire) }
|
|
478
|
+
dropped = dropped.reject { |wire| Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(wire) }
|
|
479
|
+
{ project: project, dropped: dropped, source: source }
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Enrich multiple schemas at once. Builds the relation graph exactly
|
|
483
|
+
# once and threads it through each per-schema enrichment so the
|
|
484
|
+
# combined call is O(classes) rather than O(classes^2).
|
|
485
|
+
#
|
|
486
|
+
# @param server_schemas [Array<Hash>] schemas from Parse Server
|
|
487
|
+
# @param agent_permission [Symbol] the agent's permission level
|
|
488
|
+
# @return [Array<Hash>] enriched schemas
|
|
489
|
+
def enriched_schemas(server_schemas, agent_permission: :readonly)
|
|
490
|
+
edges = Parse::Agent::RelationGraph.build
|
|
491
|
+
server_schemas.map do |schema|
|
|
492
|
+
enriched_schema(schema["className"], schema, agent_permission: agent_permission, edges: edges)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Get the class description for a Parse class if registered.
|
|
497
|
+
#
|
|
498
|
+
# @param class_name [String] the Parse class name
|
|
499
|
+
# @return [String, nil] the description or nil
|
|
500
|
+
def class_description(class_name)
|
|
501
|
+
klass = find_model_class(class_name)
|
|
502
|
+
klass&.respond_to?(:agent_description) ? klass.agent_description : nil
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Get property descriptions for a Parse class if registered.
|
|
506
|
+
#
|
|
507
|
+
# @param class_name [String] the Parse class name
|
|
508
|
+
# @return [Hash<Symbol, String>] field descriptions
|
|
509
|
+
def property_descriptions(class_name)
|
|
510
|
+
klass = find_model_class(class_name)
|
|
511
|
+
return {} unless klass&.respond_to?(:property_descriptions)
|
|
512
|
+
klass.property_descriptions || {}
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Get agent methods for a Parse class filtered by permission.
|
|
516
|
+
#
|
|
517
|
+
# @param class_name [String] the Parse class name
|
|
518
|
+
# @param agent_permission [Symbol] the agent's permission level
|
|
519
|
+
# @return [Hash<Symbol, Hash>] available methods
|
|
520
|
+
def agent_methods(class_name, agent_permission: :readonly)
|
|
521
|
+
klass = find_model_class(class_name)
|
|
522
|
+
return {} unless klass&.respond_to?(:agent_methods_for)
|
|
523
|
+
klass.agent_methods_for(agent_permission)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Check if a model class has agent metadata.
|
|
527
|
+
#
|
|
528
|
+
# @param class_name [String] the Parse class name
|
|
529
|
+
# @return [Boolean]
|
|
530
|
+
def has_metadata?(class_name)
|
|
531
|
+
klass = find_model_class(class_name)
|
|
532
|
+
klass&.respond_to?(:has_agent_metadata?) && klass.has_agent_metadata?
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Check whether COLLSCANs are explicitly permitted for the given class.
|
|
536
|
+
# Returns true when the model declares `agent_allow_collscan true`, false
|
|
537
|
+
# otherwise (including when no model class is registered).
|
|
538
|
+
#
|
|
539
|
+
# @param class_name [String] the Parse class name
|
|
540
|
+
# @return [Boolean]
|
|
541
|
+
def allow_collscan?(class_name)
|
|
542
|
+
klass = find_model_class(class_name)
|
|
543
|
+
return false unless klass&.respond_to?(:agent_allow_collscan?)
|
|
544
|
+
klass.agent_allow_collscan?
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Look up the canonical "valid state" filter declared via
|
|
548
|
+
# `agent_canonical_filter` on the model class. Returns nil when
|
|
549
|
+
# no filter is declared.
|
|
550
|
+
#
|
|
551
|
+
# @param class_name [String] the Parse class name
|
|
552
|
+
# @return [Hash, nil] a String-keyed where-style hash, or nil
|
|
553
|
+
def canonical_filter(class_name)
|
|
554
|
+
klass = find_model_class(class_name)
|
|
555
|
+
return nil unless klass&.respond_to?(:agent_canonical_filter_for_apply)
|
|
556
|
+
klass.agent_canonical_filter_for_apply
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# ============================================================
|
|
560
|
+
# Tenant Scope Registry
|
|
561
|
+
# ============================================================
|
|
562
|
+
|
|
563
|
+
# Register a tenant scope rule for a class.
|
|
564
|
+
#
|
|
565
|
+
# @param class_name [String] the Parse class name
|
|
566
|
+
# @param field [Symbol] the field to scope on
|
|
567
|
+
# @param from [Proc] callable receiving agent, returning the scope value
|
|
568
|
+
def register_tenant_scope(class_name, field, from:)
|
|
569
|
+
@tenant_scope_mutex.synchronize do
|
|
570
|
+
@tenant_scope_rules[class_name.to_s] = { field: field.to_sym, from: from }
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Register a bypass proc for a class's tenant scope.
|
|
575
|
+
#
|
|
576
|
+
# @param class_name [String] the Parse class name
|
|
577
|
+
# @param bypass_proc [Proc] callable receiving agent, returning truthy to bypass
|
|
578
|
+
def register_tenant_scope_bypass(class_name, bypass_proc)
|
|
579
|
+
@tenant_scope_bypass_mutex.synchronize do
|
|
580
|
+
@tenant_scope_bypasses[class_name.to_s] = bypass_proc
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Return the tenant scope rule for a class name, or nil if none declared.
|
|
585
|
+
#
|
|
586
|
+
# @param class_name [String] the Parse class name
|
|
587
|
+
# @return [Hash, nil] { field: Symbol, from: Proc } or nil
|
|
588
|
+
def tenant_scope_rule(class_name)
|
|
589
|
+
@tenant_scope_mutex.synchronize { @tenant_scope_rules[class_name.to_s] }
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Check whether the given agent should bypass the tenant scope for a class.
|
|
593
|
+
# Returns false when no bypass is registered or when the bypass proc raises.
|
|
594
|
+
#
|
|
595
|
+
# @param class_name [String] the Parse class name
|
|
596
|
+
# @param agent [Parse::Agent] the agent instance
|
|
597
|
+
# @return [Boolean]
|
|
598
|
+
def tenant_scope_bypassed?(class_name, agent)
|
|
599
|
+
bypass = @tenant_scope_bypass_mutex.synchronize { @tenant_scope_bypasses[class_name.to_s] }
|
|
600
|
+
return false unless bypass
|
|
601
|
+
begin
|
|
602
|
+
!!bypass.call(agent)
|
|
603
|
+
rescue StandardError
|
|
604
|
+
# A bypass proc that raises is treated as not-bypassed (fail closed).
|
|
605
|
+
false
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Resolve the effective tenant scope for a class and agent.
|
|
610
|
+
#
|
|
611
|
+
# Returns nil when:
|
|
612
|
+
# - No agent_tenant_scope is declared for this class (back-compat pass-through).
|
|
613
|
+
# - The bypass condition is satisfied (admin agents, etc.).
|
|
614
|
+
#
|
|
615
|
+
# Returns { field: Symbol, value: Object } when a scope should be enforced.
|
|
616
|
+
#
|
|
617
|
+
# Raises Parse::Agent::AccessDenied when:
|
|
618
|
+
# - A scope rule is declared and the bypass is not satisfied, but the
|
|
619
|
+
# agent's scope value (from: proc) returns nil — meaning the agent
|
|
620
|
+
# has no tenant binding and must not touch this class.
|
|
621
|
+
#
|
|
622
|
+
# @param class_name [String] the Parse class name
|
|
623
|
+
# @param agent [Parse::Agent] the agent instance
|
|
624
|
+
# @return [Hash, nil] { field: Symbol, value: Object } or nil
|
|
625
|
+
# @raise [Parse::Agent::AccessDenied]
|
|
626
|
+
def resolve_tenant_scope(class_name, agent)
|
|
627
|
+
rule = tenant_scope_rule(class_name)
|
|
628
|
+
return nil unless rule
|
|
629
|
+
|
|
630
|
+
return nil if tenant_scope_bypassed?(class_name, agent)
|
|
631
|
+
|
|
632
|
+
value = rule[:from].call(agent)
|
|
633
|
+
if value.nil?
|
|
634
|
+
raise Parse::Agent::AccessDenied.new(
|
|
635
|
+
class_name,
|
|
636
|
+
"Agent has no tenant binding for class '#{class_name}' which requires tenant scoping",
|
|
637
|
+
)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
{ field: rule[:field], value: value }
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
private
|
|
644
|
+
|
|
645
|
+
# Find the Ruby model class for a Parse class name.
|
|
646
|
+
#
|
|
647
|
+
# @param class_name [String] the Parse class name
|
|
648
|
+
# @return [Class, nil] the model class or nil
|
|
649
|
+
def find_model_class(class_name)
|
|
650
|
+
Parse::Model.find_class(class_name)
|
|
651
|
+
rescue NameError
|
|
652
|
+
# Expected - class not registered as a Ruby model
|
|
653
|
+
# This is normal for Parse classes without a corresponding Ruby class
|
|
654
|
+
nil
|
|
655
|
+
rescue StandardError => e
|
|
656
|
+
# Unexpected error - log it for debugging but don't crash
|
|
657
|
+
warn "[Parse::Agent::MetadataRegistry] Error finding model for '#{class_name}': #{e.class} - #{e.message}"
|
|
658
|
+
nil
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Deep duplicate a hash to avoid modifying the original.
|
|
662
|
+
#
|
|
663
|
+
# @param hash [Hash] the hash to duplicate
|
|
664
|
+
# @return [Hash] the duplicated hash
|
|
665
|
+
def deep_dup(hash)
|
|
666
|
+
return hash unless hash.is_a?(Hash)
|
|
667
|
+
hash.transform_values do |v|
|
|
668
|
+
case v
|
|
669
|
+
when Hash then deep_dup(v)
|
|
670
|
+
when Array then v.map { |e| e.is_a?(Hash) ? deep_dup(e) : e }
|
|
671
|
+
else v
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Enrich field configs with property descriptions.
|
|
677
|
+
#
|
|
678
|
+
# @param fields [Hash] the fields from server schema
|
|
679
|
+
# @param klass [Class] the model class
|
|
680
|
+
# @return [Hash] enriched fields
|
|
681
|
+
def enrich_fields(fields, klass)
|
|
682
|
+
descriptions = klass.property_descriptions
|
|
683
|
+
enums = klass.respond_to?(:property_enum_descriptions) ?
|
|
684
|
+
klass.property_enum_descriptions : {}
|
|
685
|
+
large_fields = klass.respond_to?(:agent_large_field_list) ? klass.agent_large_field_list : []
|
|
686
|
+
large_set = large_fields.map(&:to_sym).to_set
|
|
687
|
+
|
|
688
|
+
# Reverse field_map (wire symbol -> Ruby symbol) so descriptions
|
|
689
|
+
# and enums declared on properties with an explicit `field:`
|
|
690
|
+
# alias resolve correctly. Example: `property :external_status,
|
|
691
|
+
# :string, field: :ExtStatus, _description: "..."` stores the
|
|
692
|
+
# description under `:external_status`, but the server returns
|
|
693
|
+
# the column as `"ExtStatus"`. The 3-key sym/underscore/string
|
|
694
|
+
# chain misses it (`"ExtStatus".underscore.to_sym == :ext_status
|
|
695
|
+
# != :external_status`); the reverse lookup finds the Ruby
|
|
696
|
+
# property symbol from the wire name and recovers. Same bug
|
|
697
|
+
# class as the 4.2.1 fix on field_allowlist — the lookup must
|
|
698
|
+
# consult field_map to honor explicit aliases.
|
|
699
|
+
fmap_reverse = klass.respond_to?(:field_map) ? klass.field_map.invert : {}
|
|
700
|
+
|
|
701
|
+
fields.transform_keys.with_object({}) do |name, result|
|
|
702
|
+
config = fields[name]
|
|
703
|
+
config = config.is_a?(Hash) ? deep_dup(config) : { "type" => config.to_s }
|
|
704
|
+
|
|
705
|
+
# Look up description by the field_map reverse (property with
|
|
706
|
+
# an explicit `field:` alias), then by symbol, then by camelCase.
|
|
707
|
+
ruby_sym_from_wire = fmap_reverse[name.to_sym] || fmap_reverse[name.to_s.to_sym]
|
|
708
|
+
desc = (ruby_sym_from_wire && descriptions[ruby_sym_from_wire]) ||
|
|
709
|
+
descriptions[name.to_sym] ||
|
|
710
|
+
descriptions[name.to_s.underscore.to_sym] ||
|
|
711
|
+
descriptions[name.to_s]
|
|
712
|
+
|
|
713
|
+
config["description"] = desc if desc
|
|
714
|
+
|
|
715
|
+
# Per-value enum descriptions. Same 4-key lookup as the
|
|
716
|
+
# description path: reverse-mapped Ruby symbol (honors `field:`
|
|
717
|
+
# aliases), declared property symbol, underscored wire name,
|
|
718
|
+
# raw string. Emitted as a list of `{value:, description:}`
|
|
719
|
+
# objects so the JSON shape round-trips cleanly through MCP
|
|
720
|
+
# without depending on Hash ordering semantics in the consumer.
|
|
721
|
+
enum_hash = (ruby_sym_from_wire && enums[ruby_sym_from_wire]) ||
|
|
722
|
+
enums[name.to_sym] ||
|
|
723
|
+
enums[name.to_s.underscore.to_sym] ||
|
|
724
|
+
enums[name.to_s]
|
|
725
|
+
if enum_hash.is_a?(Hash) && enum_hash.any?
|
|
726
|
+
config["allowed_values"] = enum_hash.map do |value, value_desc|
|
|
727
|
+
{ "value" => value.to_s, "description" => value_desc.to_s }
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# `agent_large_fields` annotation. Skip Pointer/Relation types —
|
|
732
|
+
# the stored value is a small reference; only `include:`
|
|
733
|
+
# resolution materializes the underlying payload, and that is a
|
|
734
|
+
# query-time concern, not a schema-time hint.
|
|
735
|
+
ftype = config["type"].to_s
|
|
736
|
+
unless ftype == "Pointer" || ftype == "Relation"
|
|
737
|
+
sym_name = name.to_s.underscore.to_sym
|
|
738
|
+
if large_set.include?(sym_name) || large_set.include?(name.to_sym)
|
|
739
|
+
config["large_field"] = true
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
result[name] = config
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Compact a relation edge for inline schema embedding. Drops the
|
|
748
|
+
# `kind:` symbol (the `cardinality` already conveys belongs_to vs
|
|
749
|
+
# relation: `1:N` vs `N:M`) to keep the schema response short.
|
|
750
|
+
def edge_summary(edge)
|
|
751
|
+
{
|
|
752
|
+
"from" => edge[:from],
|
|
753
|
+
"to" => edge[:to],
|
|
754
|
+
"via" => edge[:via],
|
|
755
|
+
"cardinality" => edge[:cardinality],
|
|
756
|
+
}
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Format methods hash for schema output.
|
|
760
|
+
#
|
|
761
|
+
# Emits the full contract per declared `agent_method`: name, type
|
|
762
|
+
# (class vs instance), permission tier, description, dry-run
|
|
763
|
+
# support, the permitted_keys allowlist (when declared), and the
|
|
764
|
+
# parameters JSON Schema (when declared). Lets MCP consumers of
|
|
765
|
+
# `get_schema` discover which `call_method` invocations are
|
|
766
|
+
# available on a class WITHOUT needing prior knowledge of method
|
|
767
|
+
# names. Empty values are omitted via `.compact` so the wire
|
|
768
|
+
# envelope stays tight on methods that declared only the minimum.
|
|
769
|
+
#
|
|
770
|
+
# @param methods [Hash<Symbol, Hash>] the methods to format
|
|
771
|
+
# @return [Array<Hash>] formatted method list
|
|
772
|
+
def format_methods(methods)
|
|
773
|
+
methods.map do |name, info|
|
|
774
|
+
# `permitted_keys` names the keys accepted by `call_method` for
|
|
775
|
+
# this method. Disclosing it by default enumerates the write-field
|
|
776
|
+
# authorization boundary. Gate it behind `Parse::Agent.agent_debug?`
|
|
777
|
+
# (default false) so production `get_schema` responses do not
|
|
778
|
+
# expose which fields are mutable. Enable in trusted internal
|
|
779
|
+
# environments where the LLM needs the full method contract.
|
|
780
|
+
keys = Parse::Agent.agent_debug? ? info[:permitted_keys]&.map(&:to_s) : nil
|
|
781
|
+
{
|
|
782
|
+
name: name.to_s,
|
|
783
|
+
type: info[:type]&.to_s || "unknown",
|
|
784
|
+
permission: info[:permission]&.to_s || "readonly",
|
|
785
|
+
description: info[:description],
|
|
786
|
+
supports_dry_run: info[:supports_dry_run] ? true : nil,
|
|
787
|
+
permitted_keys: keys,
|
|
788
|
+
parameters: info[:parameters],
|
|
789
|
+
}.compact
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
end
|