parse-stack-next 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
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.
|