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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. 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