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
data/docs/mcp_guide.md ADDED
@@ -0,0 +1,3484 @@
1
+ # Parse Stack MCP Guide
2
+
3
+ ## Overview
4
+
5
+ The Model Context Protocol (MCP) is a standardized JSON-RPC 2.0-based interface that lets external tools and agents interact with a server's capabilities in a structured way. Parse Stack exposes an MCP layer so any MCP-compatible client can query Parse data, inspect schemas, count objects, run aggregations, and invoke registered tools without writing application-specific integration code.
6
+
7
+ Three deployment modes are available:
8
+
9
+ - **Standalone HTTP server (`MCPServer`)** — a WEBrick process for dedicated MCP deployments.
10
+ - **Rack-mountable adapter (`MCPRackApp`)** — embeds inside an existing Sinatra or Rails application.
11
+ - **Direct in-process dispatcher (`MCPDispatcher`)** — a pure function for in-process usage, custom transports, and testing.
12
+
13
+ ---
14
+
15
+ ## Deployment Modes
16
+
17
+ ### Standalone HTTP server (MCPServer)
18
+
19
+ `Parse::Agent::MCPServer` wraps `Parse::Agent::MCPRackApp` in a WEBrick process. It is the fastest path to a working MCP endpoint and is well-suited for dedicated tooling services.
20
+
21
+ **Prerequisites.** The server requires both an environment variable and a programmatic flag before `enable_mcp!` will proceed:
22
+
23
+ ```ruby
24
+ # config/initializers/parse_mcp.rb (or equivalent boot file)
25
+ ENV["PARSE_MCP_ENABLED"] = "true" # must be set in the environment
26
+ Parse.mcp_server_enabled = true # must be set in code
27
+ ```
28
+
29
+ **Starting the server:**
30
+
31
+ ```ruby
32
+ Parse::Agent.enable_mcp!
33
+
34
+ Parse::Agent::MCPServer.run(
35
+ port: 3001,
36
+ host: "127.0.0.1", # default; do not bind to 0.0.0.0 without a firewall
37
+ permissions: :readonly, # :readonly, :write, or :admin
38
+ api_key: ENV["MCP_API_KEY"]
39
+ )
40
+ ```
41
+
42
+ As of v4.1.0, the constructor refuses non-loopback binds without an API key. Hosts `127.0.0.1`, `::1`, and `localhost` accept `api_key: nil`; any other host requires either an explicit `api_key:` keyword or the `MCP_API_KEY` environment variable, or `ArgumentError` is raised at construction time. Empty-string `api_key:` is treated as unset.
43
+
44
+ **Inject a shared rate limiter.** For multi-process or multi-host deployments, pass a Redis-backed limiter:
45
+
46
+ ```ruby
47
+ shared_limiter = MyRedisRateLimiter.new(limit: 100, window: 60)
48
+ Parse::Agent::MCPServer.run(
49
+ port: 3001,
50
+ permissions: :readonly,
51
+ api_key: ENV["MCP_API_KEY"],
52
+ rate_limiter: shared_limiter,
53
+ )
54
+ ```
55
+
56
+ The limiter must respond to `#check!` and raise `Parse::Agent::RateLimitExceeded` on exhaustion. The constructor raises `ArgumentError` if either contract is violated.
57
+
58
+ `MCPServer.run` is blocking. Trap signals are installed automatically (`INT`, `TERM` -> graceful shutdown).
59
+
60
+ **Authentication.** When `api_key` is set, every request to `/mcp` must include the `X-MCP-API-Key` header. The comparison uses `ActiveSupport::SecurityUtils.secure_compare` to prevent timing attacks.
61
+
62
+ **Additional endpoints exposed by the standalone server:**
63
+
64
+ | Path | Auth required | Purpose |
65
+ |------|--------------|---------|
66
+ | `/mcp` | Yes (if api_key set) | MCP JSON-RPC endpoint |
67
+ | `/health` | No | Monitoring / liveness check: `{"status":"ok","mcp_enabled":true}` |
68
+ | `/tools` | Yes (if api_key set) | Human-readable tool list |
69
+
70
+ Wire your load balancer's health check to `/health`.
71
+
72
+ ---
73
+
74
+ ### Embedded in a Rack app (MCPRackApp)
75
+
76
+ **`enable_mcp!` is not required for embedded mode.** The `ENV["PARSE_MCP_ENABLED"]` and `Parse.mcp_server_enabled` prerequisites gate only the standalone `MCPServer.run` entry point. `MCPRackApp` and `Parse::Agent.rack_app` work without either.
77
+
78
+ `Parse::Agent::MCPRackApp` is a Rack endpoint that accepts an **agent factory** — a callable (block or `agent_factory:` keyword, not both) invoked on every request. The factory is responsible for authenticating the request and returning a configured `Parse::Agent`. It must raise `Parse::Agent::Unauthorized` to signal any authentication failure.
79
+
80
+ The preferred construction is via the `Parse::Agent.rack_app` convenience method, which loads the adapter on demand:
81
+
82
+ ```ruby
83
+ Parse::Agent.rack_app { |env| ... }
84
+ ```
85
+
86
+ The verbose form `Parse::Agent::MCPRackApp.new { |env| ... }` is equivalent and is the underlying implementation.
87
+
88
+ **Transport-level checks** run before the factory is called:
89
+
90
+ - Only `POST` requests are accepted (405 otherwise).
91
+ - `Content-Type` must be `application/json` (415 otherwise).
92
+ - Body is capped at 1 MB by default (413 otherwise).
93
+ - JSON must be valid and not exceed nesting depth 20 (400 otherwise).
94
+
95
+ After those checks pass, the factory is called. If it raises `Parse::Agent::Unauthorized`, the adapter returns a sanitized 401 with a fixed JSON-RPC error body — no exception detail leaks to the caller. Any other exception from the factory returns a 500 with the same `"Internal error"` wire message.
96
+
97
+ #### 1. Rails
98
+
99
+ ```ruby
100
+ # config/routes.rb
101
+ Rails.application.routes.draw do
102
+ mcp_app = Parse::Agent.rack_app(logger: Rails.logger) do |env|
103
+ header = env["HTTP_AUTHORIZATION"].to_s
104
+ token = header.delete_prefix("Bearer ").strip
105
+
106
+ raise Parse::Agent::Unauthorized.new("missing token", reason: :missing) if token.empty?
107
+
108
+ # Replace with your real verification (Devise, JWT, Auth0, etc.)
109
+ payload = MyJWTVerifier.verify!(token) # raises on bad/expired token
110
+
111
+ # Map application roles to Parse::Agent permission levels
112
+ perms = payload["admin"] ? :write : :readonly
113
+
114
+ # Use a shared Redis-backed limiter (see Rate Limiting section)
115
+ Parse::Agent.new(
116
+ permissions: perms,
117
+ session_token: payload["parse_session_token"],
118
+ rate_limiter: $shared_redis_limiter
119
+ )
120
+ rescue MyJWTVerifier::ExpiredToken
121
+ raise Parse::Agent::Unauthorized.new("token expired", reason: :expired)
122
+ rescue MyJWTVerifier::InvalidToken
123
+ raise Parse::Agent::Unauthorized.new("token invalid", reason: :invalid)
124
+ end
125
+
126
+ mount mcp_app, at: "/mcp"
127
+ end
128
+ ```
129
+
130
+ #### 2. Sinatra
131
+
132
+ Define the Rack app as a constant inside your Sinatra class, then mount it from `config.ru` using `Rack::Builder`'s `map`. Sinatra's class body does not expose the `map` DSL — it belongs to the outer builder context.
133
+
134
+ ```ruby
135
+ # app.rb
136
+ require "sinatra/base"
137
+ require "parse-stack"
138
+
139
+ class MyApp < Sinatra::Base
140
+ MCP_APP = Parse::Agent.rack_app do |env|
141
+ token = env["HTTP_AUTHORIZATION"].to_s.delete_prefix("Bearer ").strip
142
+ raise Parse::Agent::Unauthorized.new("missing token", reason: :missing) if token.empty?
143
+
144
+ begin
145
+ payload = MyJWTVerifier.verify!(token)
146
+ rescue MyJWTVerifier::InvalidToken => e
147
+ raise Parse::Agent::Unauthorized.new(e.message, reason: :invalid)
148
+ end
149
+
150
+ Parse::Agent.new(
151
+ permissions: payload["admin"] ? :write : :readonly,
152
+ session_token: payload["parse_session_token"],
153
+ rate_limiter: $shared_redis_limiter
154
+ )
155
+ end
156
+
157
+ get("/") { "ok" }
158
+ end
159
+ ```
160
+
161
+ ```ruby
162
+ # config.ru
163
+ require_relative "app"
164
+
165
+ map("/mcp") { run MyApp::MCP_APP }
166
+ run MyApp
167
+ ```
168
+
169
+ #### 3. Plain Rack
170
+
171
+ ```ruby
172
+ # config.ru
173
+ require "parse-stack"
174
+
175
+ Parse.connect("myapp",
176
+ server_url: ENV["PARSE_SERVER_URL"],
177
+ app_id: ENV["PARSE_APP_ID"],
178
+ master_key: ENV["PARSE_MASTER_KEY"]
179
+ )
180
+
181
+ mcp_app = Parse::Agent.rack_app do |env|
182
+ api_key = env["HTTP_X_MCP_API_KEY"].to_s
183
+ unless ActiveSupport::SecurityUtils.secure_compare(ENV["MCP_API_KEY"], api_key)
184
+ raise Parse::Agent::Unauthorized.new("bad key", reason: :bad_api_key)
185
+ end
186
+
187
+ Parse::Agent.new(permissions: :readonly, rate_limiter: $shared_redis_limiter)
188
+ end
189
+
190
+ map("/mcp") { run mcp_app }
191
+ map("/") { run ->(env) { [200, {"Content-Type" => "text/plain"}, ["ok"]] } }
192
+ ```
193
+
194
+ #### MCP progress notifications via SSE (opt-in)
195
+
196
+ **WEBrick cannot stream.** The standalone `MCPServer` is WEBrick-based and buffers the full response before sending. Setting `streaming: true` on an `MCPRackApp` mounted under WEBrick silently degrades to a single buffered response with concatenated SSE events. SSE streaming requires a Rack server that supports streaming response bodies — **Puma, Falcon, or Unicorn**. Verify your deployment uses one of these before relying on `streaming: true`.
197
+
198
+ `MCPRackApp` supports Server-Sent Events for clients that want `notifications/progress` heartbeats:
199
+
200
+ ```ruby
201
+ mcp_app = Parse::Agent.rack_app(streaming: true) do |env|
202
+ # ... auth factory ...
203
+ end
204
+ ```
205
+
206
+ ```ruby
207
+ mcp_app = Parse::Agent.rack_app(
208
+ streaming: true,
209
+ heartbeat_interval: 5, # seconds between progress events (default 2)
210
+ ) do |env|
211
+ # ...
212
+ end
213
+ ```
214
+
215
+ Tune `heartbeat_interval` to your client's tolerance; default 2 seconds is appropriate for most LLM clients.
216
+
217
+ When `streaming: true` is set and the client sends `Accept: text/event-stream`, the server holds the connection open and emits `notifications/progress` heartbeats every 2 seconds. Normal (non-streaming) clients are unaffected because the default is `streaming: false`.
218
+
219
+ **Client requirements:**
220
+ - Send `Accept: text/event-stream` in the request headers.
221
+ - Be prepared for an indefinitely open response until the tool call completes.
222
+
223
+ **Nginx configuration.** Add `X-Accel-Buffering: no` to prevent Nginx from buffering the SSE stream:
224
+
225
+ ```nginx
226
+ location /mcp {
227
+ proxy_pass http://backend;
228
+ proxy_set_header X-Accel-Buffering no;
229
+ }
230
+ ```
231
+
232
+ #### Tool-internal progress reporting (v4.2)
233
+
234
+ Tools can emit their own `notifications/progress` events through the same SSE stream. Built-in tools and custom tools registered via `Parse::Agent::Tools.register` both receive the agent as their first argument; calling `agent.report_progress(progress:, total: nil, message: nil)` from inside the tool sends a `notifications/progress` event when the request was served by the streaming transport. On the JSON path (or anywhere without an active progress callback) the call is a silent no-op.
235
+
236
+ ```ruby
237
+ Parse::Agent::Tools.register(
238
+ name: :process_records,
239
+ description: "Process records with progress reporting",
240
+ parameters: { "type" => "object", "properties" => { "limit" => { "type" => "integer" } } },
241
+ permission: :readonly,
242
+ handler: ->(agent, limit: 100, **) {
243
+ records = fetch_batch(limit)
244
+ records.each_with_index do |rec, i|
245
+ transform(rec)
246
+ agent.report_progress(progress: i + 1, total: records.size, message: "Processing")
247
+ end
248
+ { success: true, data: { processed: records.size } }
249
+ },
250
+ )
251
+ ```
252
+
253
+ Wire shape of the emitted event:
254
+
255
+ ```json
256
+ {
257
+ "jsonrpc": "2.0",
258
+ "method": "notifications/progress",
259
+ "params": {
260
+ "progressToken": "<client-supplied or auto-generated>",
261
+ "progress": 42,
262
+ "total": 100,
263
+ "message": "Processing"
264
+ }
265
+ }
266
+ ```
267
+
268
+ The `progressToken` follows the request: clients that supplied `params._meta.progressToken` see that token echoed in every event; otherwise the server auto-generates one. The `message` field is optional and omitted from the wire when nil. `message` requires MCP protocol 2025-03-26 or later, which `Parse::Agent::MCPDispatcher` advertises by default in v4.2 (`PROTOCOL_VERSION = "2025-06-18"`).
269
+
270
+ **Heartbeat suppression.** As soon as a tool reports its own progress, the time-based heartbeat loop stops emitting events for the remainder of the request. The shared `progressToken` then carries a single coherent stream of work-unit progress. Tools that never call `report_progress` keep getting elapsed-seconds heartbeats as before.
271
+
272
+ #### Cancellation (v4.2)
273
+
274
+ Cooperative cancellation lets clients abort an in-flight long-running tool call. Cancellation is triggered from two paths:
275
+
276
+ 1. **`notifications/cancelled` JSON-RPC notification.** The client sends a second POST while the original request is still streaming. The body is shaped:
277
+ ```json
278
+ {
279
+ "jsonrpc": "2.0",
280
+ "method": "notifications/cancelled",
281
+ "params": { "requestId": 42, "reason": "user pressed stop" }
282
+ }
283
+ ```
284
+ The server responds with HTTP `202 Accepted` and an empty body (this is a notification — no JSON-RPC response is required or returned).
285
+
286
+ 2. **SSE client disconnect.** When the underlying TCP connection closes (browser tab closed, network drop), Rack calls `SSEBody#close`, which trips the same cancellation token.
287
+
288
+ **Identity binding (required for `notifications/cancelled`).** The cancelling request **must** carry the same `X-MCP-Session-Id` header as the original request. The header is sanitized into `agent.correlation_id` and used as half of the registry key (the JSON-RPC `requestId` is the other half). Cancellation without a matching `X-MCP-Session-Id` is a silent no-op — this prevents an attacker who guesses sequential JSON-RPC ids from cancelling other clients' in-flight requests. Failures (no session id, no matching entry, mismatched session id) all return `202` so the response shape is not a probe oracle.
289
+
290
+ **Cooperative checkpoints.** Cancellation is observed at safe points inside tool execution, not by forcibly killing the dispatcher thread. The two checkpoints built into `Parse::Agent#execute` are:
291
+
292
+ - **Before the tool runs** — catches "cancelled while queued behind the rate limiter / permission gate."
293
+ - **After the tool returns** — catches "cancelled while the tool's blocking I/O was running."
294
+
295
+ Tools with internal loops (e.g. `export_data` between chunks) can add their own checks via `agent.cancelled?`. A custom tool that wants to cooperate looks like:
296
+
297
+ ```ruby
298
+ handler: ->(agent, **kwargs) {
299
+ return { success: false, error: "Cancelled by client", cancelled: true } if agent.cancelled?
300
+ data = fetch_records(kwargs)
301
+ return { success: false, error: "Cancelled by client", cancelled: true } if agent.cancelled?
302
+ { success: true, data: data }
303
+ }
304
+ ```
305
+
306
+ **Honest limits.** Cancellation reduces wasted work; it does not stop a tool mid-flight inside a blocking I/O call (MongoDB query, Parse REST roundtrip). The Ruby-level `Timeout.timeout` already wrapping each tool remains the hard upper bound — see the **Tool timeout table** in the Performance section. Real MongoDB cursor cancellation via `killCursors` is a separate deferred item and would require deeper integration with the Mongo Ruby driver.
307
+
308
+ **Wire shape for cancelled tools.** The dispatcher detects `cancelled: true` (or `agent.cancelled?` returning true after the tool returns) and translates the result into:
309
+
310
+ ```json
311
+ {
312
+ "content": [ { "type": "text", "text": "Cancelled by client (notifications_cancelled)" } ],
313
+ "isError": true,
314
+ "cancelled": true
315
+ }
316
+ ```
317
+
318
+ The stream still emits the `response` SSE event before closing so clients do not have to distinguish "cancelled," "crashed," and "network died."
319
+
320
+ **Scope and limitations.**
321
+ - The cancellation registry is per `MCPRackApp` instance. Cancellation does not span multiple mount points within a process, nor multiple processes in a clustered deployment.
322
+ - Clients that do not set `X-MCP-Session-Id` lose cancellation but keep every other MCP feature.
323
+ - The standalone WEBrick-backed `MCPServer` does not support streaming and therefore does not support cancellation; calls return a single buffered response with no opportunity to interrupt.
324
+
325
+ ---
326
+
327
+ ### Direct in-process dispatcher (MCPDispatcher)
328
+
329
+ `Parse::Agent::MCPDispatcher.call` is a pure function: it takes an already-parsed body Hash and a `Parse::Agent` instance and returns `{ status: Integer, body: Hash }`. It performs no I/O, no HTTP parsing, and no authentication. The `body` value is the JSON-RPC response envelope (a Ruby Hash with string keys) — the caller is responsible for serializing it to JSON and writing it to the wire.
330
+
331
+ ```ruby
332
+ require "parse/agent/mcp_dispatcher"
333
+
334
+ body = JSON.parse(raw_request_body) # caller parses
335
+ agent = Parse::Agent.new(permissions: :readonly)
336
+
337
+ result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
338
+
339
+ # result[:status] => 200 (or 401 for Unauthorized)
340
+ # result[:body] => { "jsonrpc" => "2.0", "id" => ..., "result" => {...} }
341
+
342
+ response_json = JSON.generate(result[:body])
343
+ ```
344
+
345
+ The dispatcher accepts an optional `logger:` keyword for routing internal-error diagnostics:
346
+
347
+ ```ruby
348
+ result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent, logger: my_logger)
349
+ ```
350
+
351
+ `MCPRackApp` forwards its `logger:` argument to the dispatcher automatically, so transport-level and handler-level diagnostics land in the same operator log.
352
+
353
+ **`MCPDispatcher` never raises.** All `StandardError` subclasses are caught and translated into JSON-RPC `-32603` error envelopes. The wire-level message in that envelope is the literal string `"Internal error"` — no class name, no message text, no backtrace. The class name and message are emitted to the logger (or `$stderr` via `Kernel#warn` as fallback) and are operator-only. `Parse::Agent::Unauthorized` produces a `-32001` error with HTTP status 401 in the returned hash.
354
+
355
+ Common uses for the direct dispatcher:
356
+
357
+ - Unit testing — construct agents with fixture data and call the dispatcher directly without starting a server. See the Testing section.
358
+ - Custom transports — WebSockets, stdio, or any other channel that delivers a parsed body.
359
+ - Composing inside a larger MCP server that handles its own routing and auth.
360
+
361
+ ---
362
+
363
+ ## Custom Authentication
364
+
365
+ The agent factory pattern gives you full control over authentication. Every request passes through the factory before any Parse operation is attempted.
366
+
367
+ **Complete example:**
368
+
369
+ ```ruby
370
+ agent_factory = lambda do |env|
371
+ # 1. Extract the bearer token from the Authorization header.
372
+ raw = env["HTTP_AUTHORIZATION"].to_s
373
+ token = raw.delete_prefix("Bearer ").strip
374
+
375
+ if token.empty?
376
+ raise Parse::Agent::Unauthorized.new("Authorization header missing", reason: :missing)
377
+ end
378
+
379
+ # 2. Verify the token (JWT, Auth0, Devise session, or static comparison).
380
+ # For static API keys, always use secure_compare:
381
+ #
382
+ # unless ActiveSupport::SecurityUtils.secure_compare(ENV["STATIC_KEY"], token)
383
+ # raise Parse::Agent::Unauthorized.new("bad key", reason: :bad_api_key)
384
+ # end
385
+ #
386
+ # For JWT:
387
+ payload = MyJWTVerifier.verify!(token) # raises on invalid/expired
388
+
389
+ # 3. Map the verified identity to permissions.
390
+ perms = case payload["role"]
391
+ when "admin" then :write # see WARNING below
392
+ else :readonly
393
+ end
394
+
395
+ # 4. Return a configured agent. The factory chooses ONE identity input
396
+ # (mutually exclusive — passing two raises ArgumentError):
397
+ #
398
+ # session_token: <string> — bearer-token identity; SDK validates via
399
+ # /users/me at construction (best-effort)
400
+ # acl_user: <Parse::User|Pointer> — pre-resolved identity, skips
401
+ # the token round-trip; v4.4.0+
402
+ # acl_role: <name> — service-account scoping ("see as if a
403
+ # user holding this role were asking"); v4.4.0+
404
+ #
405
+ # Omitting all three runs in master-key posture (banner-warned at
406
+ # construction; the right choice for ops/admin agents).
407
+ Parse::Agent.new(
408
+ permissions: perms,
409
+ session_token: payload["parse_session_token"], # optional; scopes queries to user ACLs
410
+ rate_limiter: $shared_redis_limiter # required for per-request deployments
411
+ )
412
+
413
+ rescue MyJWTVerifier::ExpiredToken
414
+ raise Parse::Agent::Unauthorized.new("token expired", reason: :expired)
415
+ rescue MyJWTVerifier::InvalidToken
416
+ raise Parse::Agent::Unauthorized.new("token invalid", reason: :invalid)
417
+ end
418
+ ```
419
+
420
+ **`Parse::Agent::Unauthorized` contract:**
421
+
422
+ ```ruby
423
+ raise Parse::Agent::Unauthorized.new("human-readable message", reason: :symbol)
424
+ ```
425
+
426
+ The `reason:` keyword is available as `e.reason` on the exception object. Any middleware that rescues `Unauthorized` upstream of `MCPRackApp` can read it. `MCPRackApp` itself logs only the exception class name (not `e.reason`) when a `logger:` is provided. The `reason` is never included in any HTTP response body.
427
+
428
+ The response the client always receives for an authentication failure is the fixed sanitized envelope:
429
+
430
+ ```json
431
+ {"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}
432
+ ```
433
+
434
+ Only `Parse::Agent::Unauthorized` should escape the factory. Any other exception becomes a 500 response with `"Internal error"` as the wire message. Rescue and re-raise all anticipated failures as `Unauthorized` or allow unexpected errors to propagate as-is.
435
+
436
+ **WARNING: `:admin` permissions over HTTP.** The `:admin` permission level enables destructive tools (`delete_object`, `create_class`, `delete_class`). Do not grant `:admin` in an HTTP-exposed agent factory unless you have explicitly considered what happens when that endpoint is called with a stolen credential, a misconfigured reverse proxy, or a logic error in your authorization check. Prefer `:write` for mutation access and reserve `:admin` for internal tooling behind a network boundary.
437
+
438
+ ---
439
+
440
+ ## Rate Limiting in Per-Request Deployments
441
+
442
+ ### The problem
443
+
444
+ The bundled `Parse::Agent::RateLimiter` is an in-process sliding-window counter stored on the `Parse::Agent` instance. It works correctly in deployments that reuse a single agent across requests:
445
+
446
+ ```
447
+ Standalone MCPServer
448
+ creates ONE Parse::Agent at startup
449
+ rate_limiter state persists across all requests (correct)
450
+ ```
451
+
452
+ When `MCPRackApp` calls an agent factory on every request, a new `Parse::Agent` is created each time. Because `RateLimiter` state lives on the instance, it resets on every call:
453
+
454
+ ```
455
+ MCPRackApp (per-request factory)
456
+ request 1 -> new Parse::Agent -> new RateLimiter (0 requests recorded)
457
+ request 2 -> new Parse::Agent -> new RateLimiter (0 requests recorded)
458
+ effectively no rate limiting
459
+ ```
460
+
461
+ The same problem exists in miniature whenever a tool handler constructs a sub-agent inside its block — a fresh `Parse::Agent.new` produces a fresh limiter, so an attacker who can induce delegation amplifies the per-process budget linearly with delegation depth × branching. The v4.2 `parent:` kwarg closes that case automatically (see [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42)); the shared external limiter pattern below covers the cross-request case at the MCPRackApp boundary.
462
+
463
+ ### The solution
464
+
465
+ Inject a shared, externally-stateful limiter:
466
+
467
+ ```ruby
468
+ $shared_redis_limiter = MyRedisRateLimiter.new(
469
+ key: "mcp_rate_limit",
470
+ limit: 60,
471
+ window: 60
472
+ )
473
+
474
+ mcp_app = Parse::Agent.rack_app do |env|
475
+ # ... auth ...
476
+ Parse::Agent.new(
477
+ permissions: :readonly,
478
+ rate_limiter: $shared_redis_limiter
479
+ )
480
+ end
481
+ ```
482
+
483
+ ### Injected limiter protocol
484
+
485
+ An injected limiter must satisfy this interface:
486
+
487
+ ```ruby
488
+ # The limiter must respond to #check! and raise
489
+ # Parse::Agent::RateLimitExceeded when the budget is exhausted.
490
+ # Parse::Agent::RateLimitExceeded is a top-level alias for
491
+ # Parse::Agent::RateLimiter::RateLimitExceeded.
492
+
493
+ class MyRedisRateLimiter
494
+ def initialize(key:, limit:, window:)
495
+ @key = key
496
+ @limit = limit
497
+ @window = window
498
+ end
499
+
500
+ def check!
501
+ remaining = redis_sliding_window_increment(@key, @limit, @window)
502
+ if remaining < 0
503
+ raise Parse::Agent::RateLimitExceeded.new(
504
+ retry_after: @window,
505
+ limit: @limit,
506
+ window: @window
507
+ )
508
+ end
509
+ true
510
+ end
511
+
512
+ private
513
+
514
+ def redis_sliding_window_increment(key, limit, window)
515
+ # Your Redis INCR / EXPIRE or sorted-set sliding window implementation.
516
+ # Return the number of remaining slots (negative means over limit).
517
+ end
518
+ end
519
+ ```
520
+
521
+ `Parse::Agent#initialize` validates the injected limiter at construction time:
522
+
523
+ ```ruby
524
+ # Raises ArgumentError immediately if the limiter does not respond to #check!
525
+ Parse::Agent.new(rate_limiter: bad_object)
526
+ # => ArgumentError: rate_limiter must respond to #check!
527
+ ```
528
+
529
+ **Fail-closed behavior.** If the injected limiter raises an error that is not `Parse::Agent::RateLimitExceeded` (for example, a `Redis::ConnectionError` when the backing store is unavailable), `Agent#execute` translates it into a synthetic `RateLimitExceeded` with a randomized `retry_after` between 1.0 and 5.0 seconds. This prevents the Redis-down condition from being distinguishable from a real rate limit signal. The original error is emitted to `$stderr` via `Kernel#warn` with the format `"[Parse::Agent] rate limiter failure: <Class>: <message>"` — it is operator-only and never reaches the client.
530
+
531
+ The `Parse::Agent::RateLimitExceeded` constant is a stable top-level alias — external limiters should raise it directly rather than the nested `Parse::Agent::RateLimiter::RateLimitExceeded`.
532
+
533
+ Per-user rate limiting follows the same pattern: key the Redis counter on the verified user identity extracted during authentication.
534
+
535
+ ---
536
+
537
+ ## Custom Tools
538
+
539
+ Prior to v4.1.0, adding application-specific tools required wrapping the dispatcher or monkey-patching the `Tools` module. v4.1.0 closes this gap with `Parse::Agent::Tools.register`.
540
+
541
+ ### Registering custom tools
542
+
543
+ Register before the `MCPRackApp` or `MCPServer` starts handling requests. Registration is thread-safe (guarded by a mutex internally), but the registry is global to the process. Registering the same name again replaces the previous registration.
544
+
545
+ ```ruby
546
+ Parse::Agent::Tools.register(
547
+ name: :breakdown_captures,
548
+ description: "Count captures grouped by user/project/team/org with optional date window",
549
+ parameters: {
550
+ type: "object",
551
+ properties: {
552
+ group_by: {
553
+ type: "string",
554
+ enum: ["user", "project", "team", "org"],
555
+ description: "Dimension to group by"
556
+ },
557
+ since: {
558
+ type: "string",
559
+ description: "ISO8601 lower bound (inclusive)"
560
+ }
561
+ },
562
+ required: ["group_by"]
563
+ },
564
+ permission: :readonly,
565
+ category: "analytics", # optional; defaults to "custom"
566
+ timeout: 30,
567
+ handler: ->(agent, **args) { MyApp::BreakdownService.call(**args) }
568
+ )
569
+ ```
570
+
571
+ The optional `category:` kwarg (v4.2.1) assigns the tool to a discovery category surfaced via `_meta.category` on every MCP tool descriptor and consumable by the `list_tools` discovery built-in. See [Tool Categories & `list_tools`](#tool-categories--list_tools) below for details. Defaults to `"custom"`; refuses empty strings.
572
+
573
+ **How registered tools integrate with the runtime:**
574
+ - They appear in `tools/list` responses alongside built-in tools, filtered by the current agent's permission level (a tool registered with `permission: :write` will not appear for a `:readonly` agent).
575
+ - Tool calls route through `Agent#execute`, which means they go through permission checking, rate limiting, and `ActiveSupport::Notifications` instrumentation exactly like built-in tools.
576
+ - The handler lambda receives the agent instance as its first argument and keyword arguments matching the parameters schema.
577
+ - The registry is global to the process. To make a registered tool visible only to some sessions (e.g., a dashboard-only `emit_artifact` tool), use the v4.2 per-agent `tools:` filter in the agent factory rather than registering the tool conditionally. See [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42).
578
+
579
+ **Handler return contract.** Your handler must return one of:
580
+ - `{success: true, data: <Hash or Array>}` on success — the dispatcher wraps `data` in the MCP `content` envelope.
581
+ - `{success: false, error: <String>, error_code: <Symbol>}` on failure — surfaces as `isError: true` in the tool result with your message.
582
+
583
+ Any other shape is treated as an internal error. Arguments arrive as keyword arguments with **symbol keys** (`args[:since]`, not `args["since"]`), matching Ruby's `**kwargs` convention, regardless of the JSON Schema using string keys.
584
+
585
+ **Registered handlers are trusted code.** Specifically, handlers:
586
+ - Receive the bare `Parse::Agent` and can read its `session_token`, `acl_scope`, `acl_scope_kwargs`, `acl_permission_strings`, `acl_read_match_stage`, and `acl_write_match_stage` to apply the agent's identity to their own queries.
587
+ - **Bypass the COLLSCAN preflight check** when they query Parse directly (via `.results_direct`, `Parse::MongoDB`, or `Parse::Object#query`). Implement your own indexing discipline.
588
+ - **Bypass the `agent_fields` allowlist** when they return raw `Parse::Object` instances. Project fields manually in the handler.
589
+ - Bypass `max_time_ms` pushdown — Parse Server's REST surface does not accept `maxTimeMS`, so built-in tools enforce timeouts only via Ruby's `Timeout.timeout` (with the known limitation that it cannot safely interrupt native I/O mid-syscall). If you need a database-level time budget in your handler, query through `Parse::MongoDB.find` / `Parse::MongoDB.aggregate` directly with the `max_time_ms:` keyword; cancellation surfaces as `Parse::MongoDB::ExecutionTimeout`.
590
+ - Are responsible for forwarding the agent's ACL scope. Handlers that hit REST under an `acl_user:` / `acl_role:` agent (via `agent.client.find_objects(..., **agent.request_opts)`) will raise `Parse::ACLScope::ACLRequired` — fail-closed, since REST can't honor non-session scope. The remedy is to call `Parse::MongoDB.aggregate(class, pipeline, **agent.acl_scope_kwargs)` or `Parse::Query.new(class).results_direct(**agent.acl_scope_kwargs)` from inside the handler; both apply the SDK's `_rperm` `$match` + `Parse::CLPScope` enforcement automatically.
591
+
592
+ **Optional v4.2 helpers available to registered handlers** — see the Streaming, Cancellation, and Structured Tool Output sections under [Embedded in a Rack app](#embedded-in-a-rack-app-mcprackapp) for the full wire shape and constraints:
593
+ - `agent.report_progress(progress:, total: nil, message: nil)` — emit MCP `notifications/progress` events. Silent no-op on the JSON path.
594
+ - `agent.cancelled?` — poll the cooperative cancellation flag. Return `{success: false, error: "Cancelled by client", cancelled: true}` from the handler to short-circuit cleanly; the dispatcher's post-run checkpoint also catches uncooperative handlers and translates the response automatically.
595
+ - `Tools.register(..., output_schema:)` — declare a JSON Schema Hash for the tool's structured output. The schema surfaces in `tools/list` as `outputSchema`, and `tools/call` responses for that tool include a `structuredContent` field mirroring the handler's data Hash alongside the existing `content` text array.
596
+
597
+ Register at boot from code you control. Never accept registrations from configuration files at runtime.
598
+
599
+ Registering a name that matches a built-in tool replaces the built-in in `tools/list` and `tools/call` responses. To restore built-in-only state (useful in test teardown, parallel to `Parse::Agent::Prompts.reset_registry!`), call `Parse::Agent::Tools.reset_registry!`.
600
+
601
+ **v4.1.0 and later:** use `Parse::Agent::Tools.register` as shown above.
602
+
603
+ **Pre-4.1.0 workaround:** wrap the dispatcher:
604
+
605
+ ```ruby
606
+ # Pre-4.1.0 only — dispatcher-wrap pattern
607
+ original_call = Parse::Agent::MCPDispatcher.method(:call)
608
+
609
+ module CustomDispatch
610
+ def self.call(body:, agent:, logger: nil)
611
+ if body.dig("method") == "tools/call" &&
612
+ body.dig("params", "name") == "breakdown_captures"
613
+ # handle it here, return { status: 200, body: jsonrpc_result }
614
+ else
615
+ original_call.call(body: body, agent: agent, logger: logger)
616
+ end
617
+ end
618
+ end
619
+ ```
620
+
621
+ ---
622
+
623
+ ## Tool Categories & `list_tools`
624
+
625
+ Built-in and registered tools carry a `category:` field that lets clients filter the tool surface by purpose without parsing prose descriptions. Categories also feed the `list_tools` discovery built-in (added in v4.2.1), which returns a lightweight catalog of names + categories + one-line descriptions — significantly cheaper than `tools/list`'s full input-schema dump.
626
+
627
+ ### Built-in categories
628
+
629
+ | Category | Built-in tools | Purpose |
630
+ |------------|---------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
631
+ | `schema` | `get_all_schemas`, `get_schema` | Class introspection |
632
+ | `query` | `query_class`, `count_objects`, `get_object`, `get_objects`, `get_sample_objects`, `explain_query` | Read-only data access |
633
+ | `aggregate`| `aggregate`, `group_by`, `group_by_date`, `distinct` | MongoDB aggregation pipelines and high-level group/distinct helpers |
634
+ | `mutation` | `call_method` | Domain-action methods declared via `agent_method` |
635
+ | `export` | `export_data` | Bulk data export in CSV/Markdown/text |
636
+ | `discovery`| `list_tools` | The catalog tool itself |
637
+
638
+ `Parse::Agent::Tools::BUILTIN_CATEGORIES` is a frozen hash mapping each category to its human-readable one-liner. Application-registered tools default to `"custom"` unless they pass `category:` to `Tools.register`.
639
+
640
+ ### `_meta.category` on every MCP descriptor
641
+
642
+ Every tool descriptor emitted by `tools/list` carries a `_meta` block:
643
+
644
+ ```jsonc
645
+ {
646
+ "name": "query_class",
647
+ "description": "Fetch records from a Parse class ...",
648
+ "inputSchema": {...},
649
+ "_meta": { "category": "query" }
650
+ }
651
+ ```
652
+
653
+ The MCP 2025-06-18 spec permits `_meta` on tool descriptors for server-specific extensions. Older clients ignore unknown fields.
654
+
655
+ ### Server-side category filter on `tools/list`
656
+
657
+ `tools/list` accepts an optional non-standard `category` param. Vanilla MCP clients omit it and see the full allowed-tools list (backward-compatible). Clients that know about the extension can pass a category to filter the response server-side:
658
+
659
+ ```jsonc
660
+ // Request — load only the aggregation surface
661
+ { "jsonrpc": "2.0", "id": 1, "method": "tools/list",
662
+ "params": { "category": "aggregate" } }
663
+
664
+ // Response — only built-ins (and registrations) in that category
665
+ { "tools": [
666
+ { "name": "aggregate", "description": "...", "inputSchema": {...},
667
+ "_meta": { "category": "aggregate" } }
668
+ ] }
669
+ ```
670
+
671
+ Category comparison is case-insensitive. Unknown categories return an empty `tools` array (not an error). The filter never widens permission: a `:readonly` agent requesting `category: "mutation"` still excludes any `:write` registered method tool.
672
+
673
+ ### The `list_tools` built-in
674
+
675
+ For LLMs that want to decide which tool to load BEFORE paying the cost of full input schemas, call `list_tools` instead of `tools/list`:
676
+
677
+ ```ruby
678
+ agent.execute(:list_tools)
679
+ # => {
680
+ # success: true,
681
+ # data: {
682
+ # tools: [
683
+ # { name: "get_all_schemas", category: "schema", description: "List every Parse class ..." },
684
+ # { name: "get_schema", category: "schema", description: "Return the fields, types ..." },
685
+ # { name: "query_class", category: "query", description: "Fetch records from a Parse class ..." },
686
+ # # ...
687
+ # ],
688
+ # categories: {
689
+ # "schema" => "Class introspection ...",
690
+ # "query" => "Read-only data access ...",
691
+ # "aggregate" => "MongoDB aggregation pipelines ...",
692
+ # "mutation" => "Domain-action methods declared via agent_method.",
693
+ # "export" => "Bulk data export in CSV, Markdown, or fixed-width text.",
694
+ # "custom" => "Application-registered tools not assigned to a built-in category.",
695
+ # "discovery" => "..."
696
+ # }
697
+ # }
698
+ # }
699
+ ```
700
+
701
+ Pass `category:` to narrow further:
702
+
703
+ ```ruby
704
+ agent.execute(:list_tools, category: "schema")
705
+ # => { success: true, data: { tools: [
706
+ # { name: "get_all_schemas", ... },
707
+ # { name: "get_schema", ... }
708
+ # ], categories: {...} } }
709
+ ```
710
+
711
+ `list_tools` honors the agent's `allowed_tools` so it never reveals tools the caller's permission tier or `tools:` filter excludes. Permission tier: `:readonly`.
712
+
713
+ ### Resolving a tool's category programmatically
714
+
715
+ ```ruby
716
+ Parse::Agent::Tools.category_for(:aggregate) # => "aggregate"
717
+ Parse::Agent::Tools.category_for(:unknown_xyz) # => nil
718
+ ```
719
+
720
+ ---
721
+
722
+ ## Per-Agent Tool Filtering & Sub-Agent Delegation (v4.2)
723
+
724
+ The agent constructor accepts four kwargs that let a single MCP mount serve multiple **agent flavors** — different tool sets per session — and let tool handlers construct **sub-agents** that inherit shared state without resetting rate-limit budgets, severing audit correlation, or silently elevating auth scope.
725
+
726
+ The four kwargs compose; each can be used independently. None of them changes the existing permission-tier or env-gate behavior: the filter narrows on top of the tier-permitted set, never elevates.
727
+
728
+ ### `tools:` — per-instance tool name filter
729
+
730
+ Overlay the permission-tier output of `allowed_tools` with an allowlist, a denylist, or both.
731
+
732
+ ```ruby
733
+ # Allowlist (Array shorthand)
734
+ agent = Parse::Agent.new(tools: [:query_class, :get_schema])
735
+
736
+ # Allowlist + denylist (Hash form)
737
+ agent = Parse::Agent.new(tools: { only: [:query_class, :get_schema, :aggregate],
738
+ except: [:aggregate] })
739
+
740
+ # Denylist only
741
+ agent = Parse::Agent.new(tools: { except: [:emit_artifact] })
742
+ ```
743
+
744
+ **Resolution order** is strict: env-gates ▷ permission tier ▷ per-instance filter. The filter cannot elevate — `tools: { only: [:delete_object] }` on a `:readonly` agent still excludes `delete_object` because `delete_object` is not in the readonly tier's permitted set in the first place.
745
+
746
+ **Names are normalized to Symbols.** The Array form (`tools: [...]`) is shorthand for `{only: array}`. The Hash form rejects keys other than `:only` / `:except` with `ArgumentError`; bad value types (e.g. `only: "string"`) raise the same.
747
+
748
+ **Unknown names are lazy-resolved.** A name not currently in the global registry emits a `warn` typo guard but is still threaded through the filter — so a tool registered after agent construction still resolves correctly. To raise at construction instead of warn, set `Parse::Agent.strict_tool_filter = true` (global) or pass `strict_tool_filter: true` to the constructor.
749
+
750
+ ### `methods:` — per-`agent_method` filter through `call_method`
751
+
752
+ Closes the `call_method` aperture: without this kwarg, `tools: { only: [:call_method] }` exposes every declared `agent_method` across every class. The `methods:` filter is applied inside `call_method` dispatch, after the per-class `agent_method_allowed?` and tier checks have already passed.
753
+
754
+ ```ruby
755
+ # Allow archive on any class, plus set_client_description only on Project
756
+ agent = Parse::Agent.new(methods: [:archive, "Project.set_client_description"])
757
+
758
+ # Deny one specific qualified method
759
+ agent = Parse::Agent.new(methods: { except: ["Account.delete_account"] })
760
+ ```
761
+
762
+ Entries are bare method names (`:archive` — matches the method on any class) or qualified names (`"Project.archive"` — matches only on that class). Both forms coexist in the same Set; matching is an OR.
763
+
764
+ The filter **narrows declared methods** — it cannot expose a method that was not declared via the `agent_method` DSL, and it cannot bypass tier checks (`agent_can_call?`) or env-gates (`PARSE_AGENT_ALLOW_WRITE_TOOLS`, `PARSE_AGENT_ALLOW_SCHEMA_OPS`). A filtered-out invocation returns `error_code: :tool_filtered`.
765
+
766
+ Unlike `tools:`, `methods:` does no typo validation. The universe of declared `agent_method`s depends on which `Parse::Object` subclasses have been loaded at construction time, so validation would produce false positives.
767
+
768
+ **Authoring `agent_method` bodies with ACL scope (v4.4.0).** `call_method` injects the active agent into the method body when the method's signature declares an `agent:` keyword (or `**kwargs`). The method body can then forward `agent.acl_scope_kwargs` to internal queries it runs, or read `agent.acl_permission_strings` / `agent.acl_read_match_stage` / `agent.acl_write_match_stage` to build its own ACL filters:
769
+
770
+ ```ruby
771
+ class Project < Parse::Object
772
+ agent_method :archive, permission: :write, supports_dry_run: true,
773
+ permitted_keys: [:reason]
774
+ def archive(reason:, agent: nil, dry_run: false, **)
775
+ return { would: "archive #{id}", reason: reason } if dry_run
776
+ # Forward the agent's scope to any internal query — pre-filtering by
777
+ # _wperm so the update only sees rows the agent's scope is allowed
778
+ # to modify, defense-in-depth alongside Parse Server's own ACL.
779
+ Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope
780
+ update!(archived_at: Time.now, archive_reason: reason)
781
+ { archived: true, objectId: id }
782
+ end
783
+ end
784
+ ```
785
+
786
+ Two things to know:
787
+ - The `agent:` kwarg is OPTIONAL. Methods without it in their signature don't receive it — backwards compatible with existing `agent_method` declarations.
788
+ - `call_method` does NOT auto-thread the scope into the method body. Honest authors will forget — make scope-aware `agent_method`s an explicit pattern in your codebase. `call_method` runs a CLP boundary check before dispatch (`:readonly` → CLP `:find`, `:write` → `:update`, `:admin` → `:delete`), so a class whose CLP doesn't grant the mapped op to the agent's scope is refused at the gate.
789
+
790
+ ### `classes:` — per-instance class allowlist (v4.3.0)
791
+
792
+ Narrows a single agent instance to a subset of Parse classes. Compose with `tools:` and `methods:` to construct purpose-narrowed agents — a support bot that can read `Ticket` / `Customer` / `Conversation` and nothing else; an ops console scoped to `Installation` and `User`; a read-only audit agent that excludes `Session` and an `AuditLog` class.
793
+
794
+ ```ruby
795
+ # Allowlist (Array shorthand) — Ticket + Customer + Conversation only
796
+ support = Parse::Agent.new(classes: [Ticket, Customer, Conversation])
797
+
798
+ # Allowlist + denylist (Hash form)
799
+ ops = Parse::Agent.new(classes: { only: [Parse::Installation, Parse::User] })
800
+
801
+ # Denylist only — read everything EXCEPT Session and AuditLog
802
+ audit = Parse::Agent.new(classes: { except: [Parse::Session, AuditLog] })
803
+ ```
804
+
805
+ **Resolution order is strict:** identifier-format check ▷ global `agent_hidden` registry ▷ `agent_hidden(except: :master_key)` master-key bypass ▷ per-instance `classes:` filter. The per-instance filter is the **ceiling, not the floor** — it cannot re-enable a globally hidden class, and it cannot widen what `permissions:` or `agent_fields` would have allowed. It strictly narrows.
806
+
807
+ **Entries may be Ruby class constants, parse_class Strings, or Symbols.** Class constants expand through `MetadataRegistry.hidden_name_variants_for` so `Parse::User` matches `"_User"`, `"User"`, and any application-side alias declared via `parse_class`. `classes: { only: [Parse::User] }` and `classes: { only: ["_User"] }` produce the same effective gate.
808
+
809
+ **Six enforcement sites, not just the top-level gate.** The same filter applies at:
810
+
811
+ - `assert_class_accessible!` (top-level tool dispatch)
812
+ - `walk_pointer_path!` (refuses `include: ["author.session"]` when `Session` is off-allowlist)
813
+ - `walk_pipeline_stage!` (refuses `$lookup.from` / `$unionWith.coll` / `$graphLookup.from` to off-allowlist classes, recursively into `$facet` and `$lookup.pipeline` sub-stages)
814
+ - `ConstraintTranslator.translate` (refuses `$inQuery` / `$notInQuery` / `$select` / `$dontSelect` against off-allowlist classes, recursively into nested `where:`)
815
+ - `walk_and_redact` (post-fetch scrub — server-side `$lookup` output that surfaces an off-allowlist `className` is replaced with `{ __redacted: true }`)
816
+ - `redact_hidden_pointer_groups!` (`group_by` collapses off-allowlist group keys)
817
+
818
+ **Strict mode.** Unknown class names in `only:` warn at construction by default — the class universe is open via lazy autoload, so a name not currently loadable may resolve later. To raise at construction instead of warn:
819
+
820
+ ```ruby
821
+ Parse::Agent.strict_class_filter = true # process-wide default
822
+ # or
823
+ Parse::Agent.new(classes: { only: [Pots] }, strict_class_filter: true) # per-instance
824
+ ```
825
+
826
+ `except:` is never validated — an operator may proactively block a class not yet loaded.
827
+
828
+ **Sub-agent inheritance: intersect, never widen.** Unlike `tools:` (where a sub-agent's filter overrides the parent's outright), `classes:` is **intersected** with the parent's effective set so a sub-agent can NEVER widen the parent's data reach. A child `only:` that has no overlap with the parent's `only:` raises `ArgumentError` at construction. A child that omits `classes:` inherits the parent's filter verbatim. `except:` sets are unioned (a sub-agent cannot un-deny a class the parent denied). The asymmetry with `tools:` is intentional — class reach is data scope, closer to `permissions:` than to the UX-scoping `tools:` filter.
829
+
830
+ **Schema-catalog filtering.** `get_all_schemas` omits classes outside the per-agent allowlist from the catalog response so the LLM doesn't waste a tool call discovering classes it would be refused on.
831
+
832
+ **Denial code.** A refusal triggered by the per-instance filter raises `Parse::Agent::AccessDenied` with `kind: :class_filter`, distinct from the global `agent_hidden` denial which uses `kind: :hidden_class`. Lets SOC tooling distinguish operator-narrowing from policy-level denials without parsing the message prose.
833
+
834
+ ### `filters:` — per-instance per-class query filter (v4.4.0)
835
+
836
+ Accepts a Hash mapping Parse class to a constraint Hash that AND-merges into every query the agent runs against that class. Fills the gap left by the three existing primitives: class-global `agent_canonical_filter` (same constraint for every agent), agent-wide `tenant_id:` (single-field), and the per-agent `classes:` allowlist (binary visibility, not constraint). Use this when an agent needs to never see specific rows that the class permits in general — soft-delete partitioning that varies by agent role, compliance flags that differ per consumer, per-agent draft/published scoping.
837
+
838
+ ```ruby
839
+ support_agent = Parse::Agent.new(
840
+ classes: { only: [Ticket, Customer, Conversation] },
841
+ filters: {
842
+ Ticket => { archived: false, spam: false },
843
+ Customer => { test_user: false },
844
+ :default => { tenant_active: true }, # AND'd into every class's query
845
+ },
846
+ )
847
+ ```
848
+
849
+ **Composition order — all AND-merged:**
850
+
851
+ 1. Caller's `where:` argument (passed to a tool call)
852
+ 2. Class-level `agent_canonical_filter` (model-level DSL, applies to every agent)
853
+ 3. Per-agent per-class `filters:[Class]` (this kwarg)
854
+ 4. Per-agent `filters:[:default]` (cross-cutting agent-level entry)
855
+ 5. Tenant scope (when bound)
856
+
857
+ When all five compose, the final wire `where:` is a top-level `$and` array containing each non-empty layer; subscribers can recover which layer contributed which clause by reading them positionally.
858
+
859
+ **`:default` semantics.** When a class has both an explicit entry AND `:default`, the two merge with class-specific keys winning on field conflicts (more specific declaration takes precedence). A `filters: { Account => { test_user: false }, :default => { tenant_active: true } }` produces `{ test_user: false, tenant_active: true }` for `Account` queries. `:default` is meant for cross-cutting agent-level invariants — soft-delete exclusion, tenant-active flag, region pinning — that apply uniformly.
860
+
861
+ **Class identifier acceptance.** Hash keys may be Ruby class constants (`Parse::User`), parse_class Strings (`"_User"`), or Symbols. Class constants expand through `MetadataRegistry.hidden_name_variants_for` so `filter_for(Parse::User)` and `filter_for("_User")` return the same Hash. `:default` is reserved for the cross-cutting entry.
862
+
863
+ **Construction-time validation.** Every constraint Hash is run through `Parse::Agent::ConstraintTranslator.valid?` at `Parse::Agent.new` time, so a typo'd operator (`{ "$gtt" => 5 }`) or unknown operator raises `ArgumentError` at boot — not at the first tool call. Catches the common operator-misspelling failure mode at the developer's editor.
864
+
865
+ **`get_object(id)` is filtered too.** When a per-agent filter is declared for a class, `get_object(class_name:, object_id:)` rewrites internally to a `find_objects` with `where: { objectId: id, ...filter }, limit: 1`. Without this, an agent with `filters: { Account => { test_user: false } }` could still pull a specific test-user row by passing the ID directly — defeating the operator's narrowing intent. When the filter excludes the row, the call returns the standard `Object not found: <Class>#<id>` envelope, identical to a genuine missing-row case so the agent can't use deliberate-fetch attempts as an oracle for filtered-out IDs.
866
+
867
+ Note that the class-level `agent_canonical_filter` is intentionally NOT applied on `get_object(id)` — its semantic is "this class is normally queried in valid state Y," not "this agent must never see X." A caller who already has the ID gets the record as-is even when it falls outside the class's "valid state." The per-agent filter is treated differently because its semantic IS authorization.
868
+
869
+ **Pipeline emission.** When the aggregate pipeline path applies the filter, the class-canonical and per-agent filters emit as SEPARATE `$match` stages so `explain_query` output and audit trails can distinguish which restriction came from which layer.
870
+
871
+ **Inspecting the resolved filter.** `agent.filter_for(class_name)` returns the AND-composed constraint Hash for a class (per-class entry AND `:default`), or `nil` when nothing applies. Useful when application code needs to reason about what the agent would have applied — debugging "why is this query returning zero rows," surfacing the effective scope in a developer console, or constructing a manual query that mirrors the agent's reach.
872
+
873
+ **Sub-agent inheritance.** Parent's filters are inherited and the child's filters merge ON TOP with the child's keys winning on field conflicts. New class keys in the child are added; new keys in the parent are inherited verbatim. Like the `classes:` allowlist, inheritance is narrow-only: a sub-agent cannot relax a parent's filter, only tighten it.
874
+
875
+ **Phase 1: static Hashes only.** The constraint values are Hash literals frozen at construction. Runtime-computed filters (Procs that re-evaluate per call) are tracked as a Phase 2 follow-up — most "dynamic" cases are already covered by `tenant_id:` or by constructing a fresh agent per request with the right filter baked in.
876
+
877
+ ### `parent:` — sub-agent inheritance
878
+
879
+ When a tool handler constructs a sub-agent inside its block, pass `parent:` so the sub inherits the shared state and auth scope of the parent:
880
+
881
+ ```ruby
882
+ Parse::Agent::Tools.register(
883
+ name: :delegate_to_billing,
884
+ description: "Hand a billing question to a specialist sub-agent",
885
+ parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
886
+ permission: :readonly,
887
+ handler: ->(agent, question:, **_) do
888
+ sub = Parse::Agent.new(
889
+ permissions: agent.permissions,
890
+ parent: agent, # inherits limiter, correlation, depth, auth scope
891
+ tools: { only: BILLING_TOOL_SET }, # narrows the sub's surface to the billing toolset
892
+ )
893
+ sub.ask(question)
894
+ end,
895
+ )
896
+ ```
897
+
898
+ **What is inherited:**
899
+
900
+ | State | Inherited unless explicit override | Why |
901
+ |-------|-----------------------------------|-----|
902
+ | `rate_limiter` | Yes | Without sharing, the sub gets a fresh budget and an attacker who can induce delegation amplifies the per-process limit linearly with delegation depth × branching. |
903
+ | `correlation_id` | Yes | Without it, the sub's tool calls fire `parse.agent.tool_call` notifications with no `:correlation_id`, severing the audit thread for the LLM turn. |
904
+ | `session_token` | Yes (security-critical) | Without it, a session-token parent silently produces a master-key sub-agent — the constructor default is `nil`, which means master-key mode. This was the v4.2 advisor-flagged blocker; do not undo. |
905
+ | `acl_user` (v4.4.0) | Yes (security-critical) | When the parent was constructed with `acl_user:` and the child supplies none of `session_token:` / `acl_user:` / `acl_role:`, the parent's identity inherits verbatim. Inheritance is conditional on the child supplying NO identity at all — explicit overrides on the child resolve normally and then face the subset check below. |
906
+ | `acl_role` (v4.4.0) | Yes (security-critical) | Same rule as `acl_user`. A child that omits identity inherits the parent's role scope; one that supplies its own identity falls through to the subset check. |
907
+ | `tenant_id` | Yes (security-critical) | Without it, a tenant-bound parent produces an unbound sub-agent that escapes `agent_tenant_scope` rules. |
908
+ | `recursion_depth` | Always (decremented) | The parent's budget is authoritative — the explicit `recursion_depth:` kwarg is ignored on inherited construction. |
909
+
910
+ **What is NOT inherited (but is clamped):**
911
+
912
+ | State | Why not |
913
+ |-------|---------|
914
+ | `permissions` | The default of `:readonly` means `Parse::Agent.new(parent: write_agent)` produces a `:readonly` sub-agent. A sub-agent is at most as privileged as the parent by tier; this is enforced by a clamp check at construction, not by inheritance. An explicit override is accepted only if `≤ parent.permissions` — `Parse::Agent.new(parent: readonly_parent, permissions: :admin)` raises `ArgumentError`. Pass `permissions: parent.permissions` to maintain parity intentionally. |
915
+ | `client` | The constructor default `:default` resolves to the same client in standard single-app deployments. Explicit passes through. |
916
+ | `tools:` / `methods:` filters | The whole point of constructing a sub-agent is usually to give it a NARROWER surface. Explicit passes through. |
917
+
918
+ **The clamp invariant:** `sub.permissions ≤ parent.permissions` always holds. The default `:readonly` is always safe regardless of parent tier; only explicit overrides hit the clamp check, and overrides that exceed the parent's tier raise at construction. This is the structural guarantee that a `delegate_to_subagent` chain cannot escape the parent's tier through sub-agent construction — the only path to a more-privileged agent is at the MCP factory, where the explicit elevation is auditable.
919
+
920
+ **ACL-scope subset invariant (v4.4.0):** when the parent carries a resolved ACL scope (session_token / acl_user / acl_role), an explicit child override must resolve to a `permission_strings` set that is a SUBSET of the parent's. A tool handler that tries `Parse::Agent.new(parent: user_scoped, acl_role: "admin")` raises `ArgumentError` at construction because the child's claim set would include `"role:admin"`, which the parent's claim set does not. The same applies to a different `acl_user:` (different user_id), or to a child that resolves to master-key while the parent was scoped. This closes the analogous footgun for the acl_user / acl_role identity axis — the precedent of session_token swap is misleading because session tokens are externally verified by Parse Server, while `acl_user:` and `acl_role:` are unverified constructor assertions. A master-key parent (`@acl_scope.nil?`) allows any child scope because the parent already has unrestricted reach.
921
+
922
+ ### Developer introspection — `agent.describe` / `describe_for` / `would_permit?` (v4.4.0)
923
+
924
+ Three helpers on every agent for answering "why is this agent refusing this call?" and "what can this agent actually see?" without parsing audit payloads or tracing through tool implementations. NOT exposed to the LLM — operator-side observability only.
925
+
926
+ **`agent.describe`** returns a Hash listing every layer that gates the agent:
927
+
928
+ ```ruby
929
+ support = Parse::Agent.new(
930
+ permissions: :readonly,
931
+ session_token: user.session_token,
932
+ classes: { only: [Ticket, Customer] },
933
+ filters: { Ticket => { archived: false } },
934
+ tools: { except: [:emit_artifact] },
935
+ )
936
+
937
+ support.describe
938
+ # => {
939
+ # agent_id: "abc...",
940
+ # permissions: :readonly,
941
+ # auth: { mode: :session_token, fingerprint: "f8a9b2c1" },
942
+ # tenant_id: nil,
943
+ # classes: { only: ["Customer", "Ticket"], except: nil },
944
+ # tools: { only: nil, except: [:emit_artifact], effective: [...] },
945
+ # methods: { only: nil, except: nil },
946
+ # filters: { "Ticket" => ["archived"] }, # field names, not values
947
+ # hidden_classes: ["_Product", "_Session"],
948
+ # per_class: { "Ticket" => {...}, "Customer" => {...} },
949
+ # strict_modes: { tool_filter: false, class_filter: false },
950
+ # correlation_id: nil,
951
+ # }
952
+ ```
953
+
954
+ Pass `pretty: true` for a multi-line String formatted for `puts` debugging — same data, human-readable rather than structured.
955
+
956
+ **`agent.describe_for(class_name)`** is the unbounded per-class lookup. Accepts Class constants, parse_class Strings, or Symbols:
957
+
958
+ ```ruby
959
+ support.describe_for("Ticket")
960
+ # => {
961
+ # class_name: "Ticket",
962
+ # accessible: :permitted,
963
+ # agent_fields: [:subject, :status, :created_at, ...],
964
+ # agent_canonical_filter: { "isDraft" => { "$ne" => true } },
965
+ # per_agent_filter: { archived: false }, # composed: per-class AND :default
966
+ # tenant_scope: { field: :org_id, value: "acme" },
967
+ # large_fields: [:body_html],
968
+ # agent_methods: ["archive", "reopen"], # tier-filtered to what this agent can call
969
+ # }
970
+ ```
971
+
972
+ **`agent.would_permit?(tool, class_name:)`** simulates the dispatch gate without invoking the tool. Lets a developer answer "why was this refused?" in one line:
973
+
974
+ ```ruby
975
+ support.would_permit?(:query_class, class_name: "Ticket")
976
+ # => { allowed: true }
977
+
978
+ support.would_permit?(:create_object, class_name: "Ticket")
979
+ # => { allowed: false, reason: :tool_filtered, denied_at: :allowed_tools }
980
+
981
+ support.would_permit?(:query_class, class_name: "_User")
982
+ # => { allowed: false, reason: :class_filter, denied_at: :assert_class_accessible! }
983
+ ```
984
+
985
+ The `reason:` Symbol mirrors the audit-payload `:denial_kind` discriminators (`:tool_filtered`, `:class_filter`, `:access_denied`, `:hidden_class`), so developer tooling and SOC subscribers branch on the same vocabulary.
986
+
987
+ **`session_token` is never echoed.** Master-key mode is shown as `{ mode: :master_key }` with no fingerprint. Session-token mode shows `{ mode: :session_token, fingerprint: "<8 hex>" }` — the first 8 hex characters of `SHA256(session_token)`. Two `describe` calls on the same session correlate to the same fingerprint without leaking the bearer token. Verified by test to never appear in Hash output, the `pretty: true` String, or `describe_for`.
988
+
989
+ **`:filters` summary echoes field names, not values.** A `filters: { Account => { user_id: "abc123" } }` shows as `{ "Account" => ["user_id"] }` in `describe[:filters]` — matching the same value-stripping policy used for the audit payload. Use `agent.filter_for(class_name)` directly when you need the constraint values themselves.
990
+
991
+ ### `recursion_depth:` — sub-agent depth cap
992
+
993
+ Defends against any tool handler that constructs a sub-agent (e.g., the `delegate_to_subagent` recipe above) recursing without bound.
994
+
995
+ ```ruby
996
+ # Use a tighter cap than the default for a single request
997
+ Parse::Agent.new(recursion_depth: 2)
998
+
999
+ # Change the global default
1000
+ Parse::Agent.default_recursion_depth = 3
1001
+ ```
1002
+
1003
+ The default is **4**. The budget decrements on every inherited construction; a sub-agent that reaches `recursion_depth == 0` can still execute its own tools but cannot construct another sub-agent — that raises `Parse::Agent::RecursionLimitExceeded` at construction time. The error is intentionally a raise, not an `error_code:` — sub-agent construction is a programming-time choice, not a tool-dispatch decision, so it should surface immediately to the developer rather than be swallowed into the wire response.
1004
+
1005
+ ### `Parse::Agent.strict_tool_filter` — boot-time unknown-name raise
1006
+
1007
+ Production deployments where `Kernel#warn` may be muted by the host process (some Passenger / Unicorn configurations with `$stderr` redirected to `/dev/null`) cannot rely on the lazy-allowlist warn for typo detection. Enable strict mode for boot-time crash on misconfiguration:
1008
+
1009
+ ```ruby
1010
+ # Global — applies to every Parse::Agent.new
1011
+ Parse::Agent.strict_tool_filter = true
1012
+
1013
+ # Per-instance override — only this agent raises
1014
+ Parse::Agent.new(tools: [...], strict_tool_filter: true)
1015
+ ```
1016
+
1017
+ `strict_tool_filter` applies only to `tools:`. The `methods:` filter is never validated against an "unknown name" list at construction (see the rationale in the `methods:` section above).
1018
+
1019
+ ### Recipe: dashboard-only `emit_artifact` tool
1020
+
1021
+ The original v4.2 design motivation. A single `/mcp` mount serves both Claude Desktop external clients and the internal dashboard SPA; only the dashboard should see the `emit_artifact` tool:
1022
+
1023
+ ```ruby
1024
+ # At boot
1025
+ Parse::Agent::Tools.register(
1026
+ name: :emit_artifact,
1027
+ description: "Persist a chart/table artifact for the dashboard to reload later.",
1028
+ parameters: { type: "object", properties: { ... } },
1029
+ permission: :readonly,
1030
+ handler: ->(agent, **args) { AdminInternal::Artifact.create!(**args, actor_sub: agent.correlation_id) },
1031
+ )
1032
+
1033
+ # Mount
1034
+ mount Parse::Agent.rack_app { |env|
1035
+ session = MyAuth.session_for(env)
1036
+ raise Parse::Agent::Unauthorized unless session
1037
+
1038
+ base_args = {
1039
+ permissions: :readonly,
1040
+ session_token: session.parse_token,
1041
+ tenant_id: session.org_id,
1042
+ }
1043
+
1044
+ if session.via_dashboard?
1045
+ Parse::Agent.new(**base_args) # full registered surface — emit_artifact included
1046
+ else
1047
+ Parse::Agent.new(**base_args, tools: { except: [:emit_artifact] })
1048
+ end
1049
+ }, at: "/mcp"
1050
+ ```
1051
+
1052
+ Per-request `tools/list` isolation is the load-bearing invariant for this pattern. The covering integration test is `test/lib/parse/agent/tool_filter_test.rb#test_mcp_dispatcher_tools_list_reflects_per_request_filter`.
1053
+
1054
+ ---
1055
+
1056
+ ## Conversational Client (MCPClient)
1057
+
1058
+ `Parse::Agent::MCPClient` wraps a `Parse::Agent` and adds an LLM round-trip layer. It translates the agent's MCP tool catalog into the provider's native function-calling schema, drives multi-turn tool-calling iterations, dispatches every tool the LLM invokes through `MCPDispatcher`, and returns a structured `Result` with the LLM's final answer plus token usage.
1059
+
1060
+ Use it when you need a natural-language interface to your Parse data without re-implementing the tool-translation and dispatch loop yourself.
1061
+
1062
+ ### Provider setup
1063
+
1064
+ Three providers are supported. Select one via the `provider:` keyword or the `LLM_PROVIDER` environment variable:
1065
+
1066
+ | Provider | Value | Notes |
1067
+ |----------|-------|-------|
1068
+ | OpenAI | `:openai` | Uses the Chat Completions endpoint. Requires `LLM_API_KEY`. |
1069
+ | Anthropic | `:anthropic` | Uses the Messages endpoint. Requires `LLM_API_KEY`. |
1070
+ | LM Studio | `:lmstudio` | OpenAI-compatible; any local server (LM Studio, Ollama, vLLM). API key value is ignored. |
1071
+
1072
+ Default models when `LLM_MODEL` is not set: `gpt-4o-mini` (OpenAI), `claude-haiku-4-5` (Anthropic), `qwen2.5-7b-instruct` (LM Studio).
1073
+
1074
+ Default base URLs: `https://api.openai.com/v1` (OpenAI), `https://api.anthropic.com/v1` (Anthropic), `http://localhost:1234/v1` (LM Studio).
1075
+
1076
+ ### Constructor
1077
+
1078
+ ```ruby
1079
+ require "parse/agent/mcp_client"
1080
+
1081
+ client = Parse::Agent::MCPClient.new(
1082
+ agent: my_agent, # required — a Parse::Agent instance
1083
+ provider: :openai, # required unless LLM_PROVIDER is set
1084
+ api_key: ENV["LLM_API_KEY"],
1085
+ model: "gpt-4o-mini", # optional; overrides LLM_MODEL and default
1086
+ base_url: nil, # optional; overrides LLM_BASE_URL and default
1087
+ max_iterations: 8, # cap on tool-call turns per ask (default 8)
1088
+ timeout: 90, # per-request HTTP read timeout in seconds
1089
+ system_prompt: nil, # optional String prepended to every conversation
1090
+ pricing: nil, # override DEFAULT_PRICING table (Hash)
1091
+ auto_compact_at: nil, # auto-compact threshold in tokens (Integer or nil)
1092
+ )
1093
+ ```
1094
+
1095
+ `ArgumentError` is raised immediately if `provider` is missing, unknown, or if `api_key` is empty (except for `:lmstudio`, which ignores the value and fills a placeholder).
1096
+
1097
+ ### Asking a question
1098
+
1099
+ ```ruby
1100
+ result = client.ask("How many users signed up in the last 24 hours?")
1101
+
1102
+ puts result.text # the LLM's final answer as a String
1103
+ result.tool_calls.each { |tc| puts "#{tc[:name]}: #{tc[:arguments].inspect}" }
1104
+ puts result.usage # "84 in + 120 out = 204 tokens $0.000101"
1105
+ ```
1106
+
1107
+ `ask` resets conversation history by default (`reset: true`). Pass `reset: false` to continue from prior context:
1108
+
1109
+ ```ruby
1110
+ client.ask("How many users signed up in the last 24 hours?")
1111
+ client.ask("And how many of those are in the Admin role?", reset: false)
1112
+ ```
1113
+
1114
+ ### Result object
1115
+
1116
+ `ask` returns a `Parse::Agent::MCPClient::Result` struct:
1117
+
1118
+ | Attribute | Type | Description |
1119
+ |-----------|------|-------------|
1120
+ | `text` | String | The LLM's final-turn answer. |
1121
+ | `tool_calls` | Array<Hash> | Ordered list of tools invoked. Each entry has `:name`, `:arguments`, and `:result`. |
1122
+ | `transcript` | Array<Hash> | Full message log for the call (useful for debugging). |
1123
+ | `usage` | `Usage` | Token counts and USD cost for this single `ask` call. |
1124
+ | `client` | `MCPClient` | Back-reference to the originating client. |
1125
+
1126
+ `Result#reply(question)` continues the same conversation without resetting history:
1127
+
1128
+ ```ruby
1129
+ chain = client.ask("How many Song records do we have?")
1130
+ .reply("Which genre has the most?")
1131
+ .reply("And the fewest?")
1132
+ puts chain.text
1133
+ ```
1134
+
1135
+ ### Multi-turn sessions
1136
+
1137
+ History accumulates across `ask(..., reset: false)` calls. Read it at any point:
1138
+
1139
+ ```ruby
1140
+ client.history # => Array of { role:, content: } hashes (a dup — safe to inspect)
1141
+ ```
1142
+
1143
+ Reset explicitly when you want to start fresh without constructing a new client:
1144
+
1145
+ ```ruby
1146
+ client.reset!
1147
+ ```
1148
+
1149
+ ### Token usage and cost
1150
+
1151
+ ```ruby
1152
+ # Per-call usage from the most recent ask
1153
+ puts client.last_call_usage # "42 in + 65 out = 107 tokens $0.000053"
1154
+
1155
+ # Running session totals (accumulates across every ask and compact! call)
1156
+ puts client.usage # "512 in + 890 out = 1402 tokens $0.001231"
1157
+ ```
1158
+
1159
+ The `Usage` struct has fields `prompt_tokens`, `completion_tokens`, `total_tokens`, and `cost_usd` (USD dollars, not cents). Arithmetic via `+` is defined, so you can sum usages from separate clients.
1160
+
1161
+ Cost is computed from `DEFAULT_PRICING`, a table of list prices per million tokens keyed by model name. Override at construction time with `pricing:` or assign to `client.pricing` afterward:
1162
+
1163
+ ```ruby
1164
+ client.pricing = { "gpt-4o-mini" => { input: 0.15, output: 0.60 } }
1165
+ ```
1166
+
1167
+ Models not in the table default to zero cost.
1168
+
1169
+ ### Session compaction
1170
+
1171
+ When a long session approaches the model's context limit, call `compact!` to replace the conversation history with an LLM-generated summary that preserves tool-retrieved facts:
1172
+
1173
+ ```ruby
1174
+ summary = client.compact!
1175
+ # => "The database has 4,231 users, of which 87 are admins. The most active..."
1176
+ ```
1177
+
1178
+ `compact!` costs one extra LLM call; its token usage is folded into `client.usage`. After compacting, `client.history` contains a single system-tagged summary turn.
1179
+
1180
+ ### Automatic compaction
1181
+
1182
+ Set `auto_compact_at:` at construction time to trigger compaction automatically when the session's running total crosses a threshold:
1183
+
1184
+ ```ruby
1185
+ client = Parse::Agent::MCPClient.new(
1186
+ agent: my_agent,
1187
+ provider: :openai,
1188
+ api_key: ENV["LLM_API_KEY"],
1189
+ auto_compact_at: 50_000, # compact when session exceeds 50k tokens
1190
+ )
1191
+ ```
1192
+
1193
+ `max_iterations: 8` (the default) caps tool-call turns per `ask` call, providing implicit per-question cost protection independent of session length.
1194
+
1195
+ ### End-to-end example
1196
+
1197
+ ```ruby
1198
+ require "parse-stack"
1199
+ require "parse/agent"
1200
+ require "parse/agent/mcp_client"
1201
+
1202
+ # Boot the Parse client (production app would use ENV vars or an initializer)
1203
+ Parse.setup(
1204
+ server_url: ENV["PARSE_SERVER_URL"],
1205
+ application_id: ENV["PARSE_APP_ID"],
1206
+ api_key: ENV["PARSE_API_KEY"],
1207
+ master_key: ENV["PARSE_MASTER_KEY"],
1208
+ )
1209
+
1210
+ agent = Parse::Agent.new(permissions: :readonly)
1211
+ client = Parse::Agent::MCPClient.new(
1212
+ agent: agent,
1213
+ provider: :openai,
1214
+ api_key: ENV["LLM_API_KEY"],
1215
+ model: "gpt-4o-mini",
1216
+ max_iterations: 8,
1217
+ auto_compact_at: 40_000,
1218
+ )
1219
+
1220
+ # Single question
1221
+ result = client.ask("What are the five most recently created Song records?")
1222
+ puts result.text
1223
+
1224
+ # Multi-turn chain using reply
1225
+ client.ask("How many Song records are there in total?")
1226
+ .reply("Which artist appears most often?")
1227
+ .reply("Does that artist have any records created before 2024?")
1228
+ .tap { |r| puts r.text }
1229
+
1230
+ # Session cost summary
1231
+ puts "Session total: #{client.usage}"
1232
+ ```
1233
+
1234
+ ---
1235
+
1236
+ ## Rake Tasks for Local Interaction
1237
+
1238
+ Three rake tasks give you immediate access to Parse data via the MCP agent layer: a conversational chat loop (`mcp:chat`), an IRB console with MCP helpers pre-bound (`mcp:console`), and a one-shot tool dispatcher (`mcp:tool`).
1239
+
1240
+ ### Environment setup
1241
+
1242
+ All three tasks read configuration from `.env` (via `dotenv`) or from shell environment variables. Copy `.env.sample` to `.env` and fill in values:
1243
+
1244
+ ```bash
1245
+ cp .env.sample .env
1246
+ ```
1247
+
1248
+ The Parse connection block is required for all tasks:
1249
+
1250
+ ```bash
1251
+ PARSE_SERVER_URL=http://localhost:2337/parse
1252
+ PARSE_APP_ID=myAppId
1253
+ PARSE_API_KEY=myApiKey
1254
+ PARSE_MASTER_KEY=myMasterKey
1255
+ ```
1256
+
1257
+ For `mcp:chat` and the optional LLM binding in `mcp:console`, add one provider stanza. Pick one:
1258
+
1259
+ ```bash
1260
+ # OpenAI (~$0.0001 per question with gpt-4o-mini)
1261
+ LLM_PROVIDER=openai
1262
+ LLM_API_KEY=sk-proj-...
1263
+ LLM_MODEL=gpt-4o-mini
1264
+
1265
+ # Anthropic (~$0.001 per question with claude-haiku-4-5)
1266
+ LLM_PROVIDER=anthropic
1267
+ LLM_API_KEY=sk-ant-api03-...
1268
+ LLM_MODEL=claude-haiku-4-5
1269
+
1270
+ # LM Studio (free, local — start the server first)
1271
+ LLM_PROVIDER=lmstudio
1272
+ LLM_MODEL=qwen2.5-7b-instruct
1273
+ LLM_BASE_URL=http://localhost:1234/v1
1274
+ LLM_API_KEY=lm-studio
1275
+ ```
1276
+
1277
+ See `.env.sample` for the complete template including optional fields.
1278
+
1279
+ **Sanity check.** Verify the Docker Parse Server is reachable before running tasks that require it:
1280
+
1281
+ ```bash
1282
+ curl http://localhost:2337/parse/health
1283
+ # Expected: {"status":"ok"}
1284
+ ```
1285
+
1286
+ If that fails, start the test containers first: `docker-compose -f scripts/docker/docker-compose.test.yml up -d`.
1287
+
1288
+ ### `rake mcp:chat` — conversational loop
1289
+
1290
+ A continuous chat session backed by `MCPClient`. Each input drives the LLM through tool calls against Parse and prints the final answer. History persists across turns within the session.
1291
+
1292
+ ```bash
1293
+ bundle exec rake mcp:chat
1294
+ ```
1295
+
1296
+ Requires `LLM_PROVIDER` and `LLM_API_KEY` in the environment (or `.env`). Aborts with a helpful message if `LLM_PROVIDER` is not set.
1297
+
1298
+ **Slash commands available inside the loop:**
1299
+
1300
+ | Command | Effect |
1301
+ |---------|--------|
1302
+ | `/reset` | Clear conversation history and start fresh. |
1303
+ | `/compact` | Replace history with an LLM-generated summary (one extra call). Prints the token delta and a truncated preview. |
1304
+ | `/tools` | Print every MCP tool available to the current agent (sorted). |
1305
+ | `/trace` | Toggle per-turn tool-call trace output on or off. Also controlled by `MCP_CHAT_TRACE=true` in the environment at startup. |
1306
+ | `/cost` | Print session token totals and USD cost, plus per-call figures from the last turn. |
1307
+ | `/history` | Print the current conversation history (first 120 characters per turn). |
1308
+ | `/exit` or `/quit` | End the session. Also: `Ctrl-D` or an empty line. |
1309
+
1310
+ ```
1311
+ $ bundle exec rake mcp:chat
1312
+
1313
+ Parse MCP Chat — openai / gpt-4o-mini
1314
+ Permissions: readonly | Trace: off
1315
+ Type your question. Slash commands: /reset /tools /trace /history /exit
1316
+ ======================================================================
1317
+
1318
+ > How many Song records do we have?
1319
+
1320
+ There are 4,231 Song records in the database.
1321
+
1322
+ > /cost
1323
+ session: 84 in + 121 out = 205 tokens $0.0001
1324
+ last: 84 in + 121 out = 205 tokens $0.000101
1325
+
1326
+ > /exit
1327
+ bye
1328
+ ```
1329
+
1330
+ Override the default `:readonly` permission level with `MCP_AGENT_PERMISSIONS=write rake mcp:chat` if you need write-capable tools in the session.
1331
+
1332
+ ### `rake mcp:console` — IRB REPL with MCP helpers
1333
+
1334
+ Drops you into an IRB session with a pre-configured `Parse::Agent` and a set of shortcut helpers bound at the top level. Useful for ad-hoc exploration, debugging custom tools, and testing query shapes interactively.
1335
+
1336
+ ```bash
1337
+ bundle exec rake mcp:console
1338
+ ```
1339
+
1340
+ **Helpers available in the session:**
1341
+
1342
+ | Helper | Description |
1343
+ |--------|-------------|
1344
+ | `agent` | The configured `Parse::Agent` instance. |
1345
+ | `tools` | Print all available tool names (sorted), return count. |
1346
+ | `schemas` | Print all visible class names grouped by custom / built-in, return combined list. |
1347
+ | `t(name, **kwargs)` | Invoke a tool by name. Returns the raw result hash. |
1348
+ | `q(class_name, **opts)` | Shortcut for `t(:query_class, class_name:, **opts)`. |
1349
+ | `count(class_name)` | Shortcut for `t(:count_objects, class_name:)`. |
1350
+ | `schema(class_name)` | Shortcut for `t(:get_schema, class_name:)`. |
1351
+ | `dispatch(method, params={})` | Build and dispatch a raw MCP JSON-RPC call. Returns the dispatcher result hash. |
1352
+ | `prompts` | Print all registered and built-in prompt names, return count. |
1353
+ | `render_prompt(name, args={})` | Render a prompt to its message envelope. |
1354
+
1355
+ When `LLM_PROVIDER` (and `LLM_API_KEY` for cloud providers) is set in the environment, the console also binds `mcp` as a `Parse::Agent::MCPClient` instance, enabling natural-language queries inline:
1356
+
1357
+ ```ruby
1358
+ irb> mcp.ask("how many students are there?")
1359
+ irb> _.reply("just for Ms. Vasquez") # _ is the last Result; reply continues the conversation
1360
+ ```
1361
+
1362
+ Example session:
1363
+
1364
+ ```ruby
1365
+ irb> tools
1366
+ # count_objects
1367
+ # get_object
1368
+ # query_class
1369
+ # ...
1370
+
1371
+ irb> schemas
1372
+ # Custom: Song, Album, Comment
1373
+ # Built-in: _User, _Role, _Session
1374
+ # => ["Song", "Album", "Comment", "_User", "_Role", "_Session"]
1375
+
1376
+ irb> q("Song", limit: 3, where: { "genre" => "Rock" })
1377
+ # => { success: true, data: { results: [...], count: 3 } }
1378
+
1379
+ irb> count("Song")
1380
+ # => { success: true, data: { count: 4231, class_name: "Song" } }
1381
+
1382
+ irb> dispatch("initialize")
1383
+ # => { status: 200, body: { "jsonrpc" => "2.0", "result" => { ... } } }
1384
+ ```
1385
+
1386
+ ### `rake "mcp:tool[name,jsonArgs]"` — one-shot tool dispatch
1387
+
1388
+ Execute a single tool call from the command line without entering IRB. Arguments are passed as a JSON object. The result is printed as pretty JSON; the task exits with status `0` on success, `1` on failure.
1389
+
1390
+ ```bash
1391
+ # Count objects in a class
1392
+ bundle exec rake "mcp:tool[count_objects,{\"class_name\":\"_User\"}]"
1393
+
1394
+ # Query with a where clause
1395
+ bundle exec rake "mcp:tool[query_class,{\"class_name\":\"Song\",\"limit\":5,\"where\":{\"genre\":\"Rock\"}}]"
1396
+
1397
+ # Fetch a schema
1398
+ bundle exec rake "mcp:tool[get_schema,{\"class_name\":\"_User\"}]"
1399
+ ```
1400
+
1401
+ The tool name maps directly to a built-in or registered tool. Use `bundle exec rake mcp:console` then type `tools` if you need to enumerate available names.
1402
+
1403
+ The permission level defaults to `:readonly`. Override with `MCP_AGENT_PERMISSIONS`:
1404
+
1405
+ ```bash
1406
+ MCP_AGENT_PERMISSIONS=write bundle exec rake "mcp:tool[create_class,{\"class_name\":\"Playlist\"}]"
1407
+ ```
1408
+
1409
+ ---
1410
+
1411
+ ## Prompts
1412
+
1413
+ Prompts are named instruction templates that an MCP client can request by name, optionally passing arguments. The dispatcher exposes them via `prompts/list` and `prompts/get`.
1414
+
1415
+ ### Built-in prompts
1416
+
1417
+ | Name | Description |
1418
+ |------|-------------|
1419
+ | `parse_conventions` | Generic Parse platform conventions (objectId shape, pointer/date formats, system classes). Fetch once and prepend to your LLM system message. |
1420
+ | `parse_relations` | ASCII diagram of class relationships derived from `belongs_to` and `has_many :through => :relation`. Accepts an optional `classes` argument (comma-separated subset). |
1421
+ | `explore_database` | Survey all Parse classes: list them, count each, and summarize what each appears to store. |
1422
+ | `class_overview` | Describe a class in detail: schema, total count, and sample objects. Requires `class_name`. |
1423
+ | `count_by` | Count objects in a class grouped by a field. Requires `class_name` and `group_by`. |
1424
+ | `recent_activity` | Show the most recently created objects in a class. Requires `class_name`; optional `limit` (default 10, max 100). |
1425
+ | `find_relationship` | Find objects in one class related to a given object in another via a pointer field. Requires `parent_class`, `parent_id`, `child_class`, `pointer_field`. |
1426
+ | `created_in_range` | Count and sample objects created within a date range. Requires `class_name` and `since` (ISO8601); optional `until`. |
1427
+
1428
+ ### Registering custom prompts
1429
+
1430
+ Register before the `MCPRackApp` or `MCPServer` starts handling requests. Registration is thread-safe (guarded by an internal mutex), but the registry is global to the process.
1431
+
1432
+ ```ruby
1433
+ Parse::Agent::Prompts.register(
1434
+ name: "team_health",
1435
+ description: "Summary of team activity in the last 30 days",
1436
+ arguments: [
1437
+ { "name" => "team_id", "description" => "Parse objectId of the team", "required" => true }
1438
+ ],
1439
+ renderer: ->(args) {
1440
+ since = (Time.now - 30 * 86400).utc.iso8601
1441
+ "Show activity for team #{args["team_id"]} since #{since}. " \
1442
+ "Use count_objects and query_class to report events, members, and recent changes."
1443
+ }
1444
+ )
1445
+ ```
1446
+
1447
+ A renderer lambda may return either:
1448
+
1449
+ - A `String` — used directly as the MCP message text. Description defaults to `"Parse analytics prompt: <name>"`.
1450
+ - A `Hash` with `:description` and `:text` keys — both are used verbatim. This is the only way to customize the per-render description.
1451
+
1452
+ ```ruby
1453
+ # Hash form — overrides description per render
1454
+ renderer: ->(args) {
1455
+ {
1456
+ description: "Team #{args["team_id"]} health report",
1457
+ text: "Analyze team #{args["team_id"]} activity since #{Time.now - 30 * 86400}."
1458
+ }
1459
+ }
1460
+ ```
1461
+
1462
+ Registering a name that matches a built-in replaces the built-in in `prompts/list` and `prompts/get` responses. To restore built-in-only state (useful in test teardown), call `Parse::Agent::Prompts.reset_registry!`.
1463
+
1464
+ ---
1465
+
1466
+ ## MCP Protocol Surface
1467
+
1468
+ All requests must be HTTP `POST` to the mounted path with `Content-Type: application/json`.
1469
+
1470
+ ### Supported methods
1471
+
1472
+ | Method | Description |
1473
+ |--------|-------------|
1474
+ | `initialize` | MCP handshake. Returns protocol version, server capabilities, and server name/version. |
1475
+ | `tools/list` | Returns all tools available to the current agent (filtered by permission level). Includes custom registered tools. Every descriptor carries a `_meta.category` field (v4.2.1). Accepts an optional non-standard `category` param to narrow the response server-side; see [Tool Categories & `list_tools`](#tool-categories--list_tools). |
1476
+ | `tools/call` | Executes a named tool with arguments. Tool-level errors return `isError: true` in `content`, not a JSON-RPC `error` field. The built-in `list_tools` tool (v4.2.1) returns a lightweight catalog (`name`+`category`+`description` only) and is significantly cheaper than `tools/list` for discovery. |
1477
+ | `prompts/list` | Returns all available prompts (built-in plus registered). |
1478
+ | `prompts/get` | Renders a named prompt with arguments. Returns `{ description, messages }`. |
1479
+ | `resources/list` | Lists virtual resources for each Parse class: `parse://<ClassName>/schema`, `/count`, `/samples`. Fixed in the same release as `agent_hidden` — see note below. |
1480
+ | `resources/templates/list` | Returns the three URI templates (`parse://{className}/{schema,count,samples}`) clients can use to build resource URIs without scraping `resources/list`. See **Resource templates** below. |
1481
+ | `resources/read` | Reads a resource by URI. Supported kinds: `schema`, `count`, `samples`. |
1482
+ | `ping` | No-op. Returns an empty result `{}`. |
1483
+ | `notifications/initialized` | Client signal that the `initialize` handshake completed. JSON-RPC notification (no `id`, no response body). The dispatcher performs no work — accepting the method prevents spurious `-32601 "Method not found"` errors at clients that send it (Claude Desktop, MCP Inspector, Cursor). |
1484
+ | `notifications/cancelled` | Cooperative cancellation of an in-flight request. JSON-RPC notification (no `id`, no response body). See **Cancellation** section. |
1485
+ | `notifications/tools/list_changed` | Server → client SSE-only notification fired when `Parse::Agent::Tools.register` or `Tools.reset_registry!` mutates the registry. See **listChanged notifications** below. |
1486
+ | `notifications/prompts/list_changed` | Server → client SSE-only notification fired when `Parse::Agent::Prompts.register` or `Prompts.reset_registry!` mutates the registry. |
1487
+
1488
+ **`resources/list` bug fix.** Earlier versions of `MCPDispatcher#handle_resources_list` read `result[:data][:classes]` from the `get_all_schemas` response — a key that does not exist in the envelope returned by `ResultFormatter#format_schemas`, which uses `{ total:, note:, built_in: [...], custom: [...] }`. This caused every call to `resources/list` from external MCP clients (Claude Desktop, Cursor, Continue.dev, MCP Inspector) to return an empty resource catalog. The handler now reads the `custom` and `built_in` arrays from the correct keys. Each Parse class produces three resource URIs: `parse://<Class>/schema`, `parse://<Class>/count`, and `parse://<Class>/samples`. If you were previously seeing an empty `resources/list` response, no change to your client configuration is needed — the fix is server-side.
1489
+
1490
+ **Resource templates (v4.2).** `resources/templates/list` returns three RFC 6570 URI templates so clients can build resource URIs for any Parse class without scraping the full `resources/list` enumeration. The response shape is:
1491
+
1492
+ ```json
1493
+ {
1494
+ "resourceTemplates": [
1495
+ { "uriTemplate": "parse://{className}/schema", "name": "Parse class schema", "mimeType": "application/json", "description": "..." },
1496
+ { "uriTemplate": "parse://{className}/count", "name": "Parse class object count", "mimeType": "application/json", "description": "..." },
1497
+ { "uriTemplate": "parse://{className}/samples", "name": "Parse class sample objects", "mimeType": "application/json", "description": "..." }
1498
+ ]
1499
+ }
1500
+ ```
1501
+
1502
+ Three properties worth knowing:
1503
+
1504
+ - **Templates are static server metadata.** The handler does not call `get_all_schemas` or any other agent tool — templates describe the URI shape, not the set of resources that exist. Clients combine the template with a `className` they discovered through `tools/list`, `resources/list`, or their own knowledge.
1505
+ - **`{className}` is unconstrained on the wire.** The class-name placeholder is validated when the client actually calls `resources/read parse://<expanded-name>/<kind>`; unknown or malformed names refuse there with a `-32602`. The template surface deliberately does not enumerate which classes are valid because that would leak across `agent_hidden` boundaries.
1506
+ - **`resources/list` is still authoritative for enumeration.** Use templates when a client wants to construct a resource URI for a known class name without re-polling. Use `resources/list` when a client wants to discover which classes have resources to fetch.
1507
+
1508
+ **Pagination.** `tools/list` and `prompts/list` return the full registry in a single response — there is no `cursor`/`nextCursor` pagination. The MCP spec marks pagination as optional for these endpoints. With dozens of registered tools and prompts the response stays small; practical experience suggests keeping each registry under roughly 100 entries before considering grouping, namespacing, or pruning. Aggregate-style features like `resources/list` (which scales with the Parse class count) are similarly unpaginated.
1509
+
1510
+ **MCP protocol version.** `Parse::Agent::MCPDispatcher::PROTOCOL_VERSION` advertises `"2025-06-18"`. Earlier releases pinned `"2024-11-05"`; the bump in v4.2 enables the optional `message` field on `notifications/progress` (added in 2025-03-26) and the `outputSchema` / `structuredContent` fields (2025-06-18) that registered tools may opt into via `Parse::Agent::Tools.register(..., output_schema:)`. Forward-compatible with additive 2025-06-18 fields (`annotations`, resource links) that this gem does not emit. Clients negotiating an older version still interpret the supported methods and capability shape correctly. To track a still-newer MCP revision, update this constant and verify the `initialize` handshake response, the capability declaration shape, and any new error codes against the target version's schema.
1511
+
1512
+ **Capability advertisement.** The `initialize` response declares:
1513
+
1514
+ ```json
1515
+ {
1516
+ "tools": { "listChanged": true },
1517
+ "resources": { "subscribe": false, "listChanged": false },
1518
+ "prompts": { "listChanged": true }
1519
+ }
1520
+ ```
1521
+
1522
+ `tools.listChanged` and `prompts.listChanged` were `false` prior to v4.2. They now match the SSE broadcast behavior described in the next subsection. `resources.listChanged` and `resources.subscribe` remain `false` — resource list mutations require an explicit deploy and are not signaled to clients at runtime.
1523
+
1524
+ ### listChanged notifications
1525
+
1526
+ When an application calls `Parse::Agent::Tools.register`, `Tools.reset_registry!`, `Parse::Agent::Prompts.register`, or `Prompts.reset_registry!` at runtime, every live SSE-streaming MCP client receives a `notifications/tools/list_changed` (or `.../prompts/list_changed`) event. The wire shape is a JSON-RPC notification with no `params`:
1527
+
1528
+ ```json
1529
+ { "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }
1530
+ ```
1531
+
1532
+ Per spec, clients are expected to re-fetch the corresponding list (`tools/list` or `prompts/list`) to see the updated state. The server does not include the new state inline.
1533
+
1534
+ **Subscription lifecycle.** `MCPRackApp::SSEBody` subscribes to both registries when its worker thread starts (`#each` is called) and deregisters on `#close`. Deregistration runs BEFORE the on_close hook fires so a subsequent registry mutation cannot push events into a queue belonging to a stream that has already ended.
1535
+
1536
+ **Scope.** Broadcast is per-process and SSE-only:
1537
+ - JSON-path requests cannot receive notifications. Clients on the JSON path see the new state on their next `tools/list` or `prompts/list` poll.
1538
+ - The standalone WEBrick-backed `MCPServer` does not support streaming and therefore does not deliver listChanged events.
1539
+ - Notifications are not replicated across processes in a clustered deployment — each node broadcasts only to its own connected clients.
1540
+
1541
+ **Subscribing from application code.** Application code that wants to react to registry changes (audit logging, cache invalidation) can call `Parse::Agent::Tools.subscribe { ... }`. The block receives no arguments and is invoked synchronously on the thread that triggered the mutation. The return value is a `Proc` that, when called with no arguments, deregisters the subscriber:
1542
+
1543
+ ```ruby
1544
+ unsubscribe = Parse::Agent::Tools.subscribe do
1545
+ Rails.logger.info "[mcp] tools registry changed; current names: #{Parse::Agent::Tools.all_tool_names.inspect}"
1546
+ end
1547
+ # later, at shutdown:
1548
+ unsubscribe.call
1549
+ ```
1550
+
1551
+ Subscriber callbacks must be fast and non-blocking; long work belongs in a thread or queue that the callback posts to. Exceptions raised by a subscriber are caught and logged via `Kernel#warn` — one bad subscriber cannot break the registry or prevent other subscribers from firing.
1552
+
1553
+ ### Structured tool output
1554
+
1555
+ Registered tools may declare an `outputSchema` via `Parse::Agent::Tools.register(..., output_schema:)`. When declared, the schema surfaces on the `tools/list` response as `outputSchema` for that tool's descriptor, and `tools/call` responses for that tool carry both the existing human-readable `content` array AND a `structuredContent` field mirroring the handler's result data Hash:
1556
+
1557
+ ```ruby
1558
+ Parse::Agent::Tools.register(
1559
+ name: :record_summary,
1560
+ description: "Summarize a record by id",
1561
+ parameters: { "type" => "object", "properties" => { "id" => { "type" => "string" } }, "required" => ["id"] },
1562
+ permission: :readonly,
1563
+ output_schema: {
1564
+ "type" => "object",
1565
+ "properties" => {
1566
+ "id" => { "type" => "string" },
1567
+ "title" => { "type" => "string" },
1568
+ "score" => { "type" => "number" }
1569
+ },
1570
+ "required" => ["id", "title"]
1571
+ },
1572
+ handler: ->(_agent, id:) { { id: id, title: lookup(id).title, score: lookup(id).score } }
1573
+ )
1574
+ ```
1575
+
1576
+ The `tools/call` response for this tool ships with both forms:
1577
+
1578
+ ```json
1579
+ {
1580
+ "content": [{ "type": "text", "text": "{\n \"id\": \"abc\", ...\n}" }],
1581
+ "structuredContent": { "id": "abc", "title": "...", "score": 0.91 },
1582
+ "isError": false
1583
+ }
1584
+ ```
1585
+
1586
+ Per MCP 2025-06-18 expectations, clients should prefer `structuredContent` over parsing `content` text. The text content is unchanged from prior versions so legacy clients keep working unmodified.
1587
+
1588
+ **Scope.** Only tools registered via `Tools.register(..., output_schema:)` opt into structured output. Built-in tools (`query_class`, `aggregate`, `get_object`, etc.) retain text-only output for now — opting them in is a follow-on item that would require declaring schemas for every existing tool. The `output_schema:` parameter is optional; tools registered without it produce the same wire shape they did in 4.1.
1589
+
1590
+ ### Batch pointer resolution: `get_objects`
1591
+
1592
+ When you need to dereference multiple pointers, use `get_objects(class_name:, ids:, include:)` instead of N separate `get_object` calls. The batch tool resolves all IDs in a single Parse API request and is significantly cheaper for both latency and tokens.
1593
+
1594
+ ```ruby
1595
+ result = agent.execute(:get_objects,
1596
+ class_name: "User",
1597
+ ids: ["abc123", "def456", "xyz789"],
1598
+ include: ["team"] # optional pointer fields to resolve
1599
+ )
1600
+ # result[:data] =>
1601
+ # {
1602
+ # class_name: "User",
1603
+ # objects: { "abc123" => {...user}, "def456" => {...user} },
1604
+ # missing: ["xyz789"], # ids that did not match any document
1605
+ # requested: 3,
1606
+ # found: 2
1607
+ # }
1608
+ ```
1609
+
1610
+ Three contract details worth knowing:
1611
+
1612
+ - **50-id cap.** The tool deduplicates `ids` and rejects calls where the deduplicated count exceeds 50. Use `query_class` with a `where: { "objectId" => { "$in" => [...] } }` filter for larger sets.
1613
+ - **Hash-keyed response.** `objects` is a Hash keyed by `objectId`, not an Array, so client code can look up by id without scanning. Missing ids appear in the separate `missing` array.
1614
+ - **agent_fields allowlist inheritance.** If the underlying class declares `agent_fields :only, :these` in its model, the batch fetch applies the same allowlist as a `keys:` projection — PII trimming is consistent with the single-object `get_object` path.
1615
+
1616
+ ### Error codes
1617
+
1618
+ | Code | Name | When used |
1619
+ |------|------|-----------|
1620
+ | `-32700` | Parse error | Body is invalid JSON, wrong content-type, or body exceeds size limit. |
1621
+ | `-32601` | Method not found | The `method` string is not one of the supported methods above. |
1622
+ | `-32602` | Invalid params | Missing or malformed arguments (tool name, resource URI, prompt arguments). |
1623
+ | `-32603` | Internal error | Unexpected `StandardError` inside a handler. Wire body is the literal string `"Internal error"` — no class name, no message, no backtrace. Class and message are emitted to the operator's logger only. |
1624
+ | `-32001` | Unauthorized | `Parse::Agent::Unauthorized` raised by the agent factory or a tool. HTTP status 401. |
1625
+
1626
+ For tool-call failures that are not protocol errors (a query that returns no results, a class that does not exist), the dispatcher returns HTTP 200 with `isError: true` inside the `content` array — not a JSON-RPC error code.
1627
+
1628
+ ### Tool-result `error_code` and structured `details:` (v4.2.1)
1629
+
1630
+ When a tool fails inside `Parse::Agent#execute`, the failure envelope returned to MCP clients carries an `error_code:` symbol naming the broad category (`:access_denied`, `:invalid_argument`, `:invalid_query`, `:permission_denied`, `:tool_filtered`, `:rate_limited`, `:timeout`, `:cancelled`, `:security_blocked`, `:parse_error`, `:tool_error`).
1631
+
1632
+ For `:access_denied` refusals, the envelope additionally carries a `details:` block populated from `Parse::Agent::AccessDenied#to_details`. It lets consumers branch on the specific refusal reason — and, when applicable, auto-rewrite the failing request — without parsing the prose `error:` message:
1633
+
1634
+ ```ruby
1635
+ agent.execute(:aggregate, class_name: "Capture",
1636
+ pipeline: [{ "$group" => { "_id" => "$_p_author", "n" => { "$sum" => 1 } } }]
1637
+ )
1638
+ # => {
1639
+ # success: false,
1640
+ # error: "field reference '$_p_author' (\"_p_author\") outside agent_fields allowlist. " \
1641
+ # "Allowed: author, title, createdAt, ... Hint: '_p_author' is the Parse-on-Mongo " \
1642
+ # "storage column for the 'author' pointer field — reference 'author' directly (e.g. '$author')",
1643
+ # error_code: :access_denied,
1644
+ # details: {
1645
+ # kind: :storage_form_field_ref,
1646
+ # denied_field: "_p_author",
1647
+ # allowed_fields: ["author", "title", "createdAt", "updatedAt", "objectId"],
1648
+ # suggested_rewrite: "$author"
1649
+ # }
1650
+ # }
1651
+ ```
1652
+
1653
+ Known `details[:kind]` subcodes for `:access_denied`:
1654
+
1655
+ | Subcode | When emitted |
1656
+ |---------|--------------|
1657
+ | `:hidden_class` | Target class is marked `agent_hidden` (or its alias resolves to one). Unconditional refusal; the agent's `classes:` filter doesn't apply. |
1658
+ | `:class_filter` | v4.3.0+. Target class is outside the per-agent `classes:` allowlist. Distinct from `:hidden_class` so SOC tooling can separate operator narrowing from policy-level denials. Fires from any of the six enforcement sites: top-level dispatch, include resolution, `$lookup.from`, `$inQuery`/`$select` cross-class operators, post-fetch redaction, and `group_by` group-key collapse. |
1659
+ | `:field_denied` | Projection/sort/match/expression field is outside the class's `agent_fields` allowlist |
1660
+ | `:storage_form_field_ref` | Same as `:field_denied`, but the offending name is the Parse-on-Mongo storage column (`_p_*`); `details[:suggested_rewrite]` points at the bare pointer field name |
1661
+
1662
+ `details[:allowed_fields]` is capped at the first 20 entries for wire compactness. When the class has more, the prose `error:` message includes a `+N more` suffix; the structured array is preview-only.
1663
+
1664
+ The top-level `error_code` stays at `:access_denied` for back-compat with consumers that only branch on it. The new subcode is purely additive — clients that ignore `details:` see no change in behavior.
1665
+
1666
+ ---
1667
+
1668
+ ## Performance and Timeouts
1669
+
1670
+ ### Tool timeout table
1671
+
1672
+ Each tool runs inside a `Timeout.timeout` block. The default timeouts are:
1673
+
1674
+ | Tool | Timeout (seconds) |
1675
+ |------|--------------------|
1676
+ | `aggregate` | 60 |
1677
+ | `query_class` | 30 |
1678
+ | `explain_query` | 30 |
1679
+ | `call_method` | 60 |
1680
+ | `get_all_schemas` | 15 |
1681
+ | `get_schema` | 10 |
1682
+ | `count_objects` | 20 |
1683
+ | `get_object` | 10 |
1684
+ | `get_sample_objects` | 15 |
1685
+
1686
+ Custom tools registered via `Parse::Agent::Tools.register` default to 30 seconds unless a `timeout:` value is supplied.
1687
+
1688
+ When a timeout fires, `Agent#execute` returns `{ success: false, error_code: :timeout }` with a message suggesting the client narrow the filter or add an index.
1689
+
1690
+ ### MongoDB `maxTimeMS` pushdown
1691
+
1692
+ The `query_class` and `aggregate` tools push the tool timeout (minus a 5-second buffer) down to MongoDB as `maxTimeMS`. This ensures that if the Ruby-level `Timeout` fires, MongoDB also cancels the query rather than continuing to consume server resources.
1693
+
1694
+ When MongoDB cancels an operation due to `maxTimeMS`, it raises `Parse::MongoDB::ExecutionTimeout`. `Agent#execute` catches this and returns:
1695
+
1696
+ ```ruby
1697
+ { success: false, error_code: :timeout, error: "Query exceeded time limit. Narrow the filter or add an index." }
1698
+ ```
1699
+
1700
+ ### Response size cap
1701
+
1702
+ `MCPDispatcher` enforces `MAX_TOOL_RESPONSE_BYTES = 4_194_304` (4 MiB) on serialized tool results. When a `tools/call` response would exceed this limit, the dispatcher takes one of two paths depending on the tool:
1703
+
1704
+ **`query_class` — truncate-and-annotate (partial success).** Instead of refusing outright, the dispatcher samples the rows, identifies the heaviest field by per-record bytes, drops that field from every row, and re-serializes. If still over budget it additionally trims trailing rows. The recovered response is returned as `isError: false` with a `_truncated` annotation block:
1705
+
1706
+ ```ruby
1707
+ {
1708
+ results: [...],
1709
+ _truncated: {
1710
+ reason: "response_exceeded_max_bytes",
1711
+ dropped_fields: ["full_text"],
1712
+ kept_count: 7,
1713
+ original_count: 50,
1714
+ next_skip: 107, # only present when rows were trimmed
1715
+ hint: "Field 'full_text' was dropped and only the first 7 of 50 rows fit the 4194304-byte cap. " \
1716
+ "Call query_class(skip: 107) to fetch the next page, or get_object(class_name: <class>, " \
1717
+ "object_id: <id>) for the dropped field.",
1718
+ }
1719
+ }
1720
+ ```
1721
+
1722
+ `next_skip` adds the caller's original `skip:` so consecutive `query_class` calls advance through the same dataset instead of looping. Stale `result_count`, `truncated`, and `truncated_note` fields (from `ResultFormatter`'s 50-row display cap) are stripped from the recovered envelope so `_truncated` is the sole authoritative source on cardinality. The hint deliberately mentions `get_object` so an LLM can fetch the dropped field for a specific row of interest without re-paginating.
1723
+
1724
+ **Other tools — structural refusal with diagnostic.** `aggregate`, `export_data`, `get_object`, `get_objects` all retain `isError: true` refusal. The refusal message includes a per-field byte diagnostic naming the heaviest fields and a POSITIVE `keys:` projection list the caller can use on retry:
1725
+
1726
+ ```
1727
+ Tool result exceeded 4194304 bytes (5234567). Largest fields by bytes:
1728
+ full_text (~98 KB/record), description (52 B/record), title (12 B/record).
1729
+ Try keys: "objectId,createdAt,updatedAt,title,description" (drops the heaviest field).
1730
+ Narrow the query: lower limit:, project fewer fields via keys:/select:, or add stricter where: constraints.
1731
+ ```
1732
+
1733
+ The positive keep-list is intentional — asking the model to subtract (`"excluding 'full_text'"`) produces unreliable retries (Mongo-style `keys: "-full_text"` or dropped `keys:` entirely). Field NAMES appear in the diagnostic; field VALUES never do. The diagnostic respects upstream access control: the sampler walks data that has already passed through `redact_hidden_classes!` and any `agent_fields` projection, so it cannot fingerprint hidden-class contents or PII-trimmed fields.
1734
+
1735
+ The oversized payload is never buffered to the wire in either path — the cap check happens before any HTTP write.
1736
+
1737
+ ### `explain_query` and COLLSCAN refusal
1738
+
1739
+ To detect and block full-collection scans at the tool level, set the global opt-in flag:
1740
+
1741
+ ```ruby
1742
+ Parse::Agent.refuse_collscan = true
1743
+ ```
1744
+
1745
+ With this flag set, `explain_query` will return an error if the query plan shows a `COLLSCAN` (full collection scan) stage, rather than executing it. This is useful in production environments where unindexed queries against large collections can cause performance problems.
1746
+
1747
+ **Refusal response shape.** When `refuse_collscan = true` blocks a query, the tool returns `success: false` with:
1748
+
1749
+ ```ruby
1750
+ {
1751
+ success: false,
1752
+ error: "COLLSCAN on #{class_name} — query would scan the full collection",
1753
+ error_code: :security_blocked,
1754
+ refused: true,
1755
+ reason: "COLLSCAN on #{class_name}",
1756
+ suggestion: "Add a filter on an indexed field, or call explain_query directly to inspect the plan."
1757
+ }
1758
+ ```
1759
+
1760
+ The `winning_plan` field is included only when `Parse::Agent.expose_explain = true` (default false). Exposing the plan is an index-topology enumeration oracle — keep it false for untrusted callers.
1761
+
1762
+ **Security caveat: COLLSCAN refusal is an enumeration oracle.** Even with `expose_explain = false`, the binary refused/not-refused signal lets an authenticated caller probe `where:` clauses across the schema and learn which fields are unindexed. Do not enable `refuse_collscan` on deployments serving untrusted or multi-tenant callers without additional rate-limiting and audit logging. Treat the refusal mechanism as a performance guard for cooperative clients, not a security boundary.
1763
+
1764
+ Per-class override via the `agent_allow_collscan` DSL — for small lookup tables (Roles, Config, feature flags) where a scan is cheap and expected, and forcing an index would be pointless:
1765
+
1766
+ ```ruby
1767
+ class Role < Parse::Object
1768
+ agent_allow_collscan # small lookup table, scan is fine
1769
+ end
1770
+
1771
+ class FeatureFlag < Parse::Object
1772
+ agent_allow_collscan
1773
+ end
1774
+ ```
1775
+
1776
+ The DSL takes no arguments — its presence in the class body opts that class out. Without `refuse_collscan` set globally, the per-class declaration is a no-op (no extra overhead).
1777
+
1778
+ ---
1779
+
1780
+ ## Observability
1781
+
1782
+ ### MCPRackApp logger
1783
+
1784
+ Pass a logger at construction time and `MCPRackApp` will emit:
1785
+
1786
+ - Auth failures at `warn` level: `"[Parse::Agent::MCPRackApp] Unauthorized: <ExceptionClass>"` (class name only, no message).
1787
+ - Factory errors (non-Unauthorized) at `warn` level: `"[Parse::Agent::MCPRackApp] Factory error: <ExceptionClass>"` followed by the backtrace.
1788
+
1789
+ ```ruby
1790
+ Parse::Agent.rack_app(logger: Rails.logger) do |env|
1791
+ # ... factory ...
1792
+ end
1793
+ ```
1794
+
1795
+ ### MCPDispatcher logger
1796
+
1797
+ When `MCPRackApp` has a logger, it is forwarded to `MCPDispatcher.call(logger: ...)` automatically. The dispatcher emits internal errors in the format:
1798
+
1799
+ ```
1800
+ [Parse::Agent::MCPDispatcher] <ExceptionClass>: <exception message>
1801
+ ```
1802
+
1803
+ This line goes to the logger when one is provided, or to `$stderr` via `Kernel#warn` when not. It is the only place the exception class and message are visible — they are never included in the wire response.
1804
+
1805
+ ### ActiveSupport::Notifications
1806
+
1807
+ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_call"` notification. The payload is sanitized: sensitive argument keys (`where:`, `pipeline:`, `session_token:`, `password:`, etc.) are stripped before the payload is published.
1808
+
1809
+ **Payload keys:**
1810
+
1811
+ | Key | Type | Present |
1812
+ |-----|------|---------|
1813
+ | `:tool` | Symbol | Always |
1814
+ | `:args_keys` | Array<Symbol> | Always — argument keys with SENSITIVE_LOG_KEYS removed |
1815
+ | `:auth_type` | Symbol | Always — `:session_token` or `:master_key` |
1816
+ | `:using_master_key` | Boolean | Always |
1817
+ | `:permissions` | Symbol | Always — `:readonly`, `:write`, or `:admin` |
1818
+ | `:agent_id` | Integer | Always — process-unique identifier (`Object#object_id`) for the dispatching agent instance |
1819
+ | `:agent_depth` | Integer | Always — call-tree depth; `0` for a root agent, `+1` per inherited (`parent:`) construction |
1820
+ | `:success` | Boolean | Always (set at block exit) |
1821
+ | `:result_size` | Integer | Success only — serialized byte count |
1822
+ | `:error_class` | String | Failure only — exception class name |
1823
+ | `:error_code` | Symbol | Failure only — `:security_blocked`, `:access_denied`, `:invalid_query`, `:timeout`, `:rate_limited`, `:invalid_argument`, `:parse_error`, `:internal_error`, `:permission_denied`, `:tool_filtered`, or `:cancelled` |
1824
+ | `:correlation_id` | String | Only when set — caller-supplied conversation/session identifier (see below) |
1825
+ | `:parent_agent_id` | Integer | Only on sub-agents — the `agent_id` of the parent that constructed this instance via `parent:` |
1826
+ | `:classes_only` | Array<String> | v4.3.0+ — when the agent was constructed with `classes: { only: [...] }`. Sorted canonical class-name strings (`["Post", "Topic"]`). |
1827
+ | `:classes_except` | Array<String> | v4.3.0+ — when the agent was constructed with `classes: { except: [...] }`. |
1828
+ | `:tools_only` | Array<Symbol> | v4.3.0+ — when the agent was constructed with `tools: { only: [...] }` or the Array shorthand. Sorted. |
1829
+ | `:tools_except` | Array<Symbol> | v4.3.0+ — when the agent was constructed with `tools: { except: [...] }`. |
1830
+ | `:methods_only` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { only: [...] }`. Bare names and `"Class.method"` qualified names mix. |
1831
+ | `:methods_except` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { except: [...] }`. |
1832
+ | `:filters` | Hash<String,Array<String>> | v4.4.0+ — when the agent was constructed with `filters: {...}`. Maps each filtered class name (or `"default"`) to the list of FIELD NAMES the filter constrains. Filter VALUES are intentionally NOT echoed — `filters: { Account => { user_id: "abc123" } }` would otherwise emit the user-identifying value on every audit-log line. Subscribers that need the actual constraint can call `agent.filter_for(class_name)` directly. |
1833
+ | `:denial_kind` | Symbol | v4.3.0+, AccessDenied failure path only — one of `:hidden_class` (global `agent_hidden`), `:class_filter` (per-agent `classes:` narrowing), `:field_denied` (outside `agent_fields`), or `:storage_form_field_ref` (referenced `_p_*` pointer-storage column). Lets SOC tooling distinguish operator narrowing from policy-level denials without parsing the message prose. |
1834
+
1835
+ **Conversation correlation across multi-tool sessions.** Without correlation, individual tool-call events have no link between them — a Datadog dashboard sees "user X did query_class" and "user X did get_object" as independent points, with no way to know they belong to the same LLM turn. The dispatcher threads an optional correlation id through to every notification:
1836
+
1837
+ - **Header path (recommended for hosted MCP):** the client sends `X-MCP-Session-Id: <opaque-id>` on every request in the conversation. `MCPRackApp` reads the header, sanitizes the value (charset `[A-Za-z0-9._-]`, max 128 chars — anything else is silently dropped to prevent log injection), and sets `agent.correlation_id` unless the factory has already supplied one. Notifications fired during that request carry the value as `payload[:correlation_id]`.
1838
+
1839
+ - **Factory path (for application-bound sessions):** application code that already has an internal session identifier can override the client-supplied header by setting it inside the agent factory:
1840
+
1841
+ ```ruby
1842
+ Parse::Agent.rack_app do |env|
1843
+ user = authenticate!(env)
1844
+ agent = Parse::Agent.new(session_token: user.session_token)
1845
+ agent.correlation_id = "sess-#{user.current_session.id}" # binds to YOUR record, not the client's header
1846
+ agent
1847
+ end
1848
+ ```
1849
+
1850
+ When the factory has already set the id, `MCPRackApp` does NOT overwrite it with the header value, so the application's record wins.
1851
+
1852
+ - **Programmatic path (for non-Rack callers):** set `agent.correlation_id = "..."` before calling `MCPDispatcher.call(body:, agent:, ...)` directly. The notification payload picks it up the same way.
1853
+
1854
+ When unset (no header, no factory assignment), `payload[:correlation_id]` is omitted entirely — the key does not appear in the payload hash.
1855
+
1856
+ The same `X-MCP-Session-Id` header is **required** for cooperative cancellation via `notifications/cancelled` — see the Cancellation section. Clients that thread the header through every request in a conversation get both correlated audit logs and cancellation; clients that don't lose both but keep every other MCP feature.
1857
+
1858
+ **Cancellation notification asymmetry.** A tool cancelled BEFORE it runs (via `agent.cancelled?` at the dispatcher's first checkpoint) does not fire `parse.agent.tool_call` — the tool never executed, so there is nothing to instrument. This matches how rate-limit and permission refusals are surfaced. A tool cancelled AFTER it returns (second checkpoint, "client cancelled while the tool's I/O was running") DOES fire the notification with `success: false, error_code: :cancelled`. Subscribers that count cancellations should expect the second shape; pre-run cancellations are visible to operators only via the wire response.
1859
+
1860
+ **Datadog / StatsD subscriber example:**
1861
+
1862
+ ```ruby
1863
+ ActiveSupport::Notifications.subscribe("parse.agent.tool_call") do |name, started, finished, _id, payload|
1864
+ duration_ms = ((finished - started) * 1000).round(2)
1865
+
1866
+ tags = [
1867
+ "tool:#{payload[:tool]}",
1868
+ "permissions:#{payload[:permissions]}",
1869
+ "auth_type:#{payload[:auth_type]}",
1870
+ "success:#{payload[:success]}",
1871
+ ]
1872
+
1873
+ if payload[:success]
1874
+ $statsd.histogram("parse.agent.tool.duration_ms", duration_ms, tags: tags)
1875
+ $statsd.increment("parse.agent.tool.success", tags: tags)
1876
+ if payload[:result_size]
1877
+ $statsd.histogram("parse.agent.tool.result_bytes", payload[:result_size], tags: tags)
1878
+ end
1879
+ else
1880
+ error_tags = tags + ["error_code:#{payload[:error_code]}"]
1881
+ $statsd.increment("parse.agent.tool.error", tags: error_tags)
1882
+ $statsd.histogram("parse.agent.tool.duration_ms", duration_ms, tags: error_tags)
1883
+ end
1884
+ end
1885
+ ```
1886
+
1887
+ ---
1888
+
1889
+ ## Concurrency Contract
1890
+
1891
+ ### What is thread-safe
1892
+
1893
+ - `Parse::Agent::MCPRackApp` is thread-safe. It holds no mutable state after construction; all per-request state lives in the agent instance created by the factory.
1894
+ - `Parse::Agent::Prompts` registry uses an internal mutex. It is safe to call `Prompts.register` from any thread, but practical advice is to register all prompts at boot before serving requests.
1895
+ - `Parse::Agent::Tools` registry follows the same threading model as `Prompts`.
1896
+ - Per-request agent isolation: `MCPRackApp` constructs a fresh `Parse::Agent` per request via the agent factory. These agents share only the process-wide rate limiter passed as `rate_limiter:`. Per-instance state (`@conversation_history`, `@operation_log`, token counters) is scoped to a single request and discarded when it ends. This eliminates cross-request state leakage that was present when a single long-lived agent was shared.
1897
+ - `Parse::Agent::CancellationToken` (`cancel!` / `cancelled?` / `reason`). `cancel!` is mutex-guarded so concurrent trips from the SSE disconnect path and a `notifications/cancelled` POST cannot lose a reason; the `cancelled?` poll path reads the boolean ivar directly (atomic on MRI).
1898
+ - `Parse::Agent::MCPRackApp::CancellationRegistry`. Per-app mutex-guarded `(correlation_id, request_id) → token` store. `register` runs synchronously inside `serve_sse` BEFORE the dispatcher thread spawns, so a fast-arriving `notifications/cancelled` cannot race against an empty registry.
1899
+
1900
+ ### What is NOT thread-safe
1901
+
1902
+ `Parse::Agent` itself is not safe to share across threads. The `@conversation_history`, `@operation_log`, token counters, and `@last_request`/`@last_response` attributes are not protected by a mutex. Create a new agent per request (the `MCPRackApp` factory pattern enforces this) or per thread.
1903
+
1904
+ If you are using the standalone `MCPServer`, it creates one agent per request internally via its own factory — you do not need to manage this yourself.
1905
+
1906
+ ---
1907
+
1908
+ ## Testing Your MCP Integration
1909
+
1910
+ The cleanest test approach is to call `MCPDispatcher.call` directly, bypassing HTTP entirely. Construct an agent with the permissions and state relevant to the scenario, pass a parsed body, and assert on the returned status and body.
1911
+
1912
+ ```ruby
1913
+ require "parse/agent/mcp_dispatcher"
1914
+
1915
+ # Happy path: tools/list
1916
+ agent = Parse::Agent.new(permissions: :readonly)
1917
+ body = { "jsonrpc" => "2.0", "id" => 1, "method" => "tools/list", "params" => {} }
1918
+ result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
1919
+
1920
+ assert_equal 200, result[:status]
1921
+ tools = result[:body]["result"]["tools"]
1922
+ assert tools.any? { |t| t["name"] == "query_class" }
1923
+ ```
1924
+
1925
+ ```ruby
1926
+ # Unknown method -> -32601
1927
+ body = { "jsonrpc" => "2.0", "id" => 2, "method" => "no_such_method", "params" => {} }
1928
+ result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
1929
+
1930
+ assert_equal 200, result[:status]
1931
+ assert_equal(-32601, result[:body]["error"]["code"])
1932
+ ```
1933
+
1934
+ ```ruby
1935
+ # Invalid params -> -32602
1936
+ body = { "jsonrpc" => "2.0", "id" => 3, "method" => "tools/call",
1937
+ "params" => {} } # missing "name"
1938
+ result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
1939
+
1940
+ assert_equal 200, result[:status]
1941
+ assert_equal(-32602, result[:body]["error"]["code"])
1942
+ ```
1943
+
1944
+ ```ruby
1945
+ # Test the Unauthorized path via MCPRackApp (factory-level auth test)
1946
+ require "parse/agent/mcp_rack_app"
1947
+
1948
+ app = Parse::Agent::MCPRackApp.new do |env|
1949
+ raise Parse::Agent::Unauthorized.new("no key", reason: :missing)
1950
+ end
1951
+
1952
+ env = {
1953
+ "REQUEST_METHOD" => "POST",
1954
+ "CONTENT_TYPE" => "application/json",
1955
+ "rack.input" => StringIO.new('{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'),
1956
+ }
1957
+ status, _headers, body = app.call(env)
1958
+
1959
+ assert_equal 401, status
1960
+ assert_equal(-32001, JSON.parse(body.first)["error"]["code"])
1961
+ ```
1962
+
1963
+ **Key properties of `MCPDispatcher.call`:**
1964
+ - It never raises. All exceptions are caught and returned as error envelopes.
1965
+ - The HTTP status in the returned hash is 200 for everything except `Unauthorized` (401). Even `-32603` internal errors return status 200.
1966
+ - The dispatcher is stateless; you can call it in parallel from test threads without coordination.
1967
+
1968
+ **Running the MCP test suite without Docker.** The MCP transport, dispatcher, prompts, registered tools, and streaming all run without a live Parse Server:
1969
+
1970
+ ```bash
1971
+ for f in test/lib/parse/agent/mcp_{dispatcher,rack_app,integration,streaming}_test.rb \
1972
+ test/lib/parse/agent/prompts_test.rb \
1973
+ test/lib/parse/agent/tools_{registration,get_objects,collscan}_test.rb; do
1974
+ bundle exec ruby -Ilib:test "$f"
1975
+ done
1976
+ ```
1977
+
1978
+ The end-to-end integration tests (`test/lib/parse/agent/mcp_server_e2e_test.rb`, `test/lib/parse/agent/tools_register_e2e_test.rb`, etc.) are gated on `PARSE_TEST_USE_DOCKER=true` and require the Docker Parse Server + MongoDB to be running.
1979
+
1980
+ ### Testing with MCPClient (higher-level scenarios)
1981
+
1982
+ For tests that need a real LLM in the loop, `MCPClient` is more convenient than calling `MCPDispatcher.call` directly. Stub the agent's `execute` method to return canned data, then pass a real provider key:
1983
+
1984
+ ```ruby
1985
+ require "parse/agent/mcp_client"
1986
+
1987
+ # Stub agent — no Parse Server needed.
1988
+ agent = Parse::Agent.new(permissions: :readonly)
1989
+ agent.define_singleton_method(:execute) do |tool, **_kwargs|
1990
+ case tool
1991
+ when :count_objects then { success: true, data: { count: 42, class_name: "Song" } }
1992
+ else { success: false, error: "not stubbed", error_code: :internal_error }
1993
+ end
1994
+ end
1995
+
1996
+ # Real LLM call — costs a few fractions of a cent with gpt-4o-mini.
1997
+ client = Parse::Agent::MCPClient.new(
1998
+ agent: agent,
1999
+ provider: :openai,
2000
+ api_key: ENV["LLM_API_KEY"],
2001
+ )
2002
+
2003
+ result = client.ask("How many songs are there?")
2004
+ assert_match(/42/, result.text, "LLM should mention the count")
2005
+ assert result.tool_calls.any? { |tc| tc[:name] == "count_objects" }
2006
+ ```
2007
+
2008
+ This pattern keeps test costs minimal (one LLM round-trip per assertion) while exercising the full MCPClient dispatch loop.
2009
+
2010
+ **Reference test files.** Eight integration test files under `test/lib/parse/agent/` cover real-LLM scenarios with live Parse Server data. Each is gated on `PARSE_TEST_USE_DOCKER=true` and a configured `LLM_PROVIDER`; they serve as reference patterns for writing your own:
2011
+
2012
+ | File | What it exercises |
2013
+ |------|------------------|
2014
+ | `mcp_real_llm_smoke_test.rb` | Wire-format regression check. Stubs `Agent#execute` with canned data; verifies the LLM receives `tools/list` correctly, picks the right tool, and can describe the result. No Docker required. |
2015
+ | `mcp_real_llm_docker_integration_test.rb` | Full stack: real Parse Server, real agent, real LLM. Seeds fixture records and asks a cross-class pointer-traversal question. |
2016
+ | `mcp_real_llm_schema_introspection_test.rb` | Schema discovery loop: exercises `get_all_schemas`, `get_schema`, `resources/list`, `resources/read`, and prompt rendering with a real LLM. |
2017
+ | `mcp_real_llm_tiered_complexity_test.rb` | Five tiers of increasing difficulty (count, pointer query, multi-class sort, aggregation, outlier detection). Earlier tiers catch regressions cheaply; later tiers prove analytical depth. |
2018
+ | `mcp_real_llm_temporal_analysis_test.rb` | Trend reasoning over ordered time-series data. Verifies the LLM fetches exam records in order and reasons about performance direction and variance. |
2019
+ | `mcp_real_llm_time_query_test.rb` | Date-range filtering with Parse's `__type: "Date"` wire format. Confirms the LLM constructs correct `where:` clauses rather than raw ISO strings. |
2020
+ | `mcp_real_llm_bias_detection_test.rb` | Statistical bias detection across teachers. Multi-class join + group-by reasoning to identify a grading outlier. |
2021
+ | `mcp_real_llm_access_restriction_test.rb` | Access restriction surface. Verifies `agent_hidden` and `agent_fields` actually prevent PII from reaching the LLM's wire response, even when the LLM actively tries to access hidden data. |
2022
+
2023
+ ---
2024
+
2025
+ ## Schema Tool Filters: `get_all_schemas`
2026
+
2027
+ By default `get_all_schemas` returns every Parse class the agent can see, filtered through the `agent_hidden` catalog. On deployments with hundreds of classes the response can dominate the LLM's context window even though the caller only cares about a known subset.
2028
+
2029
+ Two additive keyword arguments (v4.2.1) narrow the response without changing the security model — both apply AFTER the `agent_hidden` filter, so passing the name of a hidden class explicitly cannot probe for its existence:
2030
+
2031
+ ```ruby
2032
+ # Pull only a known subset (exact match)
2033
+ agent.execute(:get_all_schemas, names: %w[Capture Project Team])
2034
+ # => { custom: [{ name: "Capture", ... }, { name: "Project", ... }, { name: "Team", ... }], ... }
2035
+
2036
+ # Pull every class whose name starts with a prefix (case-sensitive)
2037
+ agent.execute(:get_all_schemas, prefix: "Capture")
2038
+ # => { custom: [{ name: "Capture", ... }, { name: "CaptureRevision", ... }], ... }
2039
+
2040
+ # Compose as intersection
2041
+ agent.execute(:get_all_schemas,
2042
+ names: %w[Capture CaptureRevision Project],
2043
+ prefix: "Capture")
2044
+ # => only Capture + CaptureRevision (the names that ALSO match the prefix)
2045
+ ```
2046
+
2047
+ Both arguments default to nil (no filter, current behavior). An empty `names: []` array or empty `prefix: ""` string is also a no-op. Comparison is case-sensitive for exact match and prefix.
2048
+
2049
+ ---
2050
+
2051
+ ## Aggregation Auto-`$limit`
2052
+
2053
+ `aggregate` calls that do not supply their own terminal bound have a `{ "$limit" => 200 }` stage appended automatically. The cap exists for conversational safety — without it, a chatty LLM can issue a `$group` over a million-row table, stream every row back through the dispatcher, and exhaust both the response size budget and the model's context window.
2054
+
2055
+ **When auto-`$limit` fires.** Any pipeline whose last stage is not `$limit` or `$count`. Trailing presentational stages (`$sort`, `$project`, `$addFields`, `$unset`) do **not** count as cardinality-bounding, so a pipeline ending in `$sort` still gets the auto-limit.
2056
+
2057
+ **When it does not fire.** Pipelines whose terminal stage is `$limit` (caller has expressed an explicit bound) or `$count` (the result is a single scalar). Count-style analytics work unchanged:
2058
+
2059
+ ```ruby
2060
+ agent.execute(:aggregate, class_name: "Order",
2061
+ pipeline: [{ "$match" => { "status" => "paid" } }, { "$count" => "total" }]
2062
+ )
2063
+ # => { success: true, data: { ..., results: [{ "total" => 14_823 }] } }
2064
+ # no auto_limited flag — terminal $count is a single value
2065
+ ```
2066
+
2067
+ **Response shape when limited.** The data envelope gains three extra keys, BUT only when the cap actually fired (`result_count >= AGGREGATE_DEFAULT_LIMIT`). A pipeline that lacked a terminal `$limit`/`$count` but returned fewer rows than the cap (e.g., a `$group` producing 6 buckets) does not pay the hint cost:
2068
+
2069
+ ```ruby
2070
+ {
2071
+ class_name: "Song",
2072
+ pipeline_stages: 2,
2073
+ result_count: 200,
2074
+ results: [...],
2075
+ auto_limited: true,
2076
+ auto_limit: 200,
2077
+ hint: "Pipeline auto-bounded with $limit:200 (no terminal $limit/$count supplied). " \
2078
+ "Add an explicit { \"$limit\": N } stage at the end of your pipeline to control the cap, " \
2079
+ "or call count_objects first to size the result before fetching rows."
2080
+ }
2081
+ ```
2082
+
2083
+ The hint is intentionally instructive: a well-prompted LLM will read it and either add an explicit `$limit` matching the user's intent or call `count_objects` to size the request before re-running.
2084
+
2085
+ For exports beyond 200 rows, route through the `export_data` tool (see next section), which has its own row cap (`DEFAULT_EXPORT_ROW_CAP = 1_000`, raisable to `MAX_EXPORT_ROW_CAP = 10_000`) and returns a single formatted blob rather than a row array.
2086
+
2087
+ ### Pointer compaction (`compact_pointers:`)
2088
+
2089
+ Aggregate results expose Parse pointer fields in their Parse-on-Mongo storage form: `_p_<field>: "<ClassName>$<objectId>"`. On a high-cardinality query that returns 130 rows of `_p_author: "_User$..."`, the repeated `_User$` prefix and the `_p_` column-name prefix together account for ~800 bytes of waste per call.
2090
+
2091
+ **Default-on compaction.** Every `aggregate` response is run through a compaction pass that rewrites `_p_<field>` keys to `<field>` and strips the `<ClassName>$` prefix from each value. The envelope picks up a top-level `pointer_classes:` map preserving the class information:
2092
+
2093
+ ```ruby
2094
+ agent.execute(:aggregate, class_name: "Capture",
2095
+ pipeline: [{ "$match" => { "isRemoved" => { "$ne" => true } } }, { "$project" => { "_p_author" => 1 } }]
2096
+ )
2097
+ # => {
2098
+ # class_name: "Capture",
2099
+ # result_count: 3,
2100
+ # results: [
2101
+ # { "objectId" => "row1", "author" => "alice1" },
2102
+ # { "objectId" => "row2", "author" => "bob222" },
2103
+ # { "objectId" => "row3", "author" => "carol3" },
2104
+ # ],
2105
+ # pointer_classes: { "author" => "_User" },
2106
+ # }
2107
+ ```
2108
+
2109
+ **Safety rules.** Columns where the className varies row-to-row (anomalous), and columns where both `_p_<field>` and `<field>` already coexist in the same row, are LEFT UNCOMPRESSED. The pass also runs AFTER the hidden-class redaction walker, so `_p_*` strings referencing an `agent_hidden` class are scrubbed before compaction sees them.
2110
+
2111
+ **Opting out.** Pass `compact_pointers: false` to receive raw Parse-on-Mongo shapes. Consumers that parse `<ClassName>$<objectId>` strings directly should either set the flag to `false` or migrate to consuming the bare objectId and the `pointer_classes` envelope map.
2112
+
2113
+ ```ruby
2114
+ agent.execute(:aggregate, class_name: "Capture",
2115
+ pipeline: [...],
2116
+ compact_pointers: false)
2117
+ # Response keys back to raw _p_author: "_User$alice1" form; no pointer_classes
2118
+ ```
2119
+
2120
+ ### Forward-pass field tracking on `agent_fields` (v4.4.3+)
2121
+
2122
+ The pipeline access-policy walker that enforces a class's `agent_fields` allowlist on projection-shape stages (`$project`, `$addFields`, `$set`, `$unset`, `$replaceRoot`, `$replaceWith`) now runs as a **forward pass** instead of a per-stage check against the source-class allowlist only. Each stage is validated against the effective set `(source_permitted ∪ available_so_far)`, where `available_so_far` accumulates fields introduced by upstream stages — `$group._id` and accumulator keys, `$addFields`/`$set` outputs, `$lookup.as`, `$bucket.output`, etc.
2123
+
2124
+ Schema-replacing stages (`$project`, `$group`, `$bucket`, `$bucketAuto`, `$replaceRoot`, `$replaceWith`, `$facet`, `$sortByCount`, `$count`) drop the source set; downstream stages can only reference the newly-introduced fields. This unblocks the canonical "group → filter → sort → limit" pattern that previously failed because synthetic accumulator outputs (`contributor_count`, `total_sum`) were checked against the source class's `agent_fields` allowlist and refused as `:field_denied`.
2125
+
2126
+ ```ruby
2127
+ # Capture has agent_fields :only, [:objectId, :_p_author, :status]
2128
+ # total_sum is NOT in agent_fields — but it's introduced by $group, so the
2129
+ # downstream $match/$sort can reference it without a denial.
2130
+ agent.execute(:aggregate, class_name: "Capture", pipeline: [
2131
+ { "$group" => { "_id" => "$status",
2132
+ "total_sum" => { "$sum" => "$amount" } } },
2133
+ { "$match" => { "total_sum" => { "$gt" => 100 } } },
2134
+ { "$sort" => { "total_sum" => -1 } },
2135
+ { "$limit" => 10 },
2136
+ ])
2137
+ ```
2138
+
2139
+ The `:field_denied` refusal still fires when a stage tries to read a source-class field that isn't on the allowlist AND hasn't been introduced upstream. `$facet` sub-pipelines spawn their own forward-passes with the right starting state, so each facet branch enforces the allowlist independently from the position it diverged.
2140
+
2141
+ ---
2142
+
2143
+ ## High-Level Aggregation Helpers: `group_by` / `group_by_date` / `distinct` (v4.2.1)
2144
+
2145
+ Three category-`aggregate` tools that wrap the most common `$group` pipelines so an LLM doesn't have to author the MongoDB shape by hand. Each tool resolves pointer fields, formats the result keys, pushes sort+limit into the wire pipeline, and supports a `dry_run` mode for inspection.
2146
+
2147
+ All three are `:readonly` and inherit the same access-control gates as `aggregate`: `agent_hidden` class refusal, `agent_fields` allowlist enforcement on `field:` / `value_field:` / `where:` keys, tenant scope injection, COLLSCAN preflight on the leading `$match`, and hidden-class redaction on the response.
2148
+
2149
+ ### `group_by`
2150
+
2151
+ Group records by a field and apply an aggregation:
2152
+
2153
+ ```ruby
2154
+ agent.execute(:group_by, class_name: "Capture", field: "lastAction",
2155
+ operation: "count")
2156
+ # => { success: true, data: {
2157
+ # class_name: "Capture", field: "lastAction", operation: "count",
2158
+ # group_count: 4, limit: 200,
2159
+ # groups: [
2160
+ # { key: "submitted", value: 142 },
2161
+ # { key: "approved", value: 88 },
2162
+ # { key: "rejected", value: 12 },
2163
+ # { key: "draft", value: 5 },
2164
+ # ]
2165
+ # } }
2166
+ ```
2167
+
2168
+ **Operations.** `count` (default, no `value_field` needed), `sum`, `avg` / `average`, `min`, `max`. Non-`count` operations require `value_field:`.
2169
+
2170
+ **Pointer auto-detection.** When the local Parse model declares the field as `:pointer`, the handler emits `$_p_<field>` in the pipeline and strips the `<ClassName>$` prefix from the response keys, surfacing the class once in `pointer_class:`:
2171
+
2172
+ ```ruby
2173
+ agent.execute(:group_by, class_name: "Capture", field: "author")
2174
+ # => { ..., pointer_class: "_User",
2175
+ # groups: [{ key: "abc123", value: 47 }, { key: "def456", value: 31 }, ...] }
2176
+ ```
2177
+
2178
+ Call `get_objects(class_name: "_User", ids: ["abc123", "def456"])` to resolve the keys.
2179
+
2180
+ **Array flattening.** Pass `flatten_arrays: true` to `$unwind` the field before grouping so individual array elements are counted:
2181
+
2182
+ ```ruby
2183
+ agent.execute(:group_by, class_name: "Capture", field: "tags", flatten_arrays: true)
2184
+ # Each tag is counted once per row containing it.
2185
+ ```
2186
+
2187
+ **Top-K with wire-side sort+limit.** Pass `sort:` (`value_desc` / `value_asc` / `key_desc` / `key_asc`) and `limit:` and the handler appends `$sort` + `$limit` to the pipeline so MongoDB does the truncation — the bandwidth saving matters on high-cardinality fields:
2188
+
2189
+ ```ruby
2190
+ agent.execute(:group_by, class_name: "Order", field: "customerId",
2191
+ operation: "sum", value_field: "totalCents",
2192
+ sort: "value_desc", limit: 10)
2193
+ # Top 10 spenders, sorted server-side, capped at 10 rows over the wire.
2194
+ ```
2195
+
2196
+ `limit:` defaults to 200, max 1000. The wire pipeline uses `limit + 1` so the handler can detect server-side truncation and set `truncated: true` on the envelope.
2197
+
2198
+ ### `group_by_date`
2199
+
2200
+ Bucket records by a date field at an interval and aggregate. Same operation set as `group_by`, plus `interval:` and `timezone:`:
2201
+
2202
+ ```ruby
2203
+ agent.execute(:group_by_date, class_name: "Capture",
2204
+ field: "createdAt", interval: "day",
2205
+ timezone: "America/New_York")
2206
+ # => { success: true, data: {
2207
+ # class_name: "Capture", field: "createdAt", interval: "day",
2208
+ # operation: "count", timezone: "America/New_York", sort: "key_asc",
2209
+ # groups: [
2210
+ # { key: "2024-11-24", value: 47 },
2211
+ # { key: "2024-11-25", value: 62 },
2212
+ # { key: "2024-11-26", value: 118 },
2213
+ # ]
2214
+ # } }
2215
+ ```
2216
+
2217
+ **Interval enum.** `year`, `month`, `week`, `day`, `hour`, `minute`, `second`. The handler builds the correct combination of `$year` / `$month` / `$week` / `$dayOfMonth` / `$hour` / `$minute` / `$second` operators internally — the LLM doesn't have to know MongoDB's date-expression vocabulary.
2218
+
2219
+ **Key formatting.** Output keys are pre-formatted ISO strings — `"YYYY"`, `"YYYY-MM"`, `"YYYY-Www"`, `"YYYY-MM-DD"`, `"YYYY-MM-DD HH:00"`, etc. — rather than `{year:, month:, day:}` objects.
2220
+
2221
+ **Timezone.** Optional IANA name (`"America/New_York"`) or fixed offset (`"+05:00"`). When supplied, each date operator is wrapped in the `{date:, timezone:}` form Mongo expects. Default is UTC.
2222
+
2223
+ **Default sort.** `key_asc` (chronological). Override with `sort:` if you want value-based ordering.
2224
+
2225
+ ### `distinct`
2226
+
2227
+ Return the distinct values of a field, optionally filtered:
2228
+
2229
+ ```ruby
2230
+ agent.execute(:distinct, class_name: "Asset", field: "mediaFormat",
2231
+ where: { "isRemoved" => { "$ne" => true } })
2232
+ # => { success: true, data: {
2233
+ # class_name: "Asset", field: "mediaFormat",
2234
+ # count: 3, values: ["video", "image", "audio"]
2235
+ # } }
2236
+ ```
2237
+
2238
+ **Pointer fields.** When the field is a pointer, the values come back stripped of the `<ClassName>$` prefix and `pointer_class:` carries the class:
2239
+
2240
+ ```ruby
2241
+ agent.execute(:distinct, class_name: "Asset", field: "authorTeam")
2242
+ # => { ..., pointer_class: "Team",
2243
+ # values: ["alphaTeam", "betaTeam", "gammaTeam"] }
2244
+ ```
2245
+
2246
+ **Sort.** `asc` or `desc` (alphabetic/numeric on the values). Wire-side `$sort {_id: 1|-1}` is emitted; the response is in the database-sorted order.
2247
+
2248
+ **Limit.** Defaults to 1000, max 5000 (distinct results legitimately span more values than grouped counts).
2249
+
2250
+ ### `dry_run: true` — inspect the pipeline without executing
2251
+
2252
+ All three tools accept `dry_run: true`, which returns the constructed MongoDB pipeline plus the resolved parameters and skips the actual aggregate call. Useful for:
2253
+
2254
+ - Inspecting how the tool resolved a pointer field (was the `_p_` prefix added?), a date interval, or a timezone before paying the round-trip.
2255
+ - Composing multi-step analyses where `group_by` is one stage of a larger pipeline you intend to assemble and run via `aggregate`.
2256
+ - Letting a power-user LLM mutate the pipeline (add a `$lookup`, change the `$sort`) before re-issuing through `aggregate`.
2257
+
2258
+ ```ruby
2259
+ agent.execute(:group_by, class_name: "Capture", field: "author",
2260
+ operation: "sum", value_field: "elapsedMs",
2261
+ sort: "value_desc", limit: 10, dry_run: true)
2262
+ # => { success: true, data: {
2263
+ # dry_run: true,
2264
+ # class_name: "Capture",
2265
+ # parameters: { field: "author", operation: "sum", value_field: "elapsedMs",
2266
+ # sort: "value_desc", limit: 10 },
2267
+ # pipeline: [
2268
+ # { "$group" => { "_id" => "$_p_author", "value" => { "$sum" => "$elapsedMs" } } },
2269
+ # { "$sort" => { "value" => -1 } },
2270
+ # { "$limit" => 11 }
2271
+ # ],
2272
+ # hint: "dry_run mode — the pipeline above was constructed but NOT executed. " \
2273
+ # "Re-issue this call with dry_run: false to run it, or pass the pipeline " \
2274
+ # "to the aggregate tool (modified as needed) for full pipeline control."
2275
+ # } }
2276
+ ```
2277
+
2278
+ **Security gates still apply.** `agent_hidden`, `agent_fields` allowlist enforcement, field-shape validation, tenant scope, and operation enum validation all run BEFORE the dry-run short-circuit. `dry_run` is a no-execute mode, not an authorization bypass — a request that would have been refused returns the same refusal envelope.
2279
+
2280
+ ### Why these wrap `aggregate` instead of being the same tool
2281
+
2282
+ The `aggregate` tool stays general-purpose and accepts any (validated) MongoDB pipeline. These three are higher-leverage:
2283
+
2284
+ - **Naming reduces planning steps.** An LLM that sees `group_by` and `distinct` in `tools/list` doesn't have to derive the pipeline shape from "I need a count grouped by status."
2285
+ - **Hidden behaviors are encoded once.** Pointer `_p_` prefix detection, date-bucket expression construction, ISO date-key formatting, top-K wire-pipeline assembly — every one of those is a common failure mode if the LLM hand-authors the equivalent `aggregate` call.
2286
+ - **Top-K is correct by default.** `aggregate`'s auto-`$limit` truncates BEFORE sort if the LLM forgets the terminal `$sort` + `$limit` ordering. These tools place the bound after the accumulator, so `sort: "value_desc", limit: 10` is always a real top-10 query.
2287
+
2288
+ Use `aggregate` when you need `$lookup`, `$facet`, `$bucket`, multi-stage transformations, or anything else outside the group/distinct envelope. Use these helpers for the 80% case.
2289
+
2290
+ ---
2291
+
2292
+ ## `export_data` — CSV / Markdown / Text Table Export
2293
+
2294
+ `export_data` produces a single formatted text blob (CSV, GitHub-flavored Markdown table, or fixed-width ASCII table) from either a `query_class`-style read or an `aggregate`-style pipeline. It exists so that an LLM can hand the user a copy-pasteable artifact (e.g., "give me a CSV of all sophomores enrolled in Algebra II") without that data being streamed row-by-row into the model's context window — the formatted output ships back in a single tool result and is bounded by `MAX_TOOL_RESPONSE_BYTES` (4 MiB) at the dispatcher.
2295
+
2296
+ The tool is included in the `:readonly` permission set.
2297
+
2298
+ ### When to use `query_class(format:)` instead
2299
+
2300
+ For the common case — a CSV/Markdown/text-table dump of a simple class query with no column aliasing — `query_class` accepts a `format:` keyword argument (v4.2.1) that produces the same envelope without requiring a separate tool:
2301
+
2302
+ ```ruby
2303
+ agent.execute(:query_class, class_name: "Song",
2304
+ where: { artist: "Radiohead" },
2305
+ limit: 50,
2306
+ format: "csv")
2307
+ # => { success: true, data: {
2308
+ # class_name: "Song",
2309
+ # format: "csv",
2310
+ # headers: ["objectId", "title", "artist", "plays"],
2311
+ # row_count: 50,
2312
+ # output: "objectId,title,artist,plays\nabc,...\n..."
2313
+ # } }
2314
+ ```
2315
+
2316
+ `format:` accepts `"json"` (default — the structured row envelope), `"csv"`, `"markdown"`, or `"table"`. Columns are inferred from the first row's keys (Parse-internal envelope keys skipped). The non-json paths use the same formatters as `export_data` but skip column aliasing, dotted-path extraction, and custom row caps.
2317
+
2318
+ Reach for `export_data` (instead of `query_class(format:)`) when you need:
2319
+
2320
+ - **Column aliasing** — `columns: [{ "subject.name" => "Subject Name" }]` to rename or extract nested values.
2321
+ - **Aggregate-mode formatting** — passing a `pipeline:` instead of `where:` / `keys:`.
2322
+ - **A larger row cap** — `query_class` is bounded by the standard `MAX_LIMIT = 1000`; `export_data` honors `row_cap:` up to `MAX_EXPORT_ROW_CAP = 10000`.
2323
+
2324
+ Both paths return the same `{class_name:, format:, headers:, row_count:, output:}` envelope shape.
2325
+
2326
+ ### Modes
2327
+
2328
+ | Mode | Triggered by | Underlying call | Inherited gates |
2329
+ |------|--------------|------------------|------------------|
2330
+ | Query | `where:`, `keys:`, `include:`, `order:`, `limit:`, `skip:` (no `pipeline:`) | `client.find_objects` | `agent_hidden`, `agent_fields` allowlist intersection, include-path resolver, post-fetch redactor |
2331
+ | Aggregate | `pipeline:` supplied | `client.aggregate_pipeline` | pipeline access policy walker (`$lookup` into hidden classes, field-level allowlist on `$project` / `$addFields`), post-fetch redactor |
2332
+
2333
+ When `pipeline:` is supplied, the query-mode args (`where:`, `keys:`, `include:`, `order:`, `limit:`, `skip:`) are ignored — pipeline mode takes priority.
2334
+
2335
+ Every access-control gate that protects `query_class` and `aggregate` also protects the corresponding `export_data` path — there is no `export_data`-specific bypass. Aggregate-mode exports run through the same `ensure_aggregate_terminal_limit` injection as `aggregate`, but the export-side row cap takes precedence.
2336
+
2337
+ ### Output formats
2338
+
2339
+ `format:` accepts `"csv"` (default), `"markdown"`, or `"table"`. Any other value is rejected with `error_code: :invalid_argument`.
2340
+
2341
+ ```ruby
2342
+ agent.execute(:export_data, class_name: "Student", limit: 50)
2343
+ # => { success: true, data: { format: "csv", row_count: 50, output: "name,grade,...\nAda,11,...\n..." } }
2344
+
2345
+ agent.execute(:export_data, class_name: "Student", limit: 50, format: "markdown")
2346
+ # | name | grade |
2347
+ # | --- | --- |
2348
+ # | Ada | 11 |
2349
+
2350
+ agent.execute(:export_data, class_name: "Student", limit: 50, format: "table")
2351
+ # +------+-------+
2352
+ # | name | grade |
2353
+ # +------+-------+
2354
+ # | Ada | 11 |
2355
+ # +------+-------+
2356
+ ```
2357
+
2358
+ ### Columns and aliasing
2359
+
2360
+ `columns:` is an ordered array of specs. Each spec is either a String (used as both field path and header) or a single-key Hash `{field => header}` for aliasing. Dotted paths walk into include-resolved pointer fields. When `columns:` is nil, headers are inferred from the first row's keys with Parse-internal fields (`__type`, `className`, `ACL`) excluded.
2361
+
2362
+ ```ruby
2363
+ agent.execute(:export_data,
2364
+ class_name: "Student",
2365
+ include: ["subject"],
2366
+ columns: [
2367
+ "name", # field=name, header="name"
2368
+ { "grade" => "Year" }, # field=grade, header="Year"
2369
+ { "subject.name" => "Subject" } # field=subject.name, header="Subject"
2370
+ ],
2371
+ format: "csv"
2372
+ )
2373
+ ```
2374
+
2375
+ Validation: each Hash must have exactly one key; any other value (including bare integers or multi-key hashes) is rejected with `:invalid_argument`.
2376
+
2377
+ ### Row cap
2378
+
2379
+ | Knob | Value | Purpose |
2380
+ |------|-------|---------|
2381
+ | `DEFAULT_EXPORT_ROW_CAP` | `1_000` | Default when `row_cap:` is omitted. Sized so a 10-15 column CSV stays under ~80 KB / ~20k tokens. |
2382
+ | `MAX_EXPORT_ROW_CAP` | `10_000` | Hard ceiling regardless of `row_cap:` override. The dispatcher's 4 MiB response cap may still trim a wide-schema export below this. |
2383
+
2384
+ When the fetched result exceeds the effective cap, the tool emits the first `effective_cap` rows and sets `data[:truncated] = true`, `data[:available_rows]`, `data[:row_cap]`, and an instructional `data[:hint]` telling the caller to narrow with `where:` / `pipeline` filters or set `row_cap:` explicitly. `data[:row_count]` reflects what was actually emitted, not the upstream cardinality.
2385
+
2386
+ For artifacts larger than `MAX_EXPORT_ROW_CAP`, run the operator-side `rake "mcp:tool[export_data,...]"` task, which inherits no LLM context budget, or query the database directly from application code.
2387
+
2388
+ ---
2389
+
2390
+ ## Aggregation Results: `.raw` vs `.results`
2391
+
2392
+ When using the `aggregate` tool with a `$group` pipeline stage, the rows returned by MongoDB are not full Parse objects — they have no `_created_at` or `_updated_at` fields. v4.1.0 fixes `Aggregation#results` to distinguish these cases by checking for those timestamp fields on each raw document.
2393
+
2394
+ - **`.results`** on a `$group` pipeline: returns an array of `Parse::AggregationResult` objects (not `Parse::Object`). These are value objects with hash-like field access. They do not have `objectId`, `createdAt`, or `updatedAt`.
2395
+ - **`.results`** on a pipeline that preserves full Parse documents (e.g., `$match` only): returns typed `Parse::Object` instances.
2396
+ - **`.raw`**: returns the raw array of hashes from the aggregation response. Always works regardless of pipeline shape; prefer this in custom tool handlers when you need simple hash access.
2397
+
2398
+ Custom tool handlers that aggregate with `$group` should prefer `.raw` for straightforward hash access, or use `.results` with the awareness that the objects are `Parse::AggregationResult`, not `Parse::Object`, and therefore lack standard Parse object methods.
2399
+
2400
+ **`Parse::AggregationResult` interface.** Value object returned for non-document aggregation rows. Reading the source isn't required — the contract is small:
2401
+
2402
+ ```ruby
2403
+ row = result[:data][:results].first
2404
+ # Original field names (string keys) — works for any pipeline output.
2405
+ row["_id"] # the $group key value
2406
+ row["count"]
2407
+ # Snake-cased symbol access — useful when the pipeline produces camelCase field names.
2408
+ row[:total_plays] # if the projection was { "totalPlays" => ... }
2409
+ # Method-style access via method_missing — same snake-cased keys.
2410
+ row.total_plays
2411
+ # Convenience.
2412
+ row.to_h # Hash of snake-cased symbol keys to values
2413
+ row.raw # Hash of original keys as returned by MongoDB
2414
+ ```
2415
+
2416
+ What it does **not** have: `objectId`, `createdAt`, `updatedAt`, `save`, `destroy`, `acl`, or any Parse persistence methods. Treating one as a `Parse::Object` will raise `NoMethodError`. If a handler needs to differentiate at runtime, check `is_a?(Parse::AggregationResult)`.
2417
+
2418
+ ```ruby
2419
+ # In a custom tool handler:
2420
+ result = agent.execute(:aggregate,
2421
+ class_name: "Song",
2422
+ pipeline: [
2423
+ { "$group" => { "_id" => "$genre", "count" => { "$sum" => 1 } } },
2424
+ { "$sort" => { "count" => -1 } },
2425
+ ]
2426
+ )
2427
+
2428
+ if result[:success]
2429
+ rows = result[:data][:results] # Array of hashes: [{"_id"=>"Rock","count"=>4200}, ...]
2430
+ rows.each { |row| puts "#{row["_id"]}: #{row["count"]}" }
2431
+ end
2432
+ ```
2433
+
2434
+ ---
2435
+
2436
+ ## Security Notes
2437
+
2438
+ **Static-token comparisons must use secure compare.** String equality (`==`) is vulnerable to timing attacks. Use `ActiveSupport::SecurityUtils.secure_compare` for any comparison of secrets:
2439
+
2440
+ ```ruby
2441
+ unless ActiveSupport::SecurityUtils.secure_compare(ENV["EXPECTED_KEY"], provided_key)
2442
+ raise Parse::Agent::Unauthorized.new("bad key", reason: :bad_api_key)
2443
+ end
2444
+ ```
2445
+
2446
+ **Only `Parse::Agent::Unauthorized` should escape the agent factory.** Any other exception from the factory becomes a 500 response with `"Internal error"` as the wire message. Rescue and re-raise all anticipated failures as `Unauthorized`. Do not let exception messages from third-party libraries reach the caller — they may contain user data or internal stack details.
2447
+
2448
+ **The dispatcher sanitizes internal errors.** `MCPDispatcher` rescues `StandardError` and returns a `-32603` envelope containing the literal string `"Internal error"` — no class name, no message, no backtrace. The exception class and message are emitted to the operator's logger (or `$stderr`). This applies to handler-level errors; factory-level errors are handled by `MCPRackApp` before the dispatcher is called.
2449
+
2450
+ **`:admin` permissions over HTTP.** `:admin` enables `delete_object`, `create_class`, and `delete_class`. Do not grant `:admin` from an HTTP-exposed factory without explicit intent. Treat it as equivalent to granting master-key access to any bearer of a valid token.
2451
+
2452
+ **Body size and nesting limits.** `MCPRackApp` rejects bodies larger than 1 MB and JSON with nesting depth greater than 20. The size limit can be adjusted with `max_body_size:`:
2453
+
2454
+ ```ruby
2455
+ Parse::Agent.rack_app(max_body_size: 512_000) { |env| ... }
2456
+ ```
2457
+
2458
+ **Content-Length and Transfer-Encoding enforcement (MCPServer).** The standalone `MCPServer` rejects requests with `Transfer-Encoding: chunked` (411 Length Required), requests with a missing `Content-Length` header (411), and requests where `Content-Length` exceeds the body size limit (413). These checks run before the body is read, preventing WEBrick from dechunking an unbounded stream.
2459
+
2460
+ **Resource URIs are validated.** `resources/read` validates the URI against `parse://<ClassName>/<kind>` before calling any tool. Class names must match Parse's identifier pattern (`[A-Za-z_][A-Za-z0-9_]*`). This prevents injection of arbitrary class names through the resource layer.
2461
+
2462
+ **The `logger:` kwarg on `MCPRackApp`.** When a logger is provided, auth failures are logged with the exception class name only (not the message or the `reason` attribute). Factory errors (non-Unauthorized) are logged with class name and full backtrace. Production deployments should pass a logger so failures are observable without exposing internals to clients:
2463
+
2464
+ ```ruby
2465
+ Parse::Agent.rack_app(logger: Rails.logger) { |env| ... }
2466
+ ```
2467
+
2468
+ **Sub-agent auth-scope inheritance and permissions clamp (v4.2).** When a tool handler constructs a sub-agent with `Parse::Agent.new(parent: agent, ...)`, the sub inherits `session_token` and `tenant_id` from the parent unless explicitly overridden. Without this inheritance, a session-token parent would silently produce a master-key sub-agent — the constructor default `session_token: nil` resolves to master-key mode — escalating privilege through the very kwarg meant to close sub-agent footguns. Explicit overrides still work (`Parse::Agent.new(parent: agent, session_token: nil)` produces a master-key sub if that is genuinely what the handler wants), but the default is fail-safe inheritance. `permissions:` is NOT inherited and defaults to `:readonly`, but the constructor enforces a clamp: an explicit `permissions:` override on a sub-agent is accepted only if `≤ parent.permissions`, otherwise `ArgumentError` is raised at construction. The clamp is the structural guarantee that a delegation chain cannot escape the parent's tier through sub-agent construction. See [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42) for the full inheritance table.
2469
+
2470
+ **Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction (or defers to per-call REST if the server is unreachable). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role membership via `Parse::Role.all_for_user` — no token round-trip, the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping — no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
2471
+
2472
+ **ACL composition on the mongo-direct aggregate path (v4.4.0).** When `aggregate` routes through `Parse::MongoDB.aggregate` (the default when `Parse::MongoDB.enabled?` is true), the agent layer derives the auth posture from the agent instance and forwards it to ACLScope — session-tokened / acl_user / acl_role agents get the same row-level `_rperm` `$match` injection regardless of identity mode; master-key agents pass `master: true` (the agent's class/field/tenant/canonical-filter gates are the security boundary for that posture). The posture is built in `Parse::Agent#acl_scope_kwargs`, not from tool-call JSON arguments; LLM-supplied `master:`, `session_token:`, `acl_user:`, or `acl_role:` kwargs are silently swallowed by the tool signature's `**_kwargs` catchall and never reach `Parse::MongoDB.aggregate`. An LLM cannot escalate from a scoped posture to master-key by injecting `master: true` into the tool arguments.
2473
+
2474
+ **REST aggregate is master-key-only — auto-promoted to mongo-direct for any scoped agent (v4.4.0).** Parse Server's REST `/aggregate` endpoint does NOT enforce ACL or CLP — it runs master-key-only. The agent's `aggregate` tool therefore auto-promotes `mongo_direct: false` to `mongo_direct: true` whenever the agent carries any scope (session_token / acl_user / acl_role); only the SDK's mongo-direct path applies the `_rperm` `$match` injection via ACLScope and the CLP gates via CLPScope. Master-key agents keep the REST route because they've already opted out of ACL enforcement at construction. `group_by` / `group_by_date` / `distinct` / `export_data` follow the same auto-promotion rule because they all flow through `Parse::MongoDB.aggregate` on the direct path.
2475
+
2476
+ **REST find / get / count still go through Parse Server (mostly) (v4.4.0).** Parse Server's REST `/classes/<Class>` and `/classes/<Class>/<id>` endpoints DO enforce CLP and ACL natively when a session_token is forwarded. So `query_class`, `get_object`, `get_objects`, `get_sample_objects`, and `count_objects` keep the REST path for session_token / master-key agents. The auto-route to `Parse::Query#results_direct` (mongo-direct) fires ONLY under `acl_user:` / `acl_role:` scope — REST has no "act as user-pointer" or "act as role" affordance, so REST cannot honor those scopes at all. `Parse::Agent#request_opts` raises `Parse::ACLScope::ACLRequired` for those scopes as a fail-closed defense against any tool that bypasses the auto-route.
2477
+
2478
+ **Class-Level Permissions and Protected Fields on mongo-direct (v4.4.0).** Because Parse Server's REST aggregate runs master-key-only, the SDK is the only enforcement layer for CLP / `protectedFields` on the mongo-direct path. `Parse::CLPScope` mirrors `Parse::ACLScope`'s architecture: scope-aware module with cached `_SCHEMA` lookups (`cache_ttl = 3600` default, `Parse::CLPScope.invalidate!(class_name)` for explicit busting), `permits?` boundary check per operation, post-fetch `pointerFields` row-filtering, and `protectedFields` strip walker. `Parse::MongoDB.aggregate` runs both layers automatically. The agent layer's `assert_class_accessible!` accepts an `op:` kwarg (`:find` / `:count` / `:get` / `:create` / `:update` / `:delete`) so every built-in tool refuses CLP-denied operations at the boundary BEFORE pipeline construction. `call_method` maps the target method's permission tier to a CLP op (`:readonly` → `:find`, `:write` → `:update`, `:admin` → `:delete`) and refuses if the class's CLP doesn't grant that op to the agent's scope. `$lookup` / `$graphLookup` / `$unionWith` targets are also CLP-gated through the existing pipeline access policy. The Parse Server REST route (`mongo_direct: false`, session_token agents on find/get/count) continues to enforce CLP through Parse Server itself, unchanged.
2479
+
2480
+ **Atlas Search per-tool refusal relaxed (v4.4.0).** `atlas_text_search` and `atlas_autocomplete` no longer require `session_token:` or `master_atlas: true` at the per-tool boundary. The SDK now enforces per-row ACL on these calls via `Parse::ACLScope`'s `_rperm` `$match` regardless of identity mode (session_token / acl_user / acl_role / master-key), so the operator's master-key construction is sufficient signal — the master-key banner at construction is the security-posture indicator. `atlas_faceted_search` retains its `master_atlas: true` requirement because `$searchMeta` bucket counts cannot be ACL-filtered at the `_rperm` level.
2481
+
2482
+ The corollary: a session-tokened or `acl_user`-scoped agent calling `aggregate` will see only rows whose `_rperm` permits the requesting user (including roles inherited via `Parse::Role.all_for_user`); `acl_role` agents see rows readable by the role + its parent roles. `protectedFields` defined in the class's CLP are stripped from every returned row and every embedded `$lookup`-included sub-document. Pre-4.4.0, mongo-direct aggregate ran with admin Mongo credentials and no SDK-side enforcement — a real CLP/ACL gap that this release closes.
2483
+
2484
+ ---
2485
+
2486
+ ## `agent_hidden` — Per-Class Agent-Surface Denial
2487
+
2488
+ `agent_hidden` is a model-level DSL declaration that blocks all agent access to a Parse class. It is the strongest access-restriction primitive in the DSL — stronger than `agent_fields` (which trims visible fields) and unrelated to `agent_visible` (which is an opt-in filter for the relation diagram, not an access restriction).
2489
+
2490
+ ### Declaring a hidden class
2491
+
2492
+ ```ruby
2493
+ class StudentSSN < Parse::Object
2494
+ parse_class "StudentSSN"
2495
+ property :student_name, :string
2496
+ property :ssn, :string
2497
+ agent_hidden
2498
+ end
2499
+ ```
2500
+
2501
+ `agent_hidden` takes no arguments by default. Its presence in the class body registers the class in a process-wide hidden registry.
2502
+
2503
+ ### `agent_hidden(except: :master_key)` — relaxed scope (v4.3.0)
2504
+
2505
+ Marks a class hidden from session-bound agents (user-facing MCP, per-user tooling) while permitting master-key agents (internal admin / dev MCP / customer-support bots) to address it:
2506
+
2507
+ ```ruby
2508
+ class Parse::Session
2509
+ # Hidden from session-bound agents; reachable by master-key agents.
2510
+ # Default in v4.3.0+; an application that explicitly needs session_token
2511
+ # access can re-declare or call agent_unhidden.
2512
+ agent_hidden(except: :master_key)
2513
+ end
2514
+ ```
2515
+
2516
+ Use this for collections where a debugging tool legitimately needs read access but no per-user agent ever should — `_Session` is the canonical case. The field-level `INTERNAL_FIELDS_DENYLIST` floor (sessionToken, _hashed_password, _auth_data, _rperm/_wperm) still strips credential columns from every response regardless, so even a master-key superadmin tool that reaches `_Session` cannot exfiltrate active tokens.
2517
+
2518
+ Re-declaring `agent_hidden` with a different `except:` scope is last-write-wins: an application that wants to relax parse-stack's default strict-hidden state on `_Session` can call `Parse::Session.agent_hidden(except: :master_key)` at boot to override the default. The composition order at dispatch:
2519
+
2520
+ 1. Global hidden? → if yes and `except:` is nil, refuse all agents.
2521
+ 2. Global hidden? + `except: :master_key` → permit only when `agent.session_token` is empty.
2522
+ 3. Per-agent `classes:` allowlist (v4.3.0 — see the `Parse::Agent.new(classes:)` section above) → can further narrow but cannot re-enable.
2523
+
2524
+ ### `agent_unhidden` — reverse the default (v4.3.0)
2525
+
2526
+ Cancels a prior `agent_hidden` declaration so the class is reachable by every agent surface again. The intended use is opt-in restoration of a class that parse-stack hides by default — e.g. an application that genuinely uses `_Product` (vestigial Parse iOS IAP feature, hidden by default in v4.3.0+) can opt back in at boot:
2527
+
2528
+ ```ruby
2529
+ # config/initializers/parse_stack.rb
2530
+ Parse::Product.agent_unhidden
2531
+ ```
2532
+
2533
+ The call emits a one-line `[Parse::Agent:SECURITY]` audit banner identifying the unhidden class and reminding the operator that master-key agents bypass per-row ACL/CLP enforcement, so per-class `agent_fields` / `agent_canonical_filter` / `tenant_id` are the only remaining access boundary. Silenceable via the same `Parse::Agent.suppress_master_key_warning = true` flag that silences the master-key construction banner.
2534
+
2535
+ Returns `true` only when a previous hidden state was actually cleared, `false` for a no-op call on a never-hidden class (Hash#delete? semantics); no banner emits on a no-op so the warning isn't trained-away by repetition.
2536
+
2537
+ ### Built-in hidden classes (v4.3.0)
2538
+
2539
+ Four parse-stack core classes are now `agent_hidden` by default:
2540
+
2541
+ | Class | Why | How to restore |
2542
+ |-------|-----|----------------|
2543
+ | `Parse::Product` | The `_Product` collection is a vestigial Parse iOS in-app-purchase feature that almost no modern application uses. Exposing it just adds noise to schema listings and tool-selection prompts. | `Parse::Product.agent_unhidden` at boot. |
2544
+ | `Parse::Session` | `_Session` holds active session tokens; surfacing it under the master-key default risks credential leakage. The `sessionToken` column is also on the `INTERNAL_FIELDS_DENYLIST` floor so it's stripped from every response even when the class is reachable. | `Parse::Session.agent_unhidden` for full restoration, or `Parse::Session.agent_hidden(except: :master_key)` to keep it off the user-facing surface while permitting internal admin tooling. |
2545
+ | `Parse::JobStatus` | `_JobStatus` carries operational signal — registered job names, status messages, error traces, scheduler parameters. An agent enumerating these can fingerprint the server's internals and surface error detail an end-user-facing tool shouldn't reveal. | `Parse::JobStatus.agent_unhidden` for full restoration, or `Parse::JobStatus.agent_hidden(except: :master_key)` for internal-tooling-only access. |
2546
+ | `Parse::JobSchedule` | `_JobSchedule` rows are scheduler configuration; the `params` column can carry credentials or destination configuration written by external scheduling tooling. | `Parse::JobSchedule.agent_unhidden` for full restoration, or `Parse::JobSchedule.agent_hidden(except: :master_key)` for internal-tooling-only access. |
2547
+
2548
+ ### What changes when a class is hidden
2549
+
2550
+ **Catalog:** The class disappears from `get_all_schemas`, `tools/list`, and `resources/list` responses. MCP clients that enumerate the schema will not see it.
2551
+
2552
+ **Tool calls:** Every built-in tool that accepts a `class_name` argument (`query_class`, `count_objects`, `get_object`, `get_objects`, `get_sample_objects`, `aggregate`, `explain_query`, `get_schema`) returns a structured denial immediately, before any request reaches Parse Server:
2553
+
2554
+ ```ruby
2555
+ {
2556
+ success: false,
2557
+ error: "Class 'StudentSSN' is not accessible to this agent",
2558
+ error_code: :access_denied,
2559
+ }
2560
+ ```
2561
+
2562
+ **`ActiveSupport::Notifications`:** The `parse.agent.tool_call` event is still fired for denied calls, with `success: false`, `error_code: :access_denied`, and `error_class: "Parse::Agent::AccessDenied"`. This lets your Datadog / Splunk subscriber detect probing attempts without parsing wire responses.
2563
+
2564
+ **Database:** The records still exist in MongoDB. Direct application code (`Parse::Object#query`, `Parse::MongoDB.*`) is completely unaffected. `agent_hidden` is an agent-surface denial, not a database-level ACL.
2565
+
2566
+ ### Relationship with `agent_fields`
2567
+
2568
+ `agent_fields` and `agent_hidden` solve different problems:
2569
+
2570
+ | DSL | Effect | When to use |
2571
+ |-----|--------|-------------|
2572
+ | `agent_fields :name, :status` | Trims visible fields; class remains queryable | Expose safe analytics columns; hide PII columns in a queryable class |
2573
+ | `agent_hidden` | Removes class from all agent surfaces entirely | Entire class is sensitive (SSNs, billing, password tokens) |
2574
+
2575
+ ### Security caveats
2576
+
2577
+ **Registered tool handlers are trusted code.** Custom tools registered via `Parse::Agent::Tools.register` receive the raw `Parse::Agent` instance and can call `Parse::Object#query`, `Parse::MongoDB.find`, or `.results_direct` directly in their handler body. The `agent_hidden` denial does not propagate into handler bodies — those handlers are first-party code you control. This is by design. See the "Registered handlers are trusted code" callout in the Custom Tools section.
2578
+
2579
+ **Hidden vs. non-existent — the error-code oracle.** The `:access_denied` error code is distinct from the generic runtime error returned when a class simply does not exist. An authenticated caller who can enumerate class names can therefore distinguish "hidden" from "doesn't exist" by comparing `error_code` values. If you need to conceal even the existence of a class, the current implementation does not provide that guarantee — the access denial message includes the class name supplied by the caller.
2580
+
2581
+ **Pointer-include resolution is gated by a two-layer defense.** Earlier releases had a known gap where an `include: ["hidden_class"]` on a non-hidden parent could exfiltrate a hidden child via the server-resolved pointer. As of v4.1.0 this is closed by two complementary mechanisms:
2582
+
2583
+ 1. **Include-path resolver (request-time).** Every tool that accepts `include:` (`query_class`, `get_object`, `get_objects`, `export_data`) walks each dotted path through the parent class's `belongs_to` / `has_one` references and refuses the call with `:access_denied` if the terminal class is hidden. Both camelCase and snake_case segment names are resolved. `get_sample_objects` does not accept `include:` and relies on the redactor alone.
2584
+ 2. **Post-fetch redactor (response-time, defense in depth).** The result set from every read tool — including aggregate responses, `$lookup` outputs, and free-form `include:` names the resolver couldn't bind — is walked and any nested object whose `className` matches a hidden class is replaced with a placeholder `{ "className" => "<Class>", "__redacted" => true }`. The hidden record's fields never leave the dispatcher.
2585
+
2586
+ The walker also matches Parse-on-Mongo pointer-storage strings (`"<ClassName>$<objectId>"`) under ANY containing key, not only under `_p_*` storage-column keys. A raw aggregate pipeline that re-projects the storage column under an arbitrary output name — `{ "$project" => { "leak" => "$_p_secret" } }` or `{ "$group" => { "_id" => "$_p_secret" } }` — produces rows of the form `{ "leak" => "HiddenClass$abc123" }` where the containing key is not `_p_*`. The walker now scrubs every String value whose extracted class name is in `MetadataRegistry.hidden_class_names`, so hidden objectIds cannot be exfiltrated through a rebound key. The same scrub fires on `group_by` and `distinct` `$group._id` values via `redact_hidden_pointer_groups!` before the result reaches `ResultFormatter`.
2587
+
2588
+ If you have application-level handlers that should bypass redaction, query through `Parse::Object#query` or `Parse::MongoDB.find` directly — both guards are scoped to the agent-tool boundary, not the application data layer.
2589
+
2590
+ ### Usage example with allowlist complement
2591
+
2592
+ A common pattern is to pair a fully hidden SSN table with a sibling student table that exposes only safe analytics fields:
2593
+
2594
+ ```ruby
2595
+ # Fully hidden — no agent surface at all
2596
+ class StudentSSN < Parse::Object
2597
+ parse_class "StudentSSN"
2598
+ property :student_name, :string
2599
+ property :ssn, :string
2600
+ agent_hidden
2601
+ end
2602
+
2603
+ # Queryable, but only analytics-safe fields are visible
2604
+ class Student < Parse::Object
2605
+ property :name, :string
2606
+ property :enrolled_year, :integer
2607
+ property :subject, :string
2608
+ property :email, :string # hidden by allowlist
2609
+ agent_fields :name, :enrolled_year, :subject
2610
+ end
2611
+ ```
2612
+
2613
+ With this setup, `get_all_schemas` returns `Student` (with `email` stripped) and omits `StudentSSN` entirely. `count_objects("StudentSSN")` returns `error_code: :access_denied`. `query_class("Student")` returns objects projected to `name`, `enrolled_year`, and `subject`.
2614
+
2615
+ ---
2616
+
2617
+ ## `agent_large_fields` — Schema-Level Size Hints
2618
+
2619
+ `agent_large_fields` is a model-level declaration that flags fields known to carry large payloads (long text bodies, embedded documents, base64-encoded blobs, raw HTML, JSON blobs). The hint surfaces through `get_schema` as `large_field: true` on each declared field, so an LLM client can project the field away with `keys:` in its FIRST `query_class` call rather than discovering the size by hitting the 4 MiB response cap and having to retry.
2620
+
2621
+ ### Declaration
2622
+
2623
+ ```ruby
2624
+ class Article < Parse::Object
2625
+ parse_class "Article"
2626
+ property :title, :string
2627
+ property :body, :string
2628
+ property :raw_html, :string
2629
+ property :author, :pointer, class_name: "_User"
2630
+ agent_large_fields :body, :raw_html
2631
+ end
2632
+ ```
2633
+
2634
+ `agent_large_fields` takes a splat of field names (symbols or strings). The declaration is class-level metadata; it does not affect storage, queries, or any non-agent code path.
2635
+
2636
+ ### What changes in `get_schema`
2637
+
2638
+ The flagged fields gain a `large_field: true` key in the field info object:
2639
+
2640
+ ```ruby
2641
+ {
2642
+ name: "body",
2643
+ type: "string",
2644
+ required: false,
2645
+ large_field: true
2646
+ }
2647
+ ```
2648
+
2649
+ An LLM that reads the schema before issuing a query learns the field is heavy and can preemptively project it away:
2650
+
2651
+ ```ruby
2652
+ agent.execute(:query_class, class_name: "Article",
2653
+ keys: ["objectId", "title", "author"])
2654
+ # omits body and raw_html — response stays well under the cap
2655
+ ```
2656
+
2657
+ When the LLM specifically needs the heavy field for one record, it can fetch that record with `get_object` — one large body fits comfortably under the 4 MiB cap.
2658
+
2659
+ ### Restrictions
2660
+
2661
+ **Pointer and Relation types are never flagged.** Even when explicitly named in `agent_large_fields`, the schema annotation is suppressed for `Pointer`/`Relation` field types. The stored value for a pointer is a small reference (`{className, objectId}` or a parse-reference string); only `include:` resolution materializes the underlying record, which is a query-time concern and not a schema-time hint. Annotating the pointer would be misleading.
2662
+
2663
+ ### Relationship to other size guardrails
2664
+
2665
+ `agent_large_fields` is the **proactive** layer. It tells the LLM "this field is heavy" before the first query. Three reactive layers sit underneath it:
2666
+
2667
+ 1. **`query_class` truncate-and-annotate** — if the LLM didn't read the schema or ignored it, an oversized response is silently recovered by dropping the heaviest field and returning a partial-success `_truncated` block. See "Response size cap" in the Performance section.
2668
+ 2. **Oversize diagnostic on other tools** — `aggregate`/`export_data`/`get_object` refusals include a per-field byte ranking and a positive `keys:` recommendation so the LLM can retry correctly.
2669
+ 3. **`MAX_TOOL_RESPONSE_BYTES` floor** — 4 MiB hard ceiling regardless of all of the above.
2670
+
2671
+ Using `agent_large_fields` proactively eliminates the cost of layers (1) and (2) on classes where the developer already knows which columns are heavy. Layers (2) and (3) catch cases the declaration didn't anticipate.
2672
+
2673
+ ---
2674
+
2675
+ ## `_description:` and `_enum:` — Field-Level Schema Documentation
2676
+
2677
+ Two options on `property` carry per-field metadata to an LLM through `get_schema`. They're orthogonal to the validation-side `enum:` option and the `agent_fields` allowlist — they purely document what a field means and what its allowed values are, so an LLM composing a `where:` constraint doesn't have to infer semantics from the field name alone.
2678
+
2679
+ ### Declaration
2680
+
2681
+ ```ruby
2682
+ class Membership < Parse::Object
2683
+ parse_class "Membership"
2684
+
2685
+ property :title, :string,
2686
+ _description: "Display title for this membership grant"
2687
+
2688
+ property :grant, :string,
2689
+ _description: "Scope of the membership grant",
2690
+ _enum: {
2691
+ team: "Member of a team within the org",
2692
+ project: "Member of a single project under a team",
2693
+ organization: "Member of the org as a whole",
2694
+ }
2695
+
2696
+ property :account_level, :string,
2697
+ _enum: {
2698
+ basic: "Default tier",
2699
+ paid: "Active paid subscription",
2700
+ complimentary: "Granted by support; non-billable",
2701
+ }
2702
+ end
2703
+ ```
2704
+
2705
+ `_description:` takes a single string. `_enum:` takes a Hash mapping each allowed value (Symbol or String) to a per-value description. Value keys are stringified at declaration time to match the wire-format shape an LLM will see in query constraints (the schema always reports `value: "team"`, never `value: :team`).
2706
+
2707
+ ### Surface in `get_schema`
2708
+
2709
+ Both annotations show up per-field in the `fields[]` array:
2710
+
2711
+ ```ruby
2712
+ agent.execute(:get_schema, class_name: "Membership")
2713
+ # => {
2714
+ # success: true,
2715
+ # data: {
2716
+ # class_name: "Membership",
2717
+ # fields: [
2718
+ # { name: "title", type: "string", required: false,
2719
+ # description: "Display title for this membership grant" },
2720
+ # { name: "grant", type: "string", required: false,
2721
+ # description: "Scope of the membership grant",
2722
+ # allowed_values: [
2723
+ # { "value" => "team", "description" => "Member of a team within the org" },
2724
+ # { "value" => "project", "description" => "Member of a single project under a team" },
2725
+ # { "value" => "organization", "description" => "Member of the org as a whole" }
2726
+ # ] },
2727
+ # { name: "accountLevel", type: "string", required: false,
2728
+ # allowed_values: [...] },
2729
+ # ...
2730
+ # ]
2731
+ # }
2732
+ # }
2733
+ ```
2734
+
2735
+ `allowed_values` is an array of `{value, description}` objects so the JSON shape round-trips cleanly through MCP without depending on Hash-ordering semantics in the consumer. The `value` is always a string; the `description` is the LLM-facing prose.
2736
+
2737
+ ### Resolution against `field:` aliases
2738
+
2739
+ The lookup honors `field_map`, so a property declared with an explicit `field:` alias still resolves correctly when the server returns the column under its alias:
2740
+
2741
+ ```ruby
2742
+ property :external_status, :string,
2743
+ field: :ExtStatus,
2744
+ _description: "Status from upstream system",
2745
+ _enum: { active: "Currently operational", retired: "End-of-life" }
2746
+ ```
2747
+
2748
+ The schema response surfaces both `description:` and `allowed_values:` under `"ExtStatus"` (the wire name), not under `"external_status"` (the Ruby symbol). This is the same `field_map` lookup pattern the `agent_fields` allowlist uses — declarations on aliased properties are recovered by reversing the map at enrichment time.
2749
+
2750
+ ### `enum:` vs `_enum:` — separate concerns
2751
+
2752
+ The two options are orthogonal:
2753
+
2754
+ | Option | Role | Effect |
2755
+ |--------|------|--------|
2756
+ | `enum: [:active, :retired]` | Validation | Restricts which values can be saved; raises on save with a value outside the set. |
2757
+ | `_enum: { active: "...", retired: "..." }` | Documentation | Surfaces per-value descriptions to the LLM via `allowed_values:` on `get_schema`. |
2758
+
2759
+ Declaring both on the same property is supported and idiomatic. The gem does NOT cross-validate — `_enum:` keys can drift from `enum:` values without raising. Userland is responsible for keeping them in sync; the `audit_metadata` helper (below) flags neither divergence today.
2760
+
2761
+ ### Intended for string-typed columns only
2762
+
2763
+ Value keys are stringified unconditionally, so declaring `_enum:` on an integer/boolean column will surface string-shaped values that won't match the column in a `where:` filter:
2764
+
2765
+ ```ruby
2766
+ # Footgun — don't do this
2767
+ property :count, :integer, _enum: { 1 => "low", 2 => "high" }
2768
+ # get_schema reports allowed_values: [{ "value" => "1", ... }, { "value" => "2", ... }]
2769
+ # An LLM that copies `where: { count: "1" }` gets zero matches (column is integer).
2770
+ ```
2771
+
2772
+ The gem doesn't raise on the declaration — keeping `_enum:` on string-typed properties is userland's responsibility.
2773
+
2774
+ ---
2775
+
2776
+ ## Pointer-field `query_hint` in `get_schema` (v4.4.3+)
2777
+
2778
+ Pointer columns are stored on disk as `"ClassName$objectId"`. A `where:` constraint that passes a bare objectId without the surrounding Pointer shape matches nothing, and an LLM seeing `type: "Pointer"` alone has no signal about which value shapes are accepted. The schema formatter auto-emits a `query_hint:` on every Pointer field describing the SDK-accepted shapes inline, so the LLM doesn't have to query a sample row or guess.
2779
+
2780
+ ```ruby
2781
+ agent.execute(:get_schema, class_name: "Capture")
2782
+ # => {
2783
+ # success: true,
2784
+ # data: {
2785
+ # class_name: "Capture",
2786
+ # fields: [
2787
+ # { name: "author", type: "Pointer", required: true,
2788
+ # target_class: "_User",
2789
+ # query_hint: 'Pointer to _User. Equality: { "author" => "<objectId>" } ' \
2790
+ # 'or { "author" => { "__type" => "Pointer", ' \
2791
+ # '"className" => "_User", "objectId" => "<id>" } }. ' \
2792
+ # '$in/$nin: { "author" => { "$in" => ["<id1>", "<id2>"] } } ' \
2793
+ # '(bare objectIds; the SDK normalizes against the pointer storage shape).' },
2794
+ # ...
2795
+ # ]
2796
+ # }
2797
+ # }
2798
+ ```
2799
+
2800
+ **Hidden-target collapse.** When the target class is registered as `agent_hidden` (the LLM is not allowed to know it exists), `target_class:` is suppressed and `query_hint:` collapses the class name to a `<targetClass>` placeholder so the hint still describes the shape without leaking the target's identity:
2801
+
2802
+ ```ruby
2803
+ # Membership.belongs_to :user, class_name: "_User"
2804
+ # and _User is agent_hidden in this agent's posture
2805
+ # => { name: "user", type: "Pointer",
2806
+ # query_hint: 'Pointer to <targetClass>. Equality: { "user" => "<objectId>" } ' \
2807
+ # 'or { "user" => { "__type" => "Pointer", ' \
2808
+ # '"className" => "<targetClass>", "objectId" => "<id>" } }. ' \
2809
+ # '$in/$nin: { "user" => { "$in" => ["<id1>", "<id2>"] } } ...' }
2810
+ ```
2811
+
2812
+ The hint mirrors the shapes the SDK actually normalizes through `convert_constraints_for_aggregation` (mongo-direct) and the REST `find_objects` path — the bare-objectId `$in` form works because the query rewriter rebuilds the storage-form match from the array. The fully-qualified Pointer hash form also works in both code paths. Stating both inline removes the silent-zero failure mode where an LLM writes `where: { author: "abc123" }` against a Pointer column and reads the empty result as a real answer instead of a shape mismatch — pair with `Parse.strict_pointer_shapes = true` to convert any remaining unresolvable shapes into a `PointerShapeError` raise.
2813
+
2814
+ ---
2815
+
2816
+ ## `agent_join_fields` — Narrow Projection on Includes
2817
+
2818
+ `agent_join_fields` is a model-level declaration that controls how this class is projected when it shows up as an **included pointer** on another class's query. The direct-query `agent_fields` allowlist is typically the full "what the agent may see" set; the join-projection list is the narrower "what's interesting when I'm a foreign key" set. Without it, an `include:` of a heavy class on a high-cardinality parent query produces a wire payload dominated by fields the LLM never reads.
2819
+
2820
+ ### The bug it fixes
2821
+
2822
+ The reported reproducer: a `query_class(class_name: "Membership", keys: ["user", "title", "active", "createdAt"], include: ["user"])` against a 6-row Membership query. The included `_User` records carried full S3 presigned image URLs (~600 chars each on two columns), a 17-entry `teams[]` pointer array, an `organizations[]` array, and 13 other fields per row. The user objects accounted for ~85% of the response payload, while the LLM only ever consumed `firstName`/`lastName`/`email`/`lastActiveAt`/`internalTag` — maybe 5% of the materialized user.
2823
+
2824
+ `keys:` on the parent class trimmed the parent rows correctly, but Parse Server returned the included user untouched because no dotted-path projection was specified for the join. `agent_join_fields` is the developer-friendly way to declare the projection once at the model layer instead of per-call.
2825
+
2826
+ ### Declaration
2827
+
2828
+ ```ruby
2829
+ class Parse::User
2830
+ # Direct-query allowlist — the upper bound on what an agent ever sees
2831
+ # from _User on a `query_class("_User", ...)` call.
2832
+ agent_fields :first_name, :last_name, :email, :icon_image, :source_image,
2833
+ :teams, :organizations, :last_active_at, :internal_tag
2834
+
2835
+ # Heavy fields — stripped from any join even without an agent_join_fields
2836
+ # declaration (see "Resolution order" below).
2837
+ agent_large_fields :icon_image, :source_image
2838
+
2839
+ # Narrower projection used when _User shows up as a join target. The agent
2840
+ # gets these fields automatically when another class's query includes :user
2841
+ # — no per-call dotted-path keys needed.
2842
+ agent_join_fields :first_name, :last_name, :email, :last_active_at, :internal_tag
2843
+ end
2844
+ ```
2845
+
2846
+ ### Subset invariant
2847
+
2848
+ When both `agent_fields` and `agent_join_fields` are declared, **every entry in `agent_join_fields` MUST also appear in `agent_fields`**. The direct-query allowlist is the security upper bound on what the agent sees; the join-projection list can only tighten it, never widen it. A violation raises `ArgumentError` at class load time so the misconfiguration surfaces immediately rather than at first query.
2849
+
2850
+ Declaring `agent_join_fields` without `agent_fields` is allowed — it means "no direct-query allowlist (so the agent sees the full row on a direct `query_class`), but on a join project to these only."
2851
+
2852
+ ### Auto-projection on `include:`
2853
+
2854
+ `query_class`, `get_object`, `get_objects`, and `export_data` all run **keys-on-include auto-projection** when:
2855
+
2856
+ 1. The caller passes a non-empty `keys:` array.
2857
+ 2. The caller names a bare pointer field in both `keys:` and `include:`.
2858
+ 3. The caller does NOT pass any `<pointer>.*` dotted path for that same pointer.
2859
+
2860
+ When all three hold and the joined class has an annotation that produces a non-empty projection, the SDK appends dotted-path keys to the wire `keys:` parameter so Parse Server returns only the projected subfields of the included record. The bare-pointer entry stays in `keys:` so the pointer column itself is returned at the parent level.
2861
+
2862
+ #### Resolution order
2863
+
2864
+ For the auto-projection to fire, the joined class needs at least one of: `agent_join_fields`, `agent_fields`, or `agent_large_fields`. Resolution is first-match-wins:
2865
+
2866
+ | Tier | Joined class declares... | Projection set | Source flag |
2867
+ |------|-------------------------------------------|-----------------------------------------|------------------------------|
2868
+ | 1 | `agent_join_fields` | The declared list (wire format) | `:join_fields` |
2869
+ | 2 | `agent_fields` (no `agent_join_fields`) | `agent_fields - agent_large_fields` | `:allowlist_minus_large` |
2870
+ | 3 | only `agent_large_fields` | `field_map.keys - agent_large_fields` | `:field_map_minus_large` |
2871
+ | 4 | none of the above | nil (no projection — full record) | n/a |
2872
+
2873
+ Tier 3 ("strip mode") projects to the set of fields the Ruby model declares via `property` minus the large set. Server-side columns not declared as a `property` on the Ruby class won't come back — an honest trade-off, since the SDK can only project to fields it can name.
2874
+
2875
+ `ALWAYS_KEEP_FIELDS` (`objectId`, `createdAt`, `updatedAt`) is unioned into every projection so pointer dereferencing always works. `Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST` entries (`_hashed_password`, `_password_history`, `_session_token`, `_email_verify_token`, `_perishable_token`, `_failed_login_count`, `_account_lockout_expires_at`, `_rperm`, `_wperm`, `_tombstone`, `_auth_data`, and the `_auth_data_<provider>` prefix) are always filtered out at the end, identical to `MetadataRegistry.field_allowlist`, so an accidental `property :pw, field: :_hashed_password` mapping cannot leak through the join surface.
2876
+
2877
+ The internal-field denylist behaves as a **per-process floor** that holds independent of any `agent_fields` allowlist declaration on the joined class. Even on a class with no `agent_fields` declared, the join surface, the constraint translator (`where:` keys on every read tool), and the pipeline walker (`$project` / `$group._id` / `$addFields` / `$match` keys and `$<field>` reference strings at any nesting depth, not only inside `$expr`) all refuse internal-field names. The denylist is the security boundary; the allowlist is the documentation/projection convenience layered on top.
2878
+
2879
+ #### Suppression — caller intent overrides the auto-projection
2880
+
2881
+ Pass any `<pointer>.*` dotted path in `keys:` and auto-projection is suppressed for that pointer. The caller signaled "I named exactly what I want." The behavior matches verbatim:
2882
+
2883
+ ```ruby
2884
+ # Auto-projection fires (bare pointer in keys + include)
2885
+ agent.execute(:query_class,
2886
+ class_name: "Membership",
2887
+ keys: ["user", "title"],
2888
+ include: ["user"])
2889
+ # => wire keys: "user,title,user.firstName,user.lastName,user.email,user.internalTag,user.objectId,user.createdAt,user.updatedAt"
2890
+
2891
+ # Auto-projection SUPPRESSED (caller passed user.* dotted path)
2892
+ agent.execute(:query_class,
2893
+ class_name: "Membership",
2894
+ keys: ["user.iconImage", "title"],
2895
+ include: ["user"])
2896
+ # => wire keys: "user.iconImage,title" (no auto-expansion)
2897
+ ```
2898
+
2899
+ The auto-projection also doesn't fire when:
2900
+
2901
+ - `keys:` is absent entirely (caller chose full-row mode).
2902
+ - The bare pointer name is NOT in `keys:` (caller didn't ask for the pointer at the parent level either — Parse Server wouldn't return it).
2903
+ - The include is multi-hop (`include: ["user.team"]`) — only one-hop targets get auto-projected; deeper hops materialize fully. Keeps the rewrite bounded and avoids walking the full RelationGraph at query time.
2904
+
2905
+ ### Response envelope: `truncated_include_fields`
2906
+
2907
+ When auto-projection fires, `query_class`, `get_object`, and `get_objects` add a `truncated_include_fields` key to the response envelope listing, per pointer, which wire-format fields were actively dropped:
2908
+
2909
+ ```ruby
2910
+ agent.execute(:query_class,
2911
+ class_name: "Membership",
2912
+ keys: ["user", "title", "active"],
2913
+ include: ["user"],
2914
+ limit: 10)
2915
+ # => {
2916
+ # class_name: "Membership",
2917
+ # result_count: 10,
2918
+ # results: [...],
2919
+ # truncated_include_fields: {
2920
+ # "user" => ["iconImage", "sourceImage", "teams", "organizations"]
2921
+ # }
2922
+ # }
2923
+ ```
2924
+
2925
+ The LLM can read the envelope, see what was dropped, and re-ask with explicit dotted paths if it actually needs a dropped field (`keys: ["user.iconImage"]`). Suppressed entirely when no auto-projection fired, so the envelope stays minimal for the common case.
2926
+
2927
+ ### When `agent_join_fields` is NOT what you need
2928
+
2929
+ If the join-relevant fields ARE the same as the direct-query fields (common for small, narrow classes), don't declare `agent_join_fields` — tier 2 (`agent_fields - agent_large_fields`) handles it correctly. The new DSL exists for classes like `_User` where the direct-query allowlist is broad but the per-join projection should be narrow.
2930
+
2931
+ `agent_join_fields` does NOT replace `agent_fields`. It does NOT control direct-query projection. It only tightens the auto-projection that fires on `include:` resolution.
2932
+
2933
+ ### `get_sample_objects` is not affected
2934
+
2935
+ `get_sample_objects` does not accept an `include:` parameter, so auto-projection never fires there. Sample queries always project to the parent class's `agent_fields` allowlist (when declared) and never resolve pointers.
2936
+
2937
+ ### Discovery via `get_schema`
2938
+
2939
+ Both `agent_fields` and `agent_join_fields` are echoed as top-level keys on the `get_schema` response when declared. The allowlist is already enforced by stripping non-allowed fields from the response, but enforcement-by-omission left consumers guessing what they could write in `keys:` — the explicit echo closes that gap:
2940
+
2941
+ ```ruby
2942
+ agent.execute(:get_schema, class_name: "Membership")
2943
+ # => {
2944
+ # success: true,
2945
+ # data: {
2946
+ # class_name: "Membership",
2947
+ # type: "custom",
2948
+ # fields: [...], # already trimmed to the allowlist
2949
+ # agent_fields: ["user", "title", "active", "grant", "accountLevel"],
2950
+ # agent_join_fields: ["title", "active"], # narrower set used on `include:` resolution
2951
+ # ...
2952
+ # }
2953
+ # }
2954
+ ```
2955
+
2956
+ Wire-format names. `ALWAYS_KEEP_FIELDS` (objectId / createdAt / updatedAt) are excluded from the echo to keep it minimal — those are always available and would only noise up the list. Storage-form columns (`_p_*` pointer columns) and other Parse-internal underscored fields are never addressable through agent tools regardless of what userland passes to `agent_fields`; the `get_schema` tool description spells this out explicitly so the LLM stops trying.
2957
+
2958
+ Both echoes are suppressed when the corresponding DSL is not declared. A class with no `agent_fields` declaration produces a `get_schema` response with no `agent_fields:` key (rather than an empty array), so the absence-of-key form means "no allowlist; ask `query_class` for whatever fields you want and the LLM-facing schema is the full set."
2959
+
2960
+ ---
2961
+
2962
+ ## Operator Environment Gates for Write & Schema Tools
2963
+
2964
+ `Parse::Agent::Tools` exposes a write surface (`create_object`, `update_object`, `delete_object`, `create_class`, `delete_class`) and a write surface for declared methods (`call_method` invoking `agent_method :name, permission: :write` or `:admin`). Both surfaces are gated by per-agent `permissions:` AND by process-wide environment variables — defense in depth against a misconfigured factory that accidentally constructs a `:write` or `:admin` agent in production.
2965
+
2966
+ ### The four env vars
2967
+
2968
+ | Variable | Gates | Required for |
2969
+ |----------|-------|--------------|
2970
+ | `PARSE_AGENT_ALLOW_WRITE_TOOLS` | broad write category | `call_method` of an `agent_method :foo, permission: :write` |
2971
+ | `PARSE_AGENT_ALLOW_SCHEMA_OPS` | broad admin category | `call_method` of an `agent_method :foo, permission: :admin` |
2972
+ | `PARSE_AGENT_ALLOW_RAW_CRUD` | narrow raw CRUD | raw `create_object` / `update_object` / `delete_object` (additionally requires `WRITE_TOOLS`) |
2973
+ | `PARSE_AGENT_ALLOW_RAW_SCHEMA` | narrow raw schema | raw `create_class` / `delete_class` (additionally requires `SCHEMA_OPS`) |
2974
+
2975
+ Truthy values: `1`, `true`, `yes`, `on` (case-insensitive). Anything else (including unset) means disabled.
2976
+
2977
+ ### AND semantics for raw tools
2978
+
2979
+ The raw CRUD and raw schema tools require BOTH the broad gate AND the narrow gate:
2980
+
2981
+ - `create_object` requires `PARSE_AGENT_ALLOW_WRITE_TOOLS=true` AND `PARSE_AGENT_ALLOW_RAW_CRUD=true`.
2982
+ - `create_class` requires `PARSE_AGENT_ALLOW_SCHEMA_OPS=true` AND `PARSE_AGENT_ALLOW_RAW_SCHEMA=true`.
2983
+
2984
+ This lets a deployment enable intent-based writes via `agent_method` (set only the broad gate) WITHOUT exposing the generic create/update/delete surface (the narrow gate stays unset).
2985
+
2986
+ ### Recommended deployment posture
2987
+
2988
+ | Goal | WRITE_TOOLS | SCHEMA_OPS | RAW_CRUD | RAW_SCHEMA |
2989
+ |------|-------------|------------|----------|------------|
2990
+ | Read-only (default) | unset | unset | unset | unset |
2991
+ | Intent-based writes via declared `agent_method` only | `true` | unset | unset | unset |
2992
+ | Add admin-level agent_methods | `true` | `true` | unset | unset |
2993
+ | Add raw create/update/delete (escape hatch) | `true` | unset | `true` | unset |
2994
+ | Operator-only: entire surface | `true` | `true` | `true` | `true` |
2995
+
2996
+ The first non-default row is the recommended posture for most agent-facing deployments. Every mutation has to be declared explicitly on a Parse::Object subclass as an `agent_method`, with a method body that owns validation, normalization, and side effects. The LLM never touches `.save` directly; it calls named domain operations (`set_client_description`, `archive_user`, etc.).
2997
+
2998
+ ### Refusal shape
2999
+
3000
+ When a gate refuses, `Parse::Agent#execute` returns:
3001
+
3002
+ ```ruby
3003
+ {
3004
+ success: false,
3005
+ error_code: :access_denied,
3006
+ error: "Raw CRUD tool 'create_object' is disabled. " \
3007
+ "Required: PARSE_AGENT_ALLOW_WRITE_TOOLS=true AND PARSE_AGENT_ALLOW_RAW_CRUD=true. " \
3008
+ "Prefer declaring an agent_method on the target class for an intent-based " \
3009
+ "write path that requires only PARSE_AGENT_ALLOW_WRITE_TOOLS."
3010
+ }
3011
+ ```
3012
+
3013
+ The error message names the missing variables specifically, so an operator who sees the refusal in a log knows which env var to set. When one of the two is already set the message names only the still-missing one. The `error_code` is always `:access_denied` regardless of which gate was missing — same code as `agent_hidden` refusals — so a downstream subscriber can rate-limit, alert, or audit on the single code.
3014
+
3015
+ ### Programmatic introspection
3016
+
3017
+ `Parse::Agent.write_tools_enabled?`, `Parse::Agent.schema_ops_enabled?`, `Parse::Agent.raw_crud_enabled?`, and `Parse::Agent.raw_schema_enabled?` are class-method predicates returning the current state of each gate. Useful in startup smoke tests:
3018
+
3019
+ ```ruby
3020
+ abort "production agent must run read-only" if Parse::Agent.raw_schema_enabled?
3021
+ ```
3022
+
3023
+ ---
3024
+
3025
+ ## `agent_tenant_scope` — Multi-Tenant Data Isolation
3026
+
3027
+ `agent_tenant_scope` is a model-level declaration that enforces per-tenant data scoping on every read tool. It closes the highest-blast-radius gap in a naive multi-tenant deployment: a factory that authenticated the user but forgot to inject `{ org_id: ... }` into every `query_class` call would silently leak across tenants. The DSL makes that mistake structurally impossible.
3028
+
3029
+ ### Declaration
3030
+
3031
+ ```ruby
3032
+ class Order < Parse::Object
3033
+ parse_class "Order"
3034
+ property :org_id, :string
3035
+ property :total, :float
3036
+ property :status, :string
3037
+
3038
+ # Every read tool now filters by org_id = agent.tenant_id automatically.
3039
+ agent_tenant_scope :org_id, from: ->(agent) { agent.tenant_id }
3040
+ end
3041
+ ```
3042
+
3043
+ Two arguments:
3044
+ - `field` (Symbol or String) — the Parse field to scope on (e.g., `:org_id`, `:account_id`, `:tenant`).
3045
+ - `from:` (Proc / lambda) — a callable receiving the agent instance and returning the scope value to filter by. Return `nil` to signal "this agent has no tenant binding" — the call is then refused unless a bypass declaration covers the agent.
3046
+
3047
+ ### Setting the agent's tenant binding
3048
+
3049
+ Agents declare their tenant in the factory:
3050
+
3051
+ ```ruby
3052
+ Parse::Agent.rack_app do |env|
3053
+ user = MyAuth.verify!(env)
3054
+ Parse::Agent.new(
3055
+ permissions: :readonly,
3056
+ session_token: user.session_token,
3057
+ tenant_id: user.org_id,
3058
+ )
3059
+ end
3060
+ ```
3061
+
3062
+ `tenant_id:` is an arbitrary value (String, Integer, etc.) that the per-class `from:` callable interprets. It doesn't have to be called `org_id` — that's the field name on Parse::Object; `tenant_id` is the agent-level binding.
3063
+
3064
+ ### Enforcement across read tools
3065
+
3066
+ The scope is enforced at every read tool entry point:
3067
+
3068
+ | Tool | Enforcement mechanism |
3069
+ |------|------------------------|
3070
+ | `query_class`, `count_objects`, `get_sample_objects` | Merge `{ <field> => <value> }` into the effective `where:` after constraint translation. |
3071
+ | `aggregate`, `export_data` (pipeline mode) | Prepend a `$match` stage at pipeline index 0 with the scope filter. |
3072
+ | `export_data` (query mode) | Same as `query_class`. |
3073
+ | `get_object`, `get_objects` | After fetching, verify each returned record's scope field matches the agent's bound value. Mismatch refuses with `:access_denied`. |
3074
+
3075
+ **Why `get_object` refuses instead of filtering.** Silently returning "not found" for cross-tenant ids would create an oracle for "does this id exist in another tenant" — the timing or refusal signal differs from a truly missing id. Refusing with `:access_denied` makes the cross-tenant attempt visible in the audit log and indistinguishable to the caller from "I'm not authorized to know whether this exists."
3076
+
3077
+ ### Spoof protection for caller-supplied `where:`
3078
+
3079
+ If the LLM passes its own scope-field value (e.g., `where: { org_id: "other_tenant" }`), the merge logic compares against the agent's bound value:
3080
+
3081
+ - **Matching value** (caller's value equals the scope value, in either snake_case or camelCase) → passes through. The caller's filter is redundant but not wrong.
3082
+ - **Mismatching value** → refused with `:access_denied`. The LLM cannot spoof the tenant filter.
3083
+
3084
+ Both `"org_id"` / `:org_id` (snake_case) and `"orgId"` / `:orgId` (camelCase wire format) are checked, so an LLM passing the field name in either form is handled consistently.
3085
+
3086
+ ### Bypass for admin / operator agents
3087
+
3088
+ Some agents — operator tooling, batch processes, master-key admin agents — legitimately need cross-tenant access. Declare a bypass condition per class:
3089
+
3090
+ ```ruby
3091
+ class Order < Parse::Object
3092
+ agent_tenant_scope :org_id, from: ->(agent) { agent.tenant_id }
3093
+ agent_tenant_scope_bypass { |agent| agent.permissions == :admin }
3094
+ end
3095
+ ```
3096
+
3097
+ The block receives the agent and returns truthy to bypass enforcement. A bypass block that raises is treated as not-bypassed (fail closed). Without a bypass declaration, any agent with `tenant_id: nil` hitting a scoped class is refused outright.
3098
+
3099
+ ### Known limitation: `$lookup` / `$graphLookup` / `$unionWith` sub-pipelines
3100
+
3101
+ Tenant scope is applied as a `$match` stage at the TOP-level pipeline only. Sub-pipelines inside `$lookup`, `$graphLookup`, and `$unionWith` are NOT automatically scoped. Multi-tenant deployments that use `agent_tenant_scope` should pick one of:
3102
+
3103
+ 1. **Disable lookup auto-rewrite for tenant-bound agents** — `Parse.rewrite_lookups = false` (per-process), or pass `rewrite_lookups: false` per call. The LLM can still issue lookups using the explicit `_p_*` form, but the convenience auto-rewrite of logical-name joins is off.
3104
+ 2. **Refuse lookups from tenant-bound agents entirely** — application code rejects pipelines containing `$lookup` / `$graphLookup` / `$unionWith` when `agent.tenant_id` is set.
3105
+ 3. **Mark joinable cross-tenant classes as `agent_hidden`** — the most permissive joining-class is unreachable to the agent.
3106
+
3107
+ The proper fix (recursive scope injection into sub-pipelines) is tracked as a follow-up; see [SECURITY_GUIDE.md](../SECURITY_GUIDE.md) for the threat model and posture recommendations.
3108
+
3109
+ ---
3110
+
3111
+ ## `agent_canonical_filter` — Per-Class "Valid State" Predicate
3112
+
3113
+ Many Parse classes have a "live records" subset that every legitimate read should respect — soft-delete columns (`isRemoved`), publication flags (`onTimeline`), validity windows, tombstone markers, etc. Without a mechanism that codifies this subset, an LLM that drops to raw `aggregate` for a question `query_class` couldn't answer will silently include rows the application would have hidden, producing counts that disagree with the rest of the system.
3114
+
3115
+ `agent_canonical_filter` declares the predicate ONCE on the model class. Every read tool the agent exposes applies it BY DEFAULT to every call, and `get_schema` surfaces it so callers that opt out can reproduce the predicate manually.
3116
+
3117
+ ### Declaration
3118
+
3119
+ ```ruby
3120
+ class Capture < Parse::Object
3121
+ property :title, :string
3122
+ property :isRemoved, :boolean
3123
+ property :onTimeline, :boolean
3124
+
3125
+ # MongoDB-style match expression. Same shape that query_class's `where:`
3126
+ # accepts. Keys are stringified at declaration time.
3127
+ agent_canonical_filter "isRemoved" => { "$ne" => true },
3128
+ "onTimeline" => true
3129
+ end
3130
+ ```
3131
+
3132
+ The DSL accepts any well-formed where-expression Hash and validates it at class load time through `Parse::PipelineSecurity.validate_filter!`. Declarations containing `$where`, `$function`, or `$accumulator` raise `ArgumentError` at registration rather than being silently accepted and prepended past the per-request `PipelineValidator` at call time. Internal-field keys (`_hashed_password`, `_session_token`, `_rperm`, `_wperm`, the `_auth_data_<provider>` prefix, etc.) are also refused at registration. Normal Mongo query operators (`$ne`, `$gt`, `$in`, `$exists`, etc.) and references to user-defined fields are allowed.
3133
+
3134
+ ### Where the filter is applied
3135
+
3136
+ The canonical filter is applied across every read surface the agent exposes:
3137
+
3138
+ - **`query_class`** and **`count_objects`** — merged with the caller's `where:` via top-level `$and` so caller constraints compose rather than override. When the caller passed no `where:`, the canonical filter is used directly.
3139
+ - **`aggregate`** — prepended as a `$match` stage. When a tenant-scope `$match` is already at index 0, the canonical filter sits at index 1 so tenant isolation stays first for auditability.
3140
+ - **`group_by`**, **`group_by_date`**, **`distinct`** — prepended as a `$match` stage before the group/unwind stages so derived counts reflect the same "valid state" subset as `query_class`.
3141
+ - **`explain_query`** — the canonical predicate is included in the explained `where:` so the reported plan matches what `query_class` would actually execute.
3142
+ - **`get_sample_objects`** — included in the sample's effective `where:` so sample rows are drawn from the same subset as a normal query.
3143
+ - **`export_via_query`** and **`export_via_aggregate`** (the two backends behind `export_data`) — applied so an export is never a path to soft-deleted or otherwise excluded rows that the conversational tools hide.
3144
+
3145
+ ID-based reads (`get_object`, `get_objects`) intentionally do NOT apply the canonical filter. The caller named a specific objectId and is asking for that exact row; redacting it because it failed a `isRemoved => { "$ne" => true }` predicate would surprise legitimate callers fetching a soft-deleted record by ID for audit or restoration. Hidden-class refusal still applies — `agent_hidden` is the access boundary; `agent_canonical_filter` is a default predicate.
3146
+
3147
+ ### Per-call opt-out
3148
+
3149
+ ```ruby
3150
+ # Count all captures, including soft-deleted ones
3151
+ agent.execute(:count_objects, class_name: "Capture",
3152
+ apply_canonical_filter: false)
3153
+ ```
3154
+
3155
+ `apply_canonical_filter: false` is a per-call escape hatch on `query_class`, `count_objects`, and `aggregate`. The class-level declaration stays "applied" — the opt-out is a deliberate signal that the caller wants the full unfiltered collection for this one query. The opt-out keyword is intentionally NOT exposed on `group_by` / `group_by_date` / `distinct` / `explain_query` / `get_sample_objects` / `export_data`: those derived views must remain consistent with `query_class` for pagination cursors, plan explanations, and exports to agree with the count/list pair. A caller that genuinely needs an unfiltered group or export can drop to `aggregate` with `apply_canonical_filter: false`.
3156
+
3157
+ ### Discovery via `get_schema`
3158
+
3159
+ When a class declares `agent_canonical_filter`, `get_schema(class_name)` surfaces it as `canonical_filter:` so a caller that opts out can reproduce the predicate in its own `where:`:
3160
+
3161
+ ```ruby
3162
+ agent.execute(:get_schema, class_name: "Capture")
3163
+ # => {
3164
+ # success: true,
3165
+ # data: {
3166
+ # class_name: "Capture",
3167
+ # type: "custom",
3168
+ # fields: [...],
3169
+ # canonical_filter: { "isRemoved" => { "$ne" => true }, "onTimeline" => true },
3170
+ # ...
3171
+ # }
3172
+ # }
3173
+ ```
3174
+
3175
+ ### Programmatic lookup
3176
+
3177
+ ```ruby
3178
+ Parse::Agent::MetadataRegistry.canonical_filter("Capture")
3179
+ # => { "isRemoved" => { "$ne" => true }, "onTimeline" => true }
3180
+ Parse::Agent::MetadataRegistry.canonical_filter("ClassWithoutFilter")
3181
+ # => nil
3182
+ ```
3183
+
3184
+ ### Interaction with other gates
3185
+
3186
+ The canonical filter applies AFTER `assert_class_accessible!` (so `agent_hidden` classes still refuse before the predicate enters the picture) and AFTER tenant-scope injection (so the canonical predicate composes with — never replaces — tenant isolation). It applies BEFORE the COLLSCAN preflight, so a canonical predicate that adds an indexed column to the effective `where:` can help a class pass the preflight that would otherwise refuse it.
3187
+
3188
+ The filter is NOT a security boundary on its own — it does NOT prevent reading soft-deleted rows when the caller explicitly opts out. Use `agent_hidden` for classes the agent must never touch and `agent_fields` to redact specific columns. Use `agent_canonical_filter` for the "what counts as a live record" predicate every read should honor by default.
3189
+
3190
+ ---
3191
+
3192
+ ## `agent_method` Dry-Run Previews
3193
+
3194
+ When a developer-declared `agent_method` performs writes, an LLM caller can preview the effect of the write before committing. This reduces the risk of an LLM driven by ambiguous prompts performing destructive operations the user didn't actually want.
3195
+
3196
+ ### Opting in: `supports_dry_run: true`
3197
+
3198
+ ```ruby
3199
+ class Client < Parse::Object
3200
+ property :description, :string
3201
+ property :status, :string
3202
+
3203
+ agent_method :archive, permission: :admin, supports_dry_run: true
3204
+ def archive(dry_run: false)
3205
+ if dry_run
3206
+ return {
3207
+ would_archive: id,
3208
+ current_status: status,
3209
+ side_effects: ["notifies_owner", "logs_audit_entry"],
3210
+ }
3211
+ end
3212
+
3213
+ self.status = "archived"
3214
+ save!
3215
+ notify_owner!
3216
+ AuditLog.record!(action: :archived, client_id: id)
3217
+ { archived_at: Time.now.utc.iso8601 }
3218
+ end
3219
+ end
3220
+ ```
3221
+
3222
+ The author writes both branches: the dry-run path describes what WOULD happen; the real path performs the operation. The MCP layer simply forwards the `dry_run` kwarg — it doesn't try to intercept `save!` magically (which would break side effects).
3223
+
3224
+ ### LLM call shape
3225
+
3226
+ ```ruby
3227
+ agent.execute(:call_method,
3228
+ class_name: "Client",
3229
+ method_name: "archive",
3230
+ object_id: "abc123",
3231
+ arguments: { dry_run: true })
3232
+ # => { success: true, data: { result: { would_archive: "abc123", ... } } }
3233
+
3234
+ # After user confirmation, re-issue without dry_run:
3235
+ agent.execute(:call_method,
3236
+ class_name: "Client",
3237
+ method_name: "archive",
3238
+ object_id: "abc123")
3239
+ # => { success: true, data: { result: { archived_at: "..." } } }
3240
+ ```
3241
+
3242
+ ### Universal preview when the method does not declare `supports_dry_run`
3243
+
3244
+ When the LLM passes `dry_run: true` to an `agent_method` that did NOT declare `supports_dry_run: true`, `call_method` returns a structural preview envelope WITHOUT invoking the method body. The agent confirms the call would pass every gate it enforces (permission tier, mass-assignment guards, `permitted_keys`, instance-method object resolution) and reports the call that would have been made — but cannot produce a method-side preview, so the response is flagged `supports_real_dry_run: false`:
3245
+
3246
+ ```ruby
3247
+ agent.execute(:call_method,
3248
+ class_name: "Widget",
3249
+ method_name: "deactivate",
3250
+ object_id: "w_001",
3251
+ arguments: { dry_run: true })
3252
+ # => {
3253
+ # success: true,
3254
+ # data: {
3255
+ # class_name: "Widget",
3256
+ # method: "deactivate",
3257
+ # object_id: "w_001",
3258
+ # dry_run: true,
3259
+ # supports_real_dry_run: false,
3260
+ # would_call: {
3261
+ # class: "Widget",
3262
+ # method: "deactivate",
3263
+ # type: "instance",
3264
+ # object_id: "w_001",
3265
+ # args: {} # dry_run stripped from echoed args
3266
+ # },
3267
+ # note: "The method 'Widget.deactivate' did not declare supports_dry_run: true, ..."
3268
+ # }
3269
+ # }
3270
+ ```
3271
+
3272
+ This makes preview universally safe to call without requiring every method author to opt in. The wrapper layer can always report what the call WOULD do; the `supports_real_dry_run: false` flag tells the caller "no author-side preview was consulted, so the response can't tell you what state changes would actually occur."
3273
+
3274
+ When the method DID declare `supports_dry_run: true` (the snippet above), behavior is unchanged: the kwarg is forwarded and the method produces its own preview.
3275
+
3276
+ When the caller passes `dry_run: false` (or any other falsy value) to a method that did NOT declare dry-run support, the kwarg is stripped before forwarding so the method body never sees the unexpected keyword argument; the call executes normally.
3277
+
3278
+ ### Interaction with env gates
3279
+
3280
+ The dry-run gate fires AFTER the env-gate check. A `:write` method called with `dry_run: true` still requires `PARSE_AGENT_ALLOW_WRITE_TOOLS=true` on the server. Preview does NOT bypass the operator-level kill switch — an operator who has disabled writes entirely sees no preview attempts succeed.
3281
+
3282
+ ### `permitted_keys` disclosure and `Parse::Agent.agent_debug`
3283
+
3284
+ `get_schema` emits the full contract for each declared `agent_method`: `name`, `type` (class vs. instance), `permission`, `description`, `supports_dry_run`, and `parameters` (when set). One field — `permitted_keys` — is gated behind a separate flag because it names the exact attributes a `call_method` invocation is permitted to write, and that set IS the write-side authorization boundary. Disclosing it on every `get_schema` response enumerates the boundary for any consumer and gives an LLM the precise field list to fuzz when probing for `permitted_keys` gaps.
3285
+
3286
+ `Parse::Agent.agent_debug` (class accessor, default `false`) controls the disclosure:
3287
+
3288
+ ```ruby
3289
+ # Production posture (the default): permitted_keys omitted from get_schema
3290
+ Parse::Agent.agent_debug = false
3291
+
3292
+ # Trusted internal environments where the LLM needs the full method
3293
+ # contract to construct correct call_method payloads:
3294
+ Parse::Agent.agent_debug = true
3295
+
3296
+ # Predicate form for tooling that branches on the setting:
3297
+ Parse::Agent.agent_debug? # => false / true
3298
+ ```
3299
+
3300
+ When `agent_debug` is left at the default, `format_methods` omits the `permitted_keys` key entirely (via `.compact`); the rest of the method contract is unaffected. Set the flag to `true` only in environments where every consumer of the MCP surface is already trusted to know the write boundary — agent development sandboxes, internal-only operator tooling, or test suites that need to assert against the full contract. The flag is independent of `suppress_master_key_warning`, `refuse_collscan`, `expose_explain`, and `strict_tool_filter`; you can enable it on its own without changing any other security posture.
3301
+
3302
+ ---
3303
+
3304
+ ## Pagination `next_call` Hint
3305
+
3306
+ `query_class` responses now include an explicit `next_call:` block when `has_more: true`. LLMs follow explicit next-step instructions much more reliably than computing pagination arithmetic from `pagination.limit + pagination.skip`.
3307
+
3308
+ ### Response shape
3309
+
3310
+ ```ruby
3311
+ {
3312
+ class_name: "Order",
3313
+ result_count: 100,
3314
+ pagination: { limit: 100, skip: 0, has_more: true },
3315
+ next_call: {
3316
+ tool: "query_class",
3317
+ arguments: {
3318
+ class_name: "Order",
3319
+ limit: 100,
3320
+ skip: 100,
3321
+ where: { "status" => "paid" }, # threaded through from original call
3322
+ keys: ["objectId", "total"],
3323
+ order: "-createdAt",
3324
+ }
3325
+ },
3326
+ results: [...]
3327
+ }
3328
+ ```
3329
+
3330
+ When `has_more: false`, the `next_call:` field is absent (not nil — `.compact` strips it from the response hash).
3331
+
3332
+ The literal arguments returned in `next_call.arguments` include all the optional projection/filter arguments from the original call, so the LLM doesn't need to remember `where:` / `keys:` / `order:` / `include:` across the multi-turn pagination loop.
3333
+
3334
+ ### Interaction with truncate-and-annotate
3335
+
3336
+ When a `query_class` response triggers the dispatcher's truncate-and-annotate recovery (see "Response size cap"), `next_call:` is stripped from the recovered envelope. Its skip arithmetic (`skip + limit`) is stale because the truncation's `next_skip` uses a smaller resume offset (`original_skip + fit_count`). The `_truncated` block becomes the sole authoritative pagination signal in that case.
3337
+
3338
+ ---
3339
+
3340
+ ## Cost Telemetry Fields
3341
+
3342
+ `parse.agent.tool_call` notifications now include token-and-cost estimates so a downstream dashboard can alert on per-conversation LLM input-token spend.
3343
+
3344
+ ### Payload fields
3345
+
3346
+ | Key | Type | Present |
3347
+ |-----|------|---------|
3348
+ | `:est_input_tokens` | Integer | Success path, when `:result_size` is non-nil |
3349
+ | `:est_cost_usd` | Numeric | Success path, when `:est_input_tokens` is present AND `Parse::Agent.token_cost_per_million_input` is set |
3350
+
3351
+ Both fields are absent on the failure path (no work done → no tokens to charge for).
3352
+
3353
+ ### Configuring the cost rate
3354
+
3355
+ ```ruby
3356
+ # config/initializers/parse_agent_cost.rb
3357
+ Parse::Agent.token_cost_per_million_input = 3.00 # USD per million input tokens
3358
+ ```
3359
+
3360
+ The rate matches your LLM provider's input pricing for the model the upstream client uses. The default is `nil`, which omits the `:est_cost_usd` field entirely so dashboards don't see a constant-zero metric.
3361
+
3362
+ ### Heuristic accuracy
3363
+
3364
+ `est_input_tokens` is computed as `result_size / 4` (integer division). This is the industry-standard back-of-envelope for English JSON content and is accurate to ~20%. Operators who need exact counts should run their own tokenizer in a notification subscriber:
3365
+
3366
+ ```ruby
3367
+ ActiveSupport::Notifications.subscribe("parse.agent.tool_call") do |_n, _s, _f, _id, payload|
3368
+ next unless payload[:result_size]
3369
+ exact_tokens = TIKTOKEN.count(payload[:result_text]) # if you stash result text somewhere
3370
+ # ... record to your own metric ...
3371
+ end
3372
+ ```
3373
+
3374
+ ### Per-correlation dashboards
3375
+
3376
+ Combined with the `:correlation_id` field, operators can compute "tokens spent in conversation X" or "cost for this LLM session" by grouping events. Example StatsD shape:
3377
+
3378
+ ```ruby
3379
+ ActiveSupport::Notifications.subscribe("parse.agent.tool_call") do |_n, _s, _f, _id, payload|
3380
+ next unless payload[:est_input_tokens]
3381
+ tags = ["correlation_id:#{payload[:correlation_id] || 'none'}", "tool:#{payload[:tool]}"]
3382
+ $statsd.count("parse.agent.tokens.input", payload[:est_input_tokens], tags: tags)
3383
+ $statsd.count("parse.agent.cost.usd", payload[:est_cost_usd], tags: tags) if payload[:est_cost_usd]
3384
+ end
3385
+ ```
3386
+
3387
+ ---
3388
+
3389
+ ## `Parse::Agent.audit_metadata` — Boot-Time Metadata Audit
3390
+
3391
+ The agent surface depends on opt-in metadata: classes that haven't declared `agent_description` are invisible in `get_all_schemas` summaries; properties without `_description:` ship to the LLM with no semantic context; typos in `agent_fields` declarations silently miss after the field-map translation. `Parse::Agent.audit_metadata` walks the Parse::Object subclass set and returns a structured report of these gaps so operators can wire the check into a boot warning, a Rake task, or a CI gate.
3392
+
3393
+ ### Programmatic use
3394
+
3395
+ ```ruby
3396
+ audit = Parse::Agent.audit_metadata
3397
+ # => {
3398
+ # classes_audited: 28,
3399
+ # visible_classes_declared: true, # opt-in mode vs back-compat fallback
3400
+ # missing_class_descriptions: ["ProjectUsage", "CaptureSnapshot"],
3401
+ # missing_field_descriptions: {
3402
+ # "Capture" => [:internal_tag, :base_status, ...],
3403
+ # "Membership" => [:grant, :active]
3404
+ # },
3405
+ # unresolvable_allowlist_entries: {
3406
+ # "ProjectStage" => [:statys] # likely typo of :status
3407
+ # },
3408
+ # canonical_filter_summary: {
3409
+ # "Capture" => { "isRemoved" => { "$ne" => true }, "onTimeline" => true }
3410
+ # }
3411
+ # }
3412
+
3413
+ if audit[:missing_class_descriptions].any?
3414
+ raise "Refusing to boot: #{audit[:missing_class_descriptions].size} classes missing agent_description"
3415
+ end
3416
+ ```
3417
+
3418
+ The hash always carries the six top-level keys regardless of findings. `missing_field_descriptions`, `unresolvable_allowlist_entries`, and `canonical_filter_summary` are empty hashes when there's nothing to report. The keys never disappear — consumers can `data[:missing_class_descriptions].any?` without nil-check guards.
3419
+
3420
+ ### Field-description scope
3421
+
3422
+ When a class declares `agent_fields`, the missing-description check is scoped to the **allowlist** — those are the fields the LLM will actually see, so those are the ones worth describing. When no allowlist is declared, the check covers every property declared on the class. System fields (`object_id`, `created_at`, `updated_at`, `ACL`) are always excluded from the report.
3423
+
3424
+ ### What it skips
3425
+
3426
+ Two classes of skip prevent noise that would discourage adoption:
3427
+
3428
+ 1. **`agent_hidden` classes.** A class marked `agent_hidden` is intentionally opaque to every agent surface, so the audit doesn't pretend the missing description on it is a gap. The skip is whole-row — the class never appears in any of the four sections, even if it declares a canonical filter or allowlist typos.
3429
+ 2. **Parse system classes.** `_`-prefixed `parse_class` names (`_User`, `_Role`, `_Session`, `_Installation`, `_Product`, `_Audience`) are framework-supplied by parse-stack and don't benefit from userland-authored `agent_description`. Without this skip, every application that hadn't opted into `agent_visible` mode would see the system classes flooding `missing_class_descriptions`. Apps that genuinely want to document the system classes can still call `agent_description` on `Parse::User` etc. — the skip suppresses the "missing" reports, not legitimate declarations.
3430
+
3431
+ ### Interactive use
3432
+
3433
+ ```ruby
3434
+ Parse::Agent::MetadataAudit.print_summary
3435
+ # Parse::Agent metadata audit
3436
+ # ========================================
3437
+ # Classes audited: 28 (agent_visible mode)
3438
+ #
3439
+ # Missing class descriptions (2):
3440
+ # - ProjectUsage
3441
+ # - CaptureSnapshot
3442
+ #
3443
+ # Missing field descriptions (7 across 2 classes):
3444
+ # Capture (5):
3445
+ # internal_tag, base_status, is_removed, on_timeline, author
3446
+ # Membership (2):
3447
+ # grant, active
3448
+ #
3449
+ # Unresolvable allowlist entries:
3450
+ # ProjectStage: statys
3451
+ #
3452
+ # Canonical filters declared (1):
3453
+ # Capture: {"isRemoved" => {"$ne" => true}, "onTimeline" => true}
3454
+ ```
3455
+
3456
+ `print_summary` writes to `$stdout` by default; pass `io:` to redirect. Returns the same hash that `audit_metadata` returns, so a Rake task can both display and process the findings in one call.
3457
+
3458
+ ### Audit scope: `agent_visible` vs back-compat fallback
3459
+
3460
+ When at least one class has been marked `agent_visible`, that registry IS the canonical list to audit — the developer has explicitly said "these are the agent-facing classes." When no class has opted in, the audit walks every loaded `Parse::Object` subclass (back-compat mode) and reports against that. The `visible_classes_declared` field in the result tells consumers which path was taken.
3461
+
3462
+ In back-compat mode the descendant walk picks up every Ruby subclass loaded into the process, including test fixtures and lazily-loaded models. This is rarely a problem in production but can produce noisy results in test contexts where many fixture classes accumulate. Applications that want a tightly-scoped audit should opt into `agent_visible` mode by marking the production-facing classes.
3463
+
3464
+ ### Suggested boot integration
3465
+
3466
+ ```ruby
3467
+ # config/initializers/parse_agent_audit.rb
3468
+ Rails.application.config.after_initialize do
3469
+ audit = Parse::Agent.audit_metadata
3470
+
3471
+ if audit[:missing_class_descriptions].any?
3472
+ Rails.logger.warn "[parse-agent] #{audit[:missing_class_descriptions].size} classes " \
3473
+ "missing agent_description: #{audit[:missing_class_descriptions].inspect}"
3474
+ end
3475
+
3476
+ if audit[:unresolvable_allowlist_entries].any?
3477
+ # Typos in agent_fields silently miss; fail closed in production
3478
+ raise "agent_fields entries don't resolve to known properties: " \
3479
+ "#{audit[:unresolvable_allowlist_entries].inspect}"
3480
+ end
3481
+ end
3482
+ ```
3483
+
3484
+ The audit does not enforce anything on its own — it only reports. Operators decide what's a warning vs. a fail-closed condition for their deployment.