parse-stack-next 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,376 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "webrick"
5
+ require "json"
6
+ require "stringio"
7
+ require "active_support/core_ext/object/blank"
8
+ require "active_support/security_utils"
9
+
10
+ require_relative "prompts"
11
+ require_relative "mcp_dispatcher"
12
+ require_relative "mcp_rack_app"
13
+
14
+ module Parse
15
+ class Agent
16
+ # MCP (Model Context Protocol) HTTP Server for Parse Stack.
17
+ # Enables external AI agents (Claude, LM Studio, etc.) to interact with
18
+ # Parse data over HTTP using the MCP protocol specification.
19
+ #
20
+ # Since the Rack refactor this class is a thin WEBrick wrapper around
21
+ # {Parse::Agent::MCPRackApp}. Embedded deployments (Sinatra, Rails) should
22
+ # mount MCPRackApp directly with their own agent factory; this class
23
+ # remains for standalone server deployments and back-compat.
24
+ #
25
+ # @example Start the server
26
+ # Parse::Agent.enable_mcp!
27
+ # Parse::Agent::MCPServer.run(port: 3001)
28
+ #
29
+ # @example With custom configuration
30
+ # server = Parse::Agent::MCPServer.new(
31
+ # port: 3001,
32
+ # permissions: :readonly,
33
+ # session_token: nil
34
+ # )
35
+ # server.start
36
+ #
37
+ # @see https://modelcontextprotocol.io/ MCP Protocol Specification
38
+ # @see Parse::Agent::MCPRackApp for embedded mounting
39
+ #
40
+ class MCPServer
41
+ # MCP Protocol version
42
+ PROTOCOL_VERSION = MCPDispatcher::PROTOCOL_VERSION
43
+
44
+ # Server capabilities
45
+ CAPABILITIES = MCPDispatcher::CAPABILITIES
46
+
47
+ # Default port for the MCP server
48
+ @default_port = 3001
49
+
50
+ # Maximum allowed request body size (1 MB) — kept as a back-compat constant.
51
+ MAX_BODY_SIZE = MCPRackApp::DEFAULT_MAX_BODY_SIZE
52
+
53
+ # Maximum JSON nesting depth — kept as a back-compat constant.
54
+ MAX_JSON_NESTING = MCPRackApp::MAX_JSON_NESTING
55
+
56
+ # HTTP header for MCP API key authentication
57
+ MCP_API_KEY_HEADER = "X-MCP-API-Key"
58
+
59
+ class << self
60
+ attr_accessor :default_port
61
+
62
+ # Start the MCP server (blocking)
63
+ #
64
+ # @param port [Integer] port to listen on
65
+ # @param permissions [Symbol] agent permission level
66
+ # @param session_token [String, nil] optional session token
67
+ # @param host [String] host to bind to
68
+ # @param rate_limiter [#check!, nil] optional external rate limiter
69
+ def run(port: nil, permissions: :readonly, session_token: nil, host: "127.0.0.1", api_key: nil, rate_limiter: nil)
70
+ unless Parse::Agent.mcp_enabled?
71
+ raise "MCP server not enabled. Call Parse::Agent.enable_mcp! first"
72
+ end
73
+
74
+ server = new(
75
+ port: port || @default_port,
76
+ permissions: permissions,
77
+ session_token: session_token,
78
+ host: host,
79
+ api_key: api_key,
80
+ rate_limiter: rate_limiter,
81
+ )
82
+ server.start
83
+ end
84
+ end
85
+
86
+ # @return [Integer] the port number
87
+ attr_reader :port
88
+
89
+ # @return [String] the host to bind to
90
+ attr_reader :host
91
+
92
+ # @return [Parse::Agent] the template agent used by the /tools listing
93
+ # endpoint and as a settings source for per-request agents. Hot tools
94
+ # in MCP requests run against fresh per-request instances; do NOT
95
+ # share this object across threads for mutable state inspection.
96
+ attr_reader :agent
97
+
98
+ # Create a new MCP server instance
99
+ #
100
+ # @param port [Integer] port to listen on
101
+ # @param host [String] host to bind to
102
+ # @param permissions [Symbol] agent permission level
103
+ # @param session_token [String, nil] optional session token
104
+ # @param rate_limiter [#check!, nil] optional external rate limiter (e.g.
105
+ # Redis-backed). When provided, replaces the default in-process
106
+ # {Parse::Agent::RateLimiter}. Must respond to `#check!` and raise
107
+ # {Parse::Agent::RateLimitExceeded} when the budget is exhausted.
108
+ # @raise [ArgumentError] if rate_limiter is provided but does not respond to :check!
109
+ # Loopback hosts that are safe to bind to without an API key.
110
+ LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
111
+
112
+ def initialize(port: 3001, host: "127.0.0.1", permissions: :readonly,
113
+ session_token: nil, api_key: nil, rate_limiter: nil,
114
+ pre_auth_rate_limiter: nil,
115
+ allowed_origins: nil, require_custom_header: nil)
116
+ if rate_limiter && !rate_limiter.respond_to?(:check!)
117
+ raise ArgumentError, "rate_limiter must respond to #check!"
118
+ end
119
+ if pre_auth_rate_limiter && !pre_auth_rate_limiter.respond_to?(:check!)
120
+ raise ArgumentError, "pre_auth_rate_limiter must respond to #check!"
121
+ end
122
+
123
+ effective_api_key = api_key || ENV["MCP_API_KEY"]
124
+
125
+ # NEW-MCP-1: a non-loopback bind without an API key is an unauthenticated
126
+ # network-exposed JSON-RPC endpoint. Refuse to start. Operators who
127
+ # genuinely want this — e.g., behind a reverse proxy that handles
128
+ # auth — should bind to localhost and let the proxy forward, or
129
+ # set MCP_API_KEY explicitly even when "the proxy authenticates"
130
+ # (defense in depth).
131
+ if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.to_s.empty?
132
+ raise ArgumentError,
133
+ "MCPServer refuses to bind non-loopback host #{host.inspect} without an api_key. " \
134
+ "Set MCP_API_KEY in the environment, pass api_key: explicitly, or use a loopback " \
135
+ "host (one of: #{LOOPBACK_HOSTS.join(', ')})."
136
+ end
137
+
138
+ @port = port
139
+ @host = host
140
+ @api_key = effective_api_key
141
+ @permissions = permissions
142
+ @session_token = session_token
143
+
144
+ # Shared limiter across requests so per-request agents (built in
145
+ # agent_factory) don't reset their window on every call. The
146
+ # rate-limit budget is a server-level resource, not a per-Agent one.
147
+ @shared_rate_limiter = rate_limiter || RateLimiter.new
148
+
149
+ # Template agent for the /tools listing endpoint and for inspection
150
+ # via #agent. NOT used for live request dispatch — see agent_factory.
151
+ @agent = Parse::Agent.new(
152
+ permissions: @permissions,
153
+ session_token: @session_token,
154
+ rate_limiter: @shared_rate_limiter,
155
+ )
156
+ @server = nil
157
+
158
+ # The Rack app does the heavy lifting. Its agent_factory enforces the
159
+ # API key and constructs a FRESH Parse::Agent per request so the
160
+ # per-instance state (@conversation_history, @operation_log, token
161
+ # counters) cannot leak between requests.
162
+ # pre_auth_rate_limiter: closes NEW-MCP-6 — runs before the factory
163
+ # is invoked so an empty or malformed body can't amplify into a
164
+ # Parse Server round-trip.
165
+ @rack_app = MCPRackApp.new(
166
+ agent_factory: method(:agent_factory),
167
+ pre_auth_rate_limiter: pre_auth_rate_limiter,
168
+ allowed_origins: allowed_origins,
169
+ require_custom_header: require_custom_header,
170
+ )
171
+ end
172
+
173
+ # Start the HTTP server (blocking)
174
+ def start
175
+ @server = WEBrick::HTTPServer.new(
176
+ Port: @port,
177
+ BindAddress: @host,
178
+ Logger: WEBrick::Log.new($stdout, WEBrick::Log::INFO),
179
+ AccessLog: [[::File.open(::File::NULL, "w"), ""]], # Suppress access log
180
+ )
181
+
182
+ setup_routes
183
+
184
+ trap("INT") { stop }
185
+ trap("TERM") { stop }
186
+
187
+ puts "Parse MCP Server starting on http://#{@host}:#{@port}"
188
+ puts "Permissions: #{@agent.permissions}"
189
+ puts "Tools available: #{@agent.allowed_tools.join(", ")}"
190
+
191
+ @server.start
192
+ end
193
+
194
+ # Stop the server
195
+ def stop
196
+ @server&.shutdown
197
+ end
198
+
199
+ private
200
+
201
+ def setup_routes
202
+ # MCP endpoint — translated WEBrick request → Rack env → MCPRackApp.
203
+ @server.mount_proc("/mcp") { |req, res| handle_mcp_request(req, res) }
204
+
205
+ # Health check endpoint (unauthenticated - standard for monitoring)
206
+ @server.mount_proc("/health") do |_req, res|
207
+ json_response(res, { status: "ok", mcp_enabled: true })
208
+ end
209
+
210
+ # Tool list endpoint (requires auth if API key is configured)
211
+ @server.mount_proc("/tools") do |req, res|
212
+ if @api_key.present?
213
+ provided_key = req[MCP_API_KEY_HEADER].to_s
214
+ unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
215
+ error_response(res, 401, "Unauthorized: invalid or missing API key")
216
+ next
217
+ end
218
+ end
219
+ json_response(res, @agent.tool_definitions(format: :mcp))
220
+ end
221
+ end
222
+
223
+ # Translate a WEBrick request into a minimal Rack env and dispatch to the
224
+ # MCPRackApp. The agent_factory bound at construction handles API-key
225
+ # auth and returns the shared @agent for valid requests.
226
+ #
227
+ # WEBrick HTTPRequest#body reads lazily from the socket. We must reject
228
+ # oversized bodies BEFORE calling req.body. Two attack shapes:
229
+ # (a) Content-Length > MAX_BODY_SIZE — caught by the explicit check.
230
+ # (b) Transfer-Encoding: chunked with no Content-Length — WEBrick's
231
+ # read_chunked has no size cap and will dechunk indefinitely.
232
+ # We refuse (b) entirely: chunked or missing-Content-Length requests
233
+ # return 411 "Length Required" before req.body is ever called.
234
+ def handle_mcp_request(req, res)
235
+ # NEW-MCP-5: WEBrick's mount_proc("/mcp") is prefix-matching, so
236
+ # `/mcp/anything/at/all` reaches this handler and forwards the
237
+ # extra path segments into the Rack app via PATH_INFO. Reverse
238
+ # proxies that enforce ACLs against `^/mcp$` (or that route
239
+ # `/mcp/admin` to a different upstream) will be defeated by the
240
+ # prefix match unless we explicitly reject sub-paths here. A
241
+ # trailing slash is accepted — `/mcp/` is the same endpoint —
242
+ # but anything beyond is 404.
243
+ normalized = req.path.to_s.chomp("/")
244
+ unless normalized == "/mcp"
245
+ res.status = 404
246
+ res.content_type = "application/json"
247
+ res.body = JSON.generate({
248
+ "jsonrpc" => "2.0",
249
+ "id" => nil,
250
+ "error" => { "code" => -32_601, "message" => "Not Found" },
251
+ })
252
+ return
253
+ end
254
+
255
+ # Method gate FIRST. Returning 411 for a GET because it lacks a
256
+ # Content-Length is semantically wrong (the method itself is not
257
+ # allowed; body requirements never apply) and surprises Rack
258
+ # middleware that expects 405 for method-mismatch.
259
+ unless req.request_method == "POST"
260
+ res.status = 405
261
+ res["Allow"] = "POST"
262
+ res.content_type = "application/json"
263
+ res.body = JSON.generate({
264
+ "jsonrpc" => "2.0",
265
+ "id" => nil,
266
+ "error" => { "code" => -32_600, "message" => "Method Not Allowed: only POST is accepted" },
267
+ })
268
+ return
269
+ end
270
+
271
+ transfer_encoding = req["Transfer-Encoding"].to_s.downcase
272
+ content_length_header = req["Content-Length"]
273
+ if transfer_encoding.include?("chunked") || content_length_header.nil?
274
+ res.status = 411
275
+ res.content_type = "application/json"
276
+ res.body = JSON.generate({
277
+ "jsonrpc" => "2.0",
278
+ "id" => nil,
279
+ "error" => { "code" => -32_700, "message" => "Length Required: Content-Length header is required and Transfer-Encoding: chunked is not accepted" },
280
+ })
281
+ return
282
+ end
283
+
284
+ content_length = content_length_header.to_i
285
+ if content_length > MCPRackApp::DEFAULT_MAX_BODY_SIZE
286
+ res.status = 413
287
+ res.content_type = "application/json"
288
+ res.body = JSON.generate({
289
+ "jsonrpc" => "2.0",
290
+ "id" => nil,
291
+ "error" => { "code" => -32_700, "message" => "Payload Too Large: body exceeds #{MCPRackApp::DEFAULT_MAX_BODY_SIZE} bytes" },
292
+ })
293
+ return
294
+ end
295
+
296
+ env = build_rack_env(req)
297
+ status, headers, body_chunks = @rack_app.call(env)
298
+
299
+ res.status = status
300
+ rack_ct = headers["Content-Type"] || headers["content-type"]
301
+ headers.each { |k, v| res[k] = v unless k.casecmp("Content-Type").zero? }
302
+ res.content_type = rack_ct if rack_ct
303
+ res.body = body_chunks.join
304
+ end
305
+
306
+ # Agent factory passed to MCPRackApp. Enforces the API-key check (raising
307
+ # Parse::Agent::Unauthorized so the Rack app renders a sanitized 401)
308
+ # and then constructs a FRESH Parse::Agent per request, sharing only
309
+ # the @shared_rate_limiter so the budget persists across calls.
310
+ #
311
+ # The per-instance @conversation_history, @operation_log, and token
312
+ # counters on each returned agent are scoped to that single request
313
+ # and discarded when it ends, eliminating cross-request leakage.
314
+ def agent_factory(env)
315
+ if @api_key.present?
316
+ provided_key = env["HTTP_X_MCP_API_KEY"].to_s
317
+ unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
318
+ raise Parse::Agent::Unauthorized.new("invalid or missing API key", reason: :bad_api_key)
319
+ end
320
+ end
321
+
322
+ Parse::Agent.new(
323
+ permissions: @permissions,
324
+ session_token: @session_token,
325
+ rate_limiter: @shared_rate_limiter,
326
+ )
327
+ end
328
+
329
+ # Build a minimal Rack env from a WEBrick request. We populate the
330
+ # fields MCPRackApp reads (REQUEST_METHOD, CONTENT_TYPE, rack.input,
331
+ # HTTP_X_MCP_API_KEY) plus a few Rack-required keys so middleware that
332
+ # might wrap us still sees a plausible env. Per the Rack SPEC, the
333
+ # special Content-Type and Content-Length headers are top-level keys
334
+ # (no HTTP_ prefix), so the header-enumeration loop excludes them.
335
+ RACK_TOP_LEVEL_HEADERS = %w[Content-Type Content-Length].freeze
336
+
337
+ def build_rack_env(req)
338
+ env = {
339
+ "REQUEST_METHOD" => req.request_method,
340
+ "CONTENT_TYPE" => req["Content-Type"].to_s,
341
+ "CONTENT_LENGTH" => req["Content-Length"].to_s,
342
+ "rack.input" => StringIO.new(req.body || ""),
343
+ "rack.errors" => $stderr,
344
+ "rack.url_scheme" => "http",
345
+ "SERVER_NAME" => @host,
346
+ "SERVER_PORT" => @port.to_s,
347
+ "PATH_INFO" => req.path,
348
+ "QUERY_STRING" => req.query_string.to_s,
349
+ }
350
+ req.each do |name|
351
+ next if RACK_TOP_LEVEL_HEADERS.any? { |h| name.casecmp(h).zero? }
352
+ # NEW-MCP-2: refuse header names that already contain underscores.
353
+ # `X-MCP-API-Key` and `X_MCP_API_KEY` both collapse to the same
354
+ # Rack env key (`HTTP_X_MCP_API_KEY`); a reverse proxy that
355
+ # injects the trusted dash-form can be undermined by an attacker
356
+ # also sending the underscore-form. Drop the underscore variant
357
+ # at the transport layer.
358
+ next if name.include?("_")
359
+ header_key = "HTTP_#{name.upcase.tr("-", "_")}"
360
+ env[header_key] = req[name].to_s
361
+ end
362
+ env
363
+ end
364
+
365
+ def json_response(res, data)
366
+ res.content_type = "application/json"
367
+ res.body = JSON.generate(data)
368
+ end
369
+
370
+ def error_response(res, status, message)
371
+ res.status = status
372
+ json_response(res, { error: message })
373
+ end
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,259 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # Boot-time / on-demand audit of agent metadata declarations across
7
+ # the application's Parse::Object subclasses. Surfaces the gaps that
8
+ # silently degrade an LLM's experience of the schema: classes with no
9
+ # `agent_description`, properties on the allowlist with no
10
+ # `_description:`, and `agent_fields` entries that don't resolve to
11
+ # known wire columns.
12
+ #
13
+ # Returns structured data so callers can wire it into a boot warning,
14
+ # a CI gate, or a Rake task. `print_summary` is a convenience for
15
+ # interactive use (rails console, scripts).
16
+ #
17
+ # @example Programmatic use
18
+ # audit = Parse::Agent.audit_metadata
19
+ # if audit[:missing_class_descriptions].any?
20
+ # warn "Classes without descriptions: #{audit[:missing_class_descriptions]}"
21
+ # end
22
+ #
23
+ # @example Interactive use
24
+ # Parse::Agent::MetadataAudit.print_summary
25
+ module MetadataAudit
26
+ extend self
27
+
28
+ # System/system-adjacent fields that are always present on every
29
+ # Parse class and don't benefit from `_description:`. Excluded from
30
+ # the missing-field-descriptions report.
31
+ ALWAYS_PRESENT_FIELDS = %i[
32
+ object_id objectId
33
+ created_at createdAt
34
+ updated_at updatedAt
35
+ acl ACL
36
+ ].freeze
37
+
38
+ # Run the audit and return structured findings.
39
+ #
40
+ # @return [Hash]
41
+ # * :classes_audited [Integer] — number of classes inspected
42
+ # * :visible_classes_declared [Boolean] — whether the app uses
43
+ # opt-in `agent_visible` mode
44
+ # * :missing_class_descriptions [Array<String>] — Parse class names
45
+ # with no `agent_description`
46
+ # * :missing_field_descriptions [Hash<String, Array<Symbol>>] —
47
+ # class name -> property symbols missing `_description:`. When
48
+ # a class declares `agent_fields`, only allowlisted properties
49
+ # are counted; otherwise all declared properties.
50
+ # * :unresolvable_allowlist_entries [Hash<String, Array<Symbol>>] —
51
+ # `agent_fields` entries that don't appear in the class's
52
+ # `field_map` (likely typos that 4.2.1's wire-name translation
53
+ # will silently miss).
54
+ # * :canonical_filter_summary [Hash<String, Hash>] — per-class
55
+ # declared canonical filters, surfaced so the auditor can see
56
+ # which classes apply silent row-level predicates by default.
57
+ def audit
58
+ classes = audit_target_classes
59
+
60
+ result = {
61
+ classes_audited: classes.size,
62
+ visible_classes_declared: Parse::Agent::MetadataRegistry.has_visible_classes?,
63
+ missing_class_descriptions: [],
64
+ missing_field_descriptions: {},
65
+ unresolvable_allowlist_entries: {},
66
+ canonical_filter_summary: {},
67
+ }
68
+
69
+ classes.each do |klass|
70
+ name = parse_class_name_for(klass)
71
+ next if name.nil?
72
+
73
+ # Skip classes flagged agent_hidden — they're intentionally
74
+ # opaque to the agent surface, and we shouldn't pretend the
75
+ # missing description on them is a gap.
76
+ next if klass.respond_to?(:agent_hidden?) && klass.agent_hidden?
77
+
78
+ # Skip Parse system classes (`_`-prefixed parse_class names:
79
+ # `_User`, `_Role`, `_Session`, `_Installation`, `_Product`,
80
+ # `_Audience`). These are framework-supplied by parse-stack and
81
+ # don't benefit from userland-authored agent_description — the
82
+ # SDK is responsible for documenting them, not the application.
83
+ # Without this skip, every app that doesn't opt into
84
+ # `agent_visible` mode sees the system classes flooding
85
+ # `missing_class_descriptions`, which discourages adoption of
86
+ # the audit tool. Apps that DO want to document their system
87
+ # classes can still call `agent_description` on `Parse::User`
88
+ # etc. — the skip only suppresses the "missing" reports, not
89
+ # the legitimate ones.
90
+ next if name.to_s.start_with?("_")
91
+
92
+ if klass.respond_to?(:agent_description) && klass.agent_description.nil?
93
+ result[:missing_class_descriptions] << name
94
+ end
95
+
96
+ missing_fields = missing_field_descriptions_for(klass)
97
+ result[:missing_field_descriptions][name] = missing_fields if missing_fields.any?
98
+
99
+ unresolvable = unresolvable_allowlist_entries_for(klass)
100
+ result[:unresolvable_allowlist_entries][name] = unresolvable if unresolvable.any?
101
+
102
+ if klass.respond_to?(:agent_canonical_filter_for_apply) &&
103
+ (cf = klass.agent_canonical_filter_for_apply) &&
104
+ cf.any?
105
+ result[:canonical_filter_summary][name] = cf.dup
106
+ end
107
+ end
108
+
109
+ result[:missing_class_descriptions].sort!
110
+ result
111
+ end
112
+
113
+ # Print a human-readable summary to the given IO (defaults to $stdout).
114
+ # The structured data from {#audit} is the source of truth; this is a
115
+ # convenience for interactive sessions.
116
+ #
117
+ # @param io [IO] destination (default $stdout)
118
+ # @return [Hash] the audit findings (same shape as {#audit})
119
+ def print_summary(io: $stdout)
120
+ data = audit
121
+
122
+ io.puts "Parse::Agent metadata audit"
123
+ io.puts "=" * 40
124
+ io.puts "Classes audited: #{data[:classes_audited]} " \
125
+ "(#{data[:visible_classes_declared] ? "agent_visible mode" : "all-subclasses fallback"})"
126
+ io.puts
127
+
128
+ missing_classes = data[:missing_class_descriptions]
129
+ io.puts "Missing class descriptions (#{missing_classes.size}):"
130
+ if missing_classes.empty?
131
+ io.puts " (none)"
132
+ else
133
+ missing_classes.each { |n| io.puts " - #{n}" }
134
+ end
135
+ io.puts
136
+
137
+ missing_fields = data[:missing_field_descriptions]
138
+ total_missing_fields = missing_fields.values.sum(&:size)
139
+ io.puts "Missing field descriptions (#{total_missing_fields} across #{missing_fields.size} classes):"
140
+ if missing_fields.empty?
141
+ io.puts " (none)"
142
+ else
143
+ missing_fields.sort.each do |class_name, fields|
144
+ io.puts " #{class_name} (#{fields.size}):"
145
+ io.puts " #{fields.map(&:to_s).join(", ")}"
146
+ end
147
+ end
148
+ io.puts
149
+
150
+ unresolvable = data[:unresolvable_allowlist_entries]
151
+ io.puts "Unresolvable allowlist entries:"
152
+ if unresolvable.empty?
153
+ io.puts " (none)"
154
+ else
155
+ unresolvable.sort.each do |class_name, entries|
156
+ io.puts " #{class_name}: #{entries.map(&:to_s).join(", ")}"
157
+ end
158
+ end
159
+ io.puts
160
+
161
+ filters = data[:canonical_filter_summary]
162
+ io.puts "Canonical filters declared (#{filters.size}):"
163
+ if filters.empty?
164
+ io.puts " (none)"
165
+ else
166
+ filters.sort.each do |class_name, filter|
167
+ io.puts " #{class_name}: #{filter.inspect}"
168
+ end
169
+ end
170
+
171
+ data
172
+ end
173
+
174
+ # ----------------------------------------------------------------
175
+ # Internals
176
+ # ----------------------------------------------------------------
177
+
178
+ # Resolve the set of classes to audit.
179
+ #
180
+ # When the application has opted into `agent_visible` mode, that
181
+ # registry IS the canonical list — the developer has explicitly said
182
+ # "these are the agent-facing classes." Otherwise fall back to every
183
+ # Parse::Object subclass currently loaded (back-compat mode).
184
+ #
185
+ # @return [Array<Class>]
186
+ def audit_target_classes
187
+ if Parse::Agent::MetadataRegistry.has_visible_classes?
188
+ Parse::Agent::MetadataRegistry.visible_classes
189
+ else
190
+ # `Parse::Object.descendants` is the same iteration path used by
191
+ # `Parse::Model.find_class` to resolve a Parse class name to a
192
+ # Ruby class. Walks every loaded subclass without going through
193
+ # the find_class cache (which raises NameError on miss and would
194
+ # corrupt the audit's "what's declared" view).
195
+ Parse::Object.descendants.select do |klass|
196
+ klass.respond_to?(:parse_class) && klass.parse_class
197
+ end
198
+ end
199
+ end
200
+
201
+ # The Parse-side class name for a Ruby class, or nil when the class
202
+ # isn't a normal Parse::Object subclass (defensive — every entry in
203
+ # audit_target_classes should pass this).
204
+ def parse_class_name_for(klass)
205
+ return nil unless klass.respond_to?(:parse_class)
206
+ klass.parse_class
207
+ end
208
+
209
+ # Build the list of property symbols on a class that have no
210
+ # `_description:` declaration. When `agent_fields` is declared, the
211
+ # check is scoped to the allowlist (those are the agent-visible
212
+ # fields and the ones the LLM will see); otherwise it covers every
213
+ # declared property on the class.
214
+ #
215
+ # Excludes ALWAYS_PRESENT_FIELDS (the four system columns) since
216
+ # those don't benefit from per-property descriptions.
217
+ def missing_field_descriptions_for(klass)
218
+ return [] unless klass.respond_to?(:property_descriptions)
219
+ return [] unless klass.respond_to?(:field_map)
220
+
221
+ described = klass.property_descriptions.keys.map(&:to_sym).to_set
222
+ declared_properties = klass.field_map.keys.map(&:to_sym)
223
+
224
+ candidates =
225
+ if klass.respond_to?(:agent_field_allowlist) && klass.agent_field_allowlist.any?
226
+ klass.agent_field_allowlist.map(&:to_sym)
227
+ else
228
+ declared_properties
229
+ end
230
+
231
+ candidates - described.to_a - ALWAYS_PRESENT_FIELDS
232
+ end
233
+
234
+ # `agent_fields` entries that don't resolve to a known property on
235
+ # the class. These would silently miss after the 4.2.1 wire-name
236
+ # translation — the symbol would columnize to a column the schema
237
+ # doesn't carry, and the filter would strip nothing.
238
+ def unresolvable_allowlist_entries_for(klass)
239
+ return [] unless klass.respond_to?(:agent_field_allowlist)
240
+ allowlist = klass.agent_field_allowlist
241
+ return [] if allowlist.empty?
242
+ return [] unless klass.respond_to?(:field_map)
243
+
244
+ known = klass.field_map.keys.map(&:to_sym).to_set
245
+ allowlist.map(&:to_sym).reject { |sym| known.include?(sym) }
246
+ end
247
+ end
248
+
249
+ class << self
250
+ # Convenience class-method form of {Parse::Agent::MetadataAudit#audit}.
251
+ # See {MetadataAudit} for the full contract.
252
+ #
253
+ # @return [Hash] structured audit findings
254
+ def audit_metadata
255
+ Parse::Agent::MetadataAudit.audit
256
+ end
257
+ end
258
+ end
259
+ end