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,1023 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "errors"
6
+ require_relative "prompts"
7
+
8
+ module Parse
9
+ class Agent
10
+ # Pure JSON-RPC dispatch layer for the MCP protocol.
11
+ #
12
+ # MCPDispatcher translates an already-parsed JSON-RPC request body into a
13
+ # JSON-RPC response envelope without touching any I/O, HTTP transport, or
14
+ # authentication. Callers are responsible for:
15
+ # - Parsing the raw request body into a Hash.
16
+ # - Authenticating the request and constructing a Parse::Agent instance.
17
+ # - Serializing the returned Hash back to JSON and writing it to the wire.
18
+ #
19
+ # This design lets the same dispatch logic serve WEBrick (MCPServer),
20
+ # Rack (MCPRackApp), and in-process tests without duplication.
21
+ #
22
+ # @example Basic usage
23
+ # body = JSON.parse(raw_request_body)
24
+ # agent = Parse::Agent.new(permissions: :readonly)
25
+ # result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
26
+ # # => { status: 200, body: { "jsonrpc" => "2.0", "id" => 1, "result" => {...} } }
27
+ #
28
+ module MCPDispatcher
29
+ # MCP protocol version advertised in the `initialize` handshake.
30
+ # Matches MCPServer::PROTOCOL_VERSION.
31
+ #
32
+ # Bumped from 2024-11-05 to 2025-06-18 in v4.2 alongside tool-internal
33
+ # progress reporting. The changes from 2024-11-05 → 2025-06-18 that
34
+ # affect the surface this gem implements are all additive:
35
+ # - notifications/progress accepts an optional `message` field
36
+ # (2025-03-26).
37
+ # - Tool descriptors may carry `annotations`, `outputSchema`, and
38
+ # tool results may carry `structuredContent` / resource links
39
+ # (2025-06-18). The dispatcher does not emit these fields — they
40
+ # are forward-compatible no-ops.
41
+ #
42
+ # Clients negotiating an older version (e.g. 2024-11-05-only) will
43
+ # still interpret the `initialize` capability shape and supported
44
+ # methods correctly; the wire-level differences only matter for the
45
+ # additive fields above.
46
+ PROTOCOL_VERSION = "2025-06-18"
47
+
48
+ # Protocol versions the dispatcher is willing to negotiate. Per the
49
+ # MCP lifecycle spec the server MUST echo the client's requested
50
+ # version when supported, or fall back to a version it does
51
+ # support. This list reflects the versions whose wire shape and
52
+ # method set are compatible with the handlers below — additions
53
+ # from 2024-11-05 → 2025-06-18 are all additive and forward-
54
+ # compatible no-ops for older clients.
55
+ SUPPORTED_PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
56
+
57
+ # Server capability advertisement (mirrors MCPServer::CAPABILITIES).
58
+ #
59
+ # `tools.listChanged` and `prompts.listChanged` are advertised as
60
+ # true in v4.2: Parse::Agent::MCPRackApp's SSEBody subscribes to
61
+ # Parse::Agent::Tools.subscribe and Parse::Agent::Prompts.subscribe
62
+ # and broadcasts `notifications/tools/list_changed` /
63
+ # `notifications/prompts/list_changed` onto every live SSE stream
64
+ # when an application calls `Tools.register`,
65
+ # `Tools.reset_registry!`, `Prompts.register`, or
66
+ # `Prompts.reset_registry!` at runtime. Standalone MCPServer
67
+ # callers (WEBrick, no streaming) cannot receive notifications;
68
+ # they still see the latest registry state on the next
69
+ # `tools/list` / `prompts/list` poll.
70
+ CAPABILITIES = {
71
+ "tools" => { "listChanged" => true },
72
+ "resources" => { "subscribe" => false, "listChanged" => false },
73
+ "prompts" => { "listChanged" => true },
74
+ }.freeze
75
+
76
+ # Parse class-name identifier regex — used to validate resource URIs.
77
+ # Matches Parse's class-name convention: letter/underscore start, up to 128
78
+ # chars, alphanumeric/underscore body.
79
+ IDENTIFIER_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
80
+
81
+ # Maximum serialized response body for a single tools/call. Prevents a
82
+ # wide-schema query with limit=1000 from producing tens of megabytes
83
+ # of JSON before the response is written. When exceeded, the dispatcher
84
+ # returns an isError tool result instructing the client to narrow the
85
+ # query, NOT a JSON-RPC transport error.
86
+ MAX_TOOL_RESPONSE_BYTES = 4_194_304 # 4 MiB
87
+
88
+ # Dispatch a JSON-RPC request body to the appropriate handler.
89
+ #
90
+ # @param body [Hash] already-parsed JSON-RPC request body with string keys.
91
+ # Expected shape: { "jsonrpc" => "2.0", "method" => String,
92
+ # "params" => Hash, "id" => Any }
93
+ # @param agent [Parse::Agent] an authenticated agent instance.
94
+ # @return [Hash] always `{ status: Integer, body: Hash }`.
95
+ # `status` is the HTTP status code (200 for all successful dispatches,
96
+ # including JSON-RPC `error` responses; 401 only for Unauthorized).
97
+ # `body` is the full JSON-RPC response envelope (string keys) containing
98
+ # `"jsonrpc"`, `"id"`, and either `"result"` or `"error"`.
99
+ #
100
+ # @raise nothing — all exceptions are caught and translated to error envelopes.
101
+ #
102
+ # Error codes used:
103
+ # -32700 Parse error (body is not a Hash or missing "method")
104
+ # -32601 Method not found (unknown method name)
105
+ # -32602 Invalid params (bad arguments, SecurityError, ValidationError)
106
+ # -32603 Internal error (unexpected StandardError — class name only, no message)
107
+ # -32001 Unauthorized (Parse::Agent::Unauthorized) → HTTP 401
108
+ #
109
+ # @note Parse::Agent::Prompts contract observed from prompts.rb:
110
+ # `Prompts.list` returns an Array of prompt descriptor Hashes (builtins
111
+ # merged with any registered custom prompts).
112
+ # `Prompts.render(name, args)` returns the full MCP envelope Hash
113
+ # `{ "description" => String, "messages" => [...] }` — already shaped.
114
+ # It raises `Parse::Agent::ValidationError` for unknown prompt names and
115
+ # for missing/invalid required arguments. The dispatcher passes the
116
+ # envelope through as-is and lets rescue handle ValidationError → -32602.
117
+ # @param logger [#warn, nil] optional logger for internal errors. When
118
+ # not provided, falls back to `Kernel#warn` → $stderr. Wire from the
119
+ # transport layer (MCPRackApp forwards its logger here automatically).
120
+ # @param progress_callback [#call, nil] callback the dispatcher
121
+ # installs on the agent for the duration of the request, so tools
122
+ # can emit MCP `notifications/progress` events via
123
+ # `agent.report_progress(...)`. Set by Parse::Agent::MCPRackApp on
124
+ # the SSE path; nil for the JSON path. The callback signature is
125
+ # `call(progress:, total:, message:)` (keyword args), and it is
126
+ # cleared from the agent in an ensure block before this method
127
+ # returns.
128
+ # @param cancellation_token [Parse::Agent::CancellationToken, nil]
129
+ # cooperative cancellation token the dispatcher installs on the
130
+ # agent for the duration of the request. Tools check
131
+ # `agent.cancelled?` at safe checkpoints; cancelled tool results
132
+ # are translated into a JSON-RPC `isError` content envelope by
133
+ # {#handle_tools_call}. Cleared from the agent in an ensure block
134
+ # before this method returns.
135
+ def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil)
136
+ # Snapshot any prior callback/token already on the agent (e.g. a
137
+ # token a parent dispatcher installed before a tool handler
138
+ # invoked us recursively, or values pre-set by the application).
139
+ # We restore these in the ensure block so we never clobber state
140
+ # we did not install. Without snapshot-restore, two interleaved
141
+ # dispatches on the same shared agent would race: the second
142
+ # request's ensure would null the first request's still-needed
143
+ # token.
144
+ prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
145
+ prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
146
+
147
+ # Install the progress callback and cancellation token on the
148
+ # agent for the duration of the dispatch. Cleared in the ensure
149
+ # block below so a per-request agent that is recycled (or
150
+ # accidentally retained) never carries a stale callback or token
151
+ # across requests.
152
+ #
153
+ # Note: a single Parse::Agent instance is NOT safe to drive from
154
+ # two threads concurrently — the snapshot-restore pattern here
155
+ # only handles sequential interleave. MCPRackApp's `agent_factory:`
156
+ # is documented to return a fresh agent per request.
157
+ agent.progress_callback = progress_callback if progress_callback && agent.respond_to?(:progress_callback=)
158
+ agent.cancellation_token = cancellation_token if cancellation_token && agent.respond_to?(:cancellation_token=)
159
+
160
+ # Guard: body must be a Hash with a "method" key.
161
+ unless body.is_a?(Hash) && body.key?("method")
162
+ id = body.is_a?(Hash) ? body["id"] : nil
163
+ return { status: 200, body: jsonrpc_error(id, -32700, "Invalid Request") }
164
+ end
165
+
166
+ method = body["method"]
167
+ params = body["params"] || {}
168
+ id = body["id"]
169
+
170
+ # JSON-RPC notifications MUST NOT carry an `id` field. Reject
171
+ # `notifications/*` methods that include one — silently treating
172
+ # them as no-op notifications leaves a client expecting a
173
+ # response hanging until its read timeout.
174
+ if method.is_a?(String) && method.start_with?("notifications/") && body.key?("id") && !id.nil?
175
+ return { status: 200, body: jsonrpc_error(id, -32600, "Invalid Request: notifications must not carry an id") }
176
+ end
177
+
178
+ result_hash = dispatch(method, params, agent, id, logger)
179
+ { status: result_hash[:status], body: result_hash[:body] }
180
+
181
+ rescue Parse::Agent::Unauthorized => e
182
+ { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
183
+ rescue StandardError => e
184
+ # Do not leak the exception class name (gem fingerprinting). Server-
185
+ # side log goes to the injected logger when set, otherwise $stderr.
186
+ log_internal_error(logger, e)
187
+ { status: 200, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32603, "Internal error") }
188
+ ensure
189
+ # Restore the prior callback/token state captured above. This
190
+ # avoids clobbering a token installed by an outer scope when
191
+ # this dispatch ran as a nested invocation, and avoids leaving
192
+ # this request's token visible to a sibling dispatch on a
193
+ # shared agent.
194
+ if agent.respond_to?(:progress_callback=)
195
+ agent.progress_callback = prev_progress_callback
196
+ end
197
+ if agent.respond_to?(:cancellation_token=)
198
+ agent.cancellation_token = prev_cancellation_token
199
+ end
200
+ end
201
+
202
+ # Emit an internal-error diagnostic. The class+message are operator-only;
203
+ # never reach the wire.
204
+ def self.log_internal_error(logger, error)
205
+ line = "[Parse::Agent::MCPDispatcher] #{error.class}: #{error.message}"
206
+ if logger
207
+ logger.warn(line)
208
+ else
209
+ warn line
210
+ end
211
+ end
212
+ private_class_method :log_internal_error
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Private helpers
216
+ # ---------------------------------------------------------------------------
217
+
218
+ # Route the method string to its handler, wrap the result in a JSON-RPC
219
+ # envelope, and return { status:, body: }.
220
+ #
221
+ # @api private
222
+ def self.dispatch(method, params, agent, id, logger = nil)
223
+ result = case method
224
+ when "initialize"
225
+ handle_initialize(params)
226
+ when "tools/list"
227
+ handle_tools_list(params, agent)
228
+ when "tools/call"
229
+ handle_tools_call(params, agent)
230
+ when "resources/list"
231
+ handle_resources_list(params, agent)
232
+ when "resources/templates/list"
233
+ handle_resources_templates_list(params, agent)
234
+ when "resources/read"
235
+ handle_resources_read(params, agent)
236
+ when "prompts/list"
237
+ handle_prompts_list(params)
238
+ when "prompts/get"
239
+ handle_prompts_get(params)
240
+ when "ping"
241
+ {}
242
+ when "notifications/cancelled"
243
+ # JSON-RPC notification (no id, no response). The dispatcher
244
+ # accepts this method as a recognized no-op so unknown-method
245
+ # errors are not returned to callers that send it through the
246
+ # standalone (non-Rack) transports. The actual cancellation
247
+ # effect is implemented by Parse::Agent::MCPRackApp, which
248
+ # special-cases the method before reaching the dispatcher to
249
+ # consult its (correlation_id, request_id) registry and trip
250
+ # the matching CancellationToken.
251
+ { __notification__: true }
252
+ when "notifications/initialized"
253
+ # JSON-RPC notification sent by the client after the
254
+ # `initialize` handshake completes. Per spec the server
255
+ # performs no action and sends no response — the dispatcher
256
+ # accepts the method as a recognized no-op so clients that
257
+ # send it (Claude Desktop, MCP Inspector, etc.) do not see
258
+ # a `-32601 Method not found` error.
259
+ { __notification__: true }
260
+ else
261
+ return { status: 200, body: jsonrpc_error(id, -32601, "Method not found: #{method}") }
262
+ end
263
+
264
+ # JSON-RPC notifications carry no `id` and require no response.
265
+ # Return a 200 with a nil body so the transport layer can write
266
+ # an empty response (or, for the standalone MCPServer that always
267
+ # writes the response, an empty JSON object).
268
+ return { status: 200, body: nil } if result.is_a?(Hash) && result[:__notification__]
269
+
270
+ # result is a Hash; if it carries an :error or "error" key the handler
271
+ # wants a JSON-RPC error envelope, otherwise it's a result.
272
+ err = result[:error] || result["error"]
273
+ if err
274
+ { status: 200, body: jsonrpc_envelope(id, error: err) }
275
+ else
276
+ { status: 200, body: jsonrpc_envelope(id, result: result) }
277
+ end
278
+
279
+ rescue Parse::Agent::Unauthorized => e
280
+ { status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
281
+ rescue Parse::Agent::SecurityError
282
+ { status: 200, body: jsonrpc_error(id, -32602, "Invalid params") }
283
+ rescue Parse::Agent::ValidationError => e
284
+ { status: 200, body: jsonrpc_error(id, -32602, e.message) }
285
+ rescue ArgumentError => e
286
+ # ArgumentError from prompts/render (matches current handle_prompts_get behavior).
287
+ { status: 200, body: jsonrpc_error(id, -32602, e.message) }
288
+ rescue StandardError => e
289
+ log_internal_error(logger, e)
290
+ { status: 200, body: jsonrpc_error(id, -32603, "Internal error") }
291
+ end
292
+ private_class_method :dispatch
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Handlers — each returns a plain Hash that becomes the JSON-RPC `result`.
296
+ # If the handler needs to signal a protocol-level error it returns a Hash
297
+ # with an :error key (same convention as mcp_server.rb).
298
+ # ---------------------------------------------------------------------------
299
+
300
+ # Handle the `initialize` MCP handshake.
301
+ #
302
+ # Per the MCP lifecycle spec, the server MUST echo the client's
303
+ # requested `protocolVersion` when it can support that version,
304
+ # and SHOULD respond with another supported version otherwise so
305
+ # the client can decide to proceed or disconnect. Strict clients
306
+ # disconnect on a version they did not request — silently always
307
+ # returning the server's preferred version locks those clients
308
+ # out.
309
+ #
310
+ # @return [Hash] protocol version, capabilities, and server info.
311
+ def self.handle_initialize(params)
312
+ requested = params.is_a?(Hash) ? params["protocolVersion"] : nil
313
+ negotiated =
314
+ if requested.is_a?(String) && SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
315
+ requested
316
+ else
317
+ PROTOCOL_VERSION
318
+ end
319
+ {
320
+ "protocolVersion" => negotiated,
321
+ "capabilities" => CAPABILITIES,
322
+ "serverInfo" => {
323
+ "name" => "parse-stack-mcp",
324
+ "version" => Parse::Stack::VERSION,
325
+ },
326
+ }
327
+ end
328
+ private_class_method :handle_initialize
329
+
330
+ # Handle `tools/list`.
331
+ #
332
+ # Accepts an optional non-standard `category` param (Parse Stack
333
+ # extension). Vanilla MCP clients omit it and receive the full
334
+ # allowed-tools list unchanged. Clients that know about the
335
+ # extension can pass a category string ("schema", "query",
336
+ # "aggregate", "mutation", "export", or any custom value) to
337
+ # filter the response server-side. Tool descriptors always carry
338
+ # `_meta.category` for client-side filtering as well.
339
+ #
340
+ # @param params [Hash] JSON-RPC params (optional `category`).
341
+ # @param agent [Parse::Agent] used to retrieve allowed tool definitions.
342
+ # @return [Hash] `{ "tools" => [...] }`
343
+ def self.handle_tools_list(params, agent)
344
+ category = params.is_a?(Hash) ? params["category"] : nil
345
+ { "tools" => agent.tool_definitions(format: :mcp, category: category) }
346
+ end
347
+ private_class_method :handle_tools_list
348
+
349
+ # Handle `tools/call`.
350
+ #
351
+ # Tool execution failures (agent returns `success: false`) are returned as
352
+ # MCP tool errors (`isError: true` in content) — NOT as a JSON-RPC `error`
353
+ # field. This matches the MCP spec distinction between protocol errors and
354
+ # tool-level errors.
355
+ #
356
+ # @param agent [Parse::Agent] used to execute the named tool.
357
+ # @return [Hash] MCP content envelope (always a `result`, never `error`).
358
+ def self.handle_tools_call(params, agent)
359
+ tool_name = params["name"]
360
+ arguments = params["arguments"] || {}
361
+
362
+ unless tool_name
363
+ return { error: { "code" => -32602, "message" => "Missing tool name" } }
364
+ end
365
+
366
+ sym_args = arguments.transform_keys(&:to_sym)
367
+ result = agent.execute(tool_name.to_sym, **sym_args)
368
+
369
+ # Cancellation short-circuit. Tools cooperate by returning a
370
+ # `success: false, cancelled: true` envelope when `agent.cancelled?`
371
+ # is observed at a checkpoint. The dispatcher additionally double-
372
+ # checks `agent.cancelled?` after execute returns, catching the case
373
+ # where the cancellation landed after the tool's last checkpoint
374
+ # but before it returned (the tool finished its work normally; we
375
+ # still honor the client's intent by not surfacing the result).
376
+ if result[:cancelled] || (agent.respond_to?(:cancelled?) && agent.cancelled?)
377
+ return {
378
+ "content" => [
379
+ { "type" => "text", "text" => (result[:error] || "Cancelled by client").to_s },
380
+ ],
381
+ "isError" => true,
382
+ "cancelled" => true,
383
+ }
384
+ end
385
+
386
+ if result[:success]
387
+ text = JSON.pretty_generate(result[:data])
388
+ if text.bytesize > MAX_TOOL_RESPONSE_BYTES
389
+ # For row-shaped and hash-of-records tool results, try to recover
390
+ # with a partial success: drop the heaviest field from all rows
391
+ # (and trailing rows/records if needed) and annotate the response
392
+ # with a _truncated block. Models handle partial success much
393
+ # better than full refusal — they continue the task instead of
394
+ # restarting. Other tools fall through to the structural refusal.
395
+ recovered_text =
396
+ if %w[query_class get_objects aggregate].include?(tool_name)
397
+ attempt_truncate_response(result[:data], MAX_TOOL_RESPONSE_BYTES, tool_name)
398
+ end
399
+
400
+ if recovered_text
401
+ {
402
+ "content" => [
403
+ { "type" => "text", "text" => recovered_text },
404
+ ],
405
+ "isError" => false,
406
+ }
407
+ else
408
+ # Refuse oversized tool results structurally — give the LLM
409
+ # client a clear signal to narrow the request instead of silently
410
+ # buffering tens of MB. isError: true (not a JSON-RPC error) so
411
+ # the model can adapt mid-loop.
412
+ diagnosis = diagnose_oversize(result[:data])
413
+ msg = +"Tool result exceeded #{MAX_TOOL_RESPONSE_BYTES} bytes (#{text.bytesize})."
414
+ msg << " #{diagnosis}" if diagnosis
415
+ msg << " Narrow the query: lower limit:, project fewer fields via keys:/select:, or add stricter where: constraints."
416
+ {
417
+ "content" => [
418
+ { "type" => "text", "text" => msg },
419
+ ],
420
+ "isError" => true,
421
+ }
422
+ end
423
+ else
424
+ envelope = {
425
+ "content" => [
426
+ { "type" => "text", "text" => text },
427
+ ],
428
+ "isError" => false,
429
+ }
430
+ # MCP 2025-06-18 structured output: when the tool declared
431
+ # an outputSchema via Tools.register(..., output_schema:),
432
+ # mirror the result data as `structuredContent`. The text
433
+ # content stays as the human-readable representation; the
434
+ # structured form is the machine-readable truth.
435
+ if Parse::Agent::Tools.output_schema_for(tool_name)
436
+ envelope["structuredContent"] = result[:data]
437
+ end
438
+ envelope
439
+ end
440
+ else
441
+ {
442
+ "content" => [
443
+ { "type" => "text", "text" => result[:error].to_s },
444
+ ],
445
+ "isError" => true,
446
+ }
447
+ end
448
+ end
449
+ private_class_method :handle_tools_call
450
+
451
+ # Sample the tool's result envelope and produce a one-line diagnostic
452
+ # naming the fields that contribute the most bytes per record. Returns
453
+ # nil if the data shape isn't amenable to per-field analysis. Called
454
+ # only on the oversize-refusal path — sampling cost is acceptable
455
+ # because the request is already failing.
456
+ #
457
+ # Invariants the sampler relies on (must hold by construction in
458
+ # Parse::Agent::Tools before the result reaches the dispatcher):
459
+ # 1. `redact_hidden_classes!` has already walked the rows and
460
+ # replaced embedded objects whose className matches a hidden
461
+ # class with a `{className, __redacted: true}` placeholder. The
462
+ # sampler therefore cannot fingerprint hidden-class field
463
+ # contents via byte sizing.
464
+ # 2. The `agent_fields` allowlist has already projected the rows so
465
+ # that disallowed fields are not present. The byte-per-field
466
+ # breakdown therefore covers only fields the caller was already
467
+ # permitted to see.
468
+ #
469
+ # @api private
470
+ def self.diagnose_oversize(data)
471
+ return nil unless data.is_a?(Hash)
472
+
473
+ # export_data short-circuit: without per-column byte sampling, an
474
+ # unranked column list is actively misleading — models read
475
+ # left-to-right as a size ordering. Return nil and let the generic
476
+ # narrowing guidance carry the message.
477
+ return nil if data[:output].is_a?(String) && data[:headers].is_a?(Array)
478
+
479
+ rows =
480
+ if data[:results].is_a?(Array) then data[:results]
481
+ elsif data["results"].is_a?(Array) then data["results"]
482
+ elsif data[:objects].is_a?(Hash) then data[:objects].values
483
+ elsif data["objects"].is_a?(Hash) then data["objects"].values
484
+ elsif data[:object].is_a?(Hash) then [data[:object]]
485
+ elsif data["object"].is_a?(Hash) then [data["object"]]
486
+ end
487
+
488
+ return nil unless rows.is_a?(Array) && rows.any?
489
+
490
+ sample = rows.first(5).select { |r| r.is_a?(Hash) }
491
+ return nil if sample.empty?
492
+
493
+ bytes_per_field = Hash.new(0)
494
+ sample.each do |row|
495
+ row.each do |k, v|
496
+ bytes_per_field[k.to_s] += v.to_json.bytesize
497
+ rescue StandardError, SystemStackError
498
+ # Unserializable value or pointer-cycle recursion limit — skip
499
+ # the field rather than fail the diagnostic.
500
+ next
501
+ end
502
+ end
503
+
504
+ return nil if bytes_per_field.empty?
505
+
506
+ sorted = bytes_per_field.sort_by { |_, b| -b }
507
+ top = sorted.first(3)
508
+ n = sample.size.to_f
509
+ largest = sorted.first[0]
510
+
511
+ # Produce a POSITIVE keys: list rather than asking the LLM to
512
+ # subtract. `keys:` is inclusive — models that see "excluding 'X'"
513
+ # sometimes emit Mongo-style `keys: "-X"` (wrong) or drop keys:
514
+ # altogether (worse). Constructing a complete keep-list removes
515
+ # that retry misfire entirely.
516
+ keep_fields = bytes_per_field.keys - [largest]
517
+ keep_fields = (keep_fields | TRUNCATION_ALWAYS_KEEP).uniq
518
+
519
+ formatted = top.map { |k, b| "#{k} (~#{humanize_bytes(b / n)}/record)" }.join(", ")
520
+ "Largest fields by bytes: #{formatted}. " \
521
+ "Try keys: #{keep_fields.join(",").inspect} (drops the heaviest field)."
522
+ end
523
+ private_class_method :diagnose_oversize
524
+
525
+ # Fields that should always be retained in any `keys:` projection.
526
+ # objectId is required for pointer dereferencing and follow-up
527
+ # `get_object` calls; createdAt/updatedAt are nearly free and almost
528
+ # always wanted.
529
+ # @api private
530
+ TRUNCATION_ALWAYS_KEEP = %w[objectId createdAt updatedAt].freeze
531
+ private_constant :TRUNCATION_ALWAYS_KEEP
532
+
533
+ # Identify the heaviest field by total bytes across a sample of rows.
534
+ # Returns the field name as a String, or nil if no fields can be sized.
535
+ #
536
+ # Rescues SystemStackError on cyclic hashes so one bad row never aborts
537
+ # the whole diagnostic.
538
+ #
539
+ # @api private
540
+ def self.find_heaviest_field(sample_rows)
541
+ bytes_per_field = Hash.new(0)
542
+ sample_rows.each do |row|
543
+ next unless row.is_a?(Hash)
544
+ row.each do |k, v|
545
+ bytes_per_field[k.to_s] += v.to_json.bytesize
546
+ rescue StandardError, SystemStackError
547
+ next
548
+ end
549
+ end
550
+ return nil if bytes_per_field.empty?
551
+
552
+ bytes_per_field.max_by { |_, b| b }.first
553
+ end
554
+ private_class_method :find_heaviest_field
555
+
556
+ # Try to recover from an oversize tool response by dropping the heaviest
557
+ # field from every row (and trailing rows/records if the field alone
558
+ # isn't enough). Returns the serialized recovered text on success, or
559
+ # nil if the response can't fit even one row/record.
560
+ #
561
+ # Branches on data shape:
562
+ # - data[:results].is_a?(Array) → row-array path (query_class, aggregate)
563
+ # - data[:objects].is_a?(Hash) → hash-of-records path (get_objects)
564
+ #
565
+ # The recovered payload includes a `_truncated` annotation block so
566
+ # an LLM client can detect the partial-success path and continue:
567
+ # - reason — fixed string identifying the trigger
568
+ # - dropped_fields — array of field names removed from every row/record
569
+ # - kept_count — rows/records actually emitted
570
+ # - original_count — rows/records the underlying tool produced
571
+ # - next_skip — (query_class only) set when rows were dropped; pass
572
+ # this as `skip:` to resume pagination through the same dataset
573
+ # - dropped_for_size — (get_objects only) IDs moved out of `objects`
574
+ # because even the field-trimmed records didn't fit within the cap
575
+ # - hint — short instruction telling the model how to
576
+ # recover the dropped field for a specific record
577
+ #
578
+ # @param data [Hash] the tool's result[:data] hash.
579
+ # @param max_bytes [Integer] byte cap for the serialized response.
580
+ # @param tool_name [String] caller tool name — drives hint wording
581
+ # and whether next_skip pagination applies.
582
+ # @return [String, nil] recovered JSON text, or nil if unrecoverable.
583
+ # @api private
584
+ def self.attempt_truncate_response(data, max_bytes, tool_name)
585
+ return nil unless data.is_a?(Hash)
586
+
587
+ if (data[:results] || data["results"]).is_a?(Array)
588
+ attempt_truncate_row_array(data, max_bytes, tool_name)
589
+ elsif (data[:objects] || data["objects"]).is_a?(Hash)
590
+ attempt_truncate_objects_hash(data, max_bytes)
591
+ end
592
+ end
593
+ private_class_method :attempt_truncate_response
594
+
595
+ # Row-array recovery path for query_class and aggregate.
596
+ # @api private
597
+ def self.attempt_truncate_row_array(data, max_bytes, tool_name)
598
+ rows = data[:results] || data["results"]
599
+ return nil unless rows.is_a?(Array) && rows.any?
600
+
601
+ sample = rows.first(5).select { |r| r.is_a?(Hash) }
602
+ return nil if sample.empty?
603
+
604
+ heaviest = find_heaviest_field(sample)
605
+ return nil unless heaviest
606
+
607
+ # Drop the heaviest field from every row (shallow copy — leaves
608
+ # the caller's original hashes untouched).
609
+ trimmed_rows = rows.map do |row|
610
+ row.is_a?(Hash) ? row.reject { |k, _| k.to_s == heaviest } : row
611
+ end
612
+
613
+ # Read the caller's effective skip so next_skip can resume rather
614
+ # than reset pagination. Only relevant for query_class; aggregate
615
+ # pipelines are deterministic and not paginatable.
616
+ pagination = data[:pagination] || data["pagination"] || {}
617
+ original_skip = (pagination[:skip] || pagination["skip"] || 0).to_i
618
+
619
+ # The recovered envelope must strip stale cardinality keys so the
620
+ # LLM can't mistake the trimmed body for the full result set.
621
+ # `_truncated.original_count` carries the original cardinality.
622
+ # `truncated:`/`truncated_note:` come from ResultFormatter's row-
623
+ # display cap (50 rows) — a different concern from our byte cap.
624
+ # Stripping both ensures the only authoritative truncation signal
625
+ # in the recovered envelope is the `_truncated` block we own here.
626
+ #
627
+ # For aggregate, also strip the top-level :hint (auto-limit message)
628
+ # so the _truncated.hint is the sole guidance in the envelope.
629
+ candidate = data.dup
630
+ candidate.delete(:result_count)
631
+ candidate.delete("result_count")
632
+ candidate.delete(:truncated)
633
+ candidate.delete("truncated")
634
+ candidate.delete(:truncated_note)
635
+ candidate.delete("truncated_note")
636
+ if tool_name == "aggregate"
637
+ candidate.delete(:hint)
638
+ candidate.delete("hint")
639
+ end
640
+
641
+ # next_call from ResultFormatter is stale after truncation: its skip
642
+ # is skip+limit, but the trimmed recovery path uses a smaller resume
643
+ # offset (original_skip + fit_count). Strip it so the _truncated
644
+ # block is the sole authoritative pagination signal.
645
+ candidate.delete(:next_call)
646
+ candidate.delete("next_call")
647
+
648
+ initial_hint =
649
+ if tool_name == "aggregate"
650
+ "Field '#{heaviest}' was dropped from all rows to fit the #{max_bytes}-byte response cap. " \
651
+ "Narrow the pipeline with a $match or $project stage to reduce result size, " \
652
+ "or call get_object(class_name: <class>, object_id: <id>) for the dropped field."
653
+ else
654
+ "Field '#{heaviest}' was dropped from all rows to fit the #{max_bytes}-byte response cap. " \
655
+ "To retrieve it for a specific row, call get_object(class_name: <class>, object_id: <id>)."
656
+ end
657
+
658
+ annotation = {
659
+ reason: "response_exceeded_max_bytes",
660
+ dropped_fields: [heaviest],
661
+ kept_count: trimmed_rows.size,
662
+ original_count: rows.size,
663
+ hint: initial_hint,
664
+ }
665
+
666
+ # First try: heaviest field dropped, all rows kept.
667
+ candidate[:results] = trimmed_rows
668
+ candidate[:_truncated] = annotation
669
+ text = JSON.pretty_generate(candidate)
670
+ return text if text.bytesize <= max_bytes
671
+
672
+ # Still over budget — also drop trailing rows. Estimate fit count
673
+ # from the trimmed sample, then verify and back off by one if the
674
+ # estimator overshoots (JSON overhead).
675
+ sample_after = trimmed_rows.first([5, trimmed_rows.size].min)
676
+ sample_text = JSON.pretty_generate(sample_after)
677
+ per_row = sample_text.bytesize / sample_after.size.to_f
678
+
679
+ envelope_data = candidate.merge(results: [])
680
+ envelope_bytes = JSON.pretty_generate(envelope_data).bytesize
681
+ budget = max_bytes - envelope_bytes - 256 # safety margin
682
+ return nil if budget <= 0 || per_row <= 0
683
+
684
+ fit_count = (budget / per_row).floor
685
+ fit_count = [fit_count, trimmed_rows.size].min
686
+ return nil if fit_count < 1
687
+
688
+ loop do
689
+ candidate[:results] = trimmed_rows.first(fit_count)
690
+ candidate[:_truncated][:kept_count] = fit_count
691
+
692
+ if tool_name == "query_class"
693
+ # next_skip is relative to the same dataset the caller already
694
+ # paginated through — add the original skip so consecutive
695
+ # query_class calls advance instead of looping on page 0.
696
+ resume_skip = original_skip + fit_count
697
+ candidate[:_truncated][:next_skip] = resume_skip
698
+ candidate[:_truncated][:hint] =
699
+ "Field '#{heaviest}' was dropped and only the first #{fit_count} of #{rows.size} rows fit " \
700
+ "the #{max_bytes}-byte cap. Call query_class(skip: #{resume_skip}) to fetch the next page, " \
701
+ "or get_object(class_name: <class>, object_id: <id>) for the dropped field."
702
+ else
703
+ # aggregate: pipelines are deterministic, not paginatable.
704
+ candidate[:_truncated][:hint] =
705
+ "Field '#{heaviest}' was dropped and only the first #{fit_count} of #{rows.size} rows fit " \
706
+ "the #{max_bytes}-byte cap. Narrow the pipeline with a $match or $project stage to reduce " \
707
+ "result size, or call get_object(class_name: <class>, object_id: <id>) for the dropped field."
708
+ end
709
+
710
+ text = JSON.pretty_generate(candidate)
711
+ return text if text.bytesize <= max_bytes
712
+
713
+ fit_count -= 1
714
+ return nil if fit_count < 1
715
+ end
716
+ end
717
+ private_class_method :attempt_truncate_row_array
718
+
719
+ # Hash-of-records recovery path for get_objects.
720
+ #
721
+ # The `objects` hash maps objectId → record. We drop the heaviest field
722
+ # from every record; if still over budget, we move trailing records out
723
+ # of `objects` into a `dropped_for_size:` list in the `_truncated`
724
+ # annotation. The existing `missing:` array is left untouched — it
725
+ # represents IDs that did not exist on the server, not IDs we dropped
726
+ # for size reasons.
727
+ #
728
+ # @api private
729
+ def self.attempt_truncate_objects_hash(data, max_bytes)
730
+ objects = data[:objects] || data["objects"]
731
+ return nil unless objects.is_a?(Hash) && objects.any?
732
+
733
+ sample = objects.values.first(5).select { |r| r.is_a?(Hash) }
734
+ return nil if sample.empty?
735
+
736
+ heaviest = find_heaviest_field(sample)
737
+ return nil unless heaviest
738
+
739
+ # Drop the heaviest field from every record value (shallow copy).
740
+ trimmed_objects = objects.transform_values do |rec|
741
+ rec.is_a?(Hash) ? rec.reject { |k, _| k.to_s == heaviest } : rec
742
+ end
743
+
744
+ # Build a candidate envelope. `found` and `requested` still reflect
745
+ # server reality, as does `missing`. `_truncated.kept_count` is the
746
+ # authoritative "what made it into this response" count.
747
+ candidate = data.dup
748
+
749
+ annotation = {
750
+ reason: "response_exceeded_max_bytes",
751
+ dropped_fields: [heaviest],
752
+ kept_count: trimmed_objects.size,
753
+ original_count: objects.size,
754
+ dropped_for_size: [],
755
+ hint: "Field '#{heaviest}' was dropped from all records to fit the #{max_bytes}-byte response cap. " \
756
+ "To retrieve it for a specific record, call get_object(class_name: <class>, object_id: <id>).",
757
+ }
758
+
759
+ # First try: heaviest field dropped, all records kept.
760
+ candidate[:objects] = trimmed_objects
761
+ candidate[:_truncated] = annotation
762
+ text = JSON.pretty_generate(candidate)
763
+ return text if text.bytesize <= max_bytes
764
+
765
+ # Still over budget — drop trailing records by insertion order.
766
+ sample_after = trimmed_objects.values.first([5, trimmed_objects.size].min)
767
+ sample_text = JSON.pretty_generate(sample_after)
768
+ per_rec = sample_text.bytesize / sample_after.size.to_f
769
+
770
+ envelope_data = candidate.merge(objects: {})
771
+ envelope_bytes = JSON.pretty_generate(envelope_data).bytesize
772
+ budget = max_bytes - envelope_bytes - 256 # safety margin
773
+ return nil if budget <= 0 || per_rec <= 0
774
+
775
+ fit_count = (budget / per_rec).floor
776
+ fit_count = [fit_count, trimmed_objects.size].min
777
+ return nil if fit_count < 1
778
+
779
+ loop do
780
+ kept_keys = trimmed_objects.keys.first(fit_count)
781
+ dropped_keys = trimmed_objects.keys - kept_keys
782
+
783
+ candidate[:objects] = trimmed_objects.slice(*kept_keys)
784
+ candidate[:_truncated][:kept_count] = fit_count
785
+ candidate[:_truncated][:dropped_for_size] = dropped_keys
786
+ candidate[:_truncated][:hint] =
787
+ "Field '#{heaviest}' was dropped and only #{fit_count} of #{objects.size} records fit " \
788
+ "the #{max_bytes}-byte cap. IDs in dropped_for_size were omitted. " \
789
+ "Call get_object(class_name: <class>, object_id: <id>) to fetch any omitted record."
790
+
791
+ text = JSON.pretty_generate(candidate)
792
+ return text if text.bytesize <= max_bytes
793
+
794
+ fit_count -= 1
795
+ return nil if fit_count < 1
796
+ end
797
+ end
798
+ private_class_method :attempt_truncate_objects_hash
799
+
800
+ # @api private
801
+ def self.humanize_bytes(n)
802
+ n = n.to_f
803
+ return "#{n.round} B" if n < 1024
804
+ return "#{(n / 1024.0).round(1)} KB" if n < 1_048_576
805
+ "#{(n / 1_048_576.0).round(1)} MB"
806
+ end
807
+ private_class_method :humanize_bytes
808
+
809
+ # Handle `resources/list`.
810
+ #
811
+ # Exposes three virtual resources per Parse class: schema, count, and
812
+ # samples. Falls back to an empty list if the agent cannot fetch schemas.
813
+ #
814
+ # @param agent [Parse::Agent]
815
+ # @return [Hash] `{ "resources" => [...] }`
816
+ def self.handle_resources_list(_params, agent)
817
+ result = agent.execute(:get_all_schemas)
818
+ return { "resources" => [] } unless result[:success]
819
+
820
+ # `get_all_schemas` returns a structured envelope from ResultFormatter:
821
+ # { total:, note:, built_in: [...], custom: [...] }
822
+ # Earlier drafts of this handler read `result[:data][:classes]` (a key
823
+ # that never existed), which produced an empty resource catalog for
824
+ # every MCP client. We now concatenate `custom` and `built_in` and
825
+ # fall back to the legacy `classes` key in case a custom agent
826
+ # subclass returns the older shape.
827
+ data = result[:data] || {}
828
+ classes = (data[:custom] || []) + (data[:built_in] || [])
829
+ classes = data[:classes] || [] if classes.empty? && data[:classes]
830
+ resources = classes.flat_map do |cls|
831
+ name = cls[:name]
832
+ klass_desc = cls[:description] || "Parse class (#{cls[:type] || "Custom"})"
833
+ [
834
+ {
835
+ "uri" => "parse://#{name}/schema",
836
+ "name" => "#{name} schema",
837
+ "description" => "Field definitions and types for #{name}. #{klass_desc}",
838
+ "mimeType" => "application/json",
839
+ },
840
+ {
841
+ "uri" => "parse://#{name}/count",
842
+ "name" => "#{name} count",
843
+ "description" => "Total number of #{name} objects",
844
+ "mimeType" => "application/json",
845
+ },
846
+ {
847
+ "uri" => "parse://#{name}/samples",
848
+ "name" => "#{name} samples",
849
+ "description" => "Five most recent #{name} objects",
850
+ "mimeType" => "application/json",
851
+ },
852
+ ]
853
+ end
854
+ { "resources" => resources }
855
+ end
856
+ private_class_method :handle_resources_list
857
+
858
+ # Handle `resources/templates/list` (MCP 2025-06-18).
859
+ #
860
+ # Returns the three URI templates this server understands. Templates
861
+ # use RFC 6570 simple-expansion syntax (`{className}`) so clients
862
+ # can construct concrete URIs for any Parse class without scraping
863
+ # `resources/list`. The class-name expansion is unconstrained on
864
+ # the wire; the `resources/read` handler validates the expanded
865
+ # class name against the identifier regex and refuses unknown or
866
+ # malformed classes.
867
+ #
868
+ # The agent argument is unused — templates are server metadata, not
869
+ # tied to a specific agent's view of the schema. It is accepted for
870
+ # signature parity with sibling handlers.
871
+ #
872
+ # @param _agent [Parse::Agent] unused.
873
+ # @return [Hash] `{ "resourceTemplates" => [...] }`
874
+ def self.handle_resources_templates_list(_params, _agent)
875
+ {
876
+ "resourceTemplates" => [
877
+ {
878
+ "uriTemplate" => "parse://{className}/schema",
879
+ "name" => "Parse class schema",
880
+ "description" => "Field definitions and types for a Parse class. Expand {className} with any class your agent can list via tools/list or resources/list.",
881
+ "mimeType" => "application/json",
882
+ },
883
+ {
884
+ "uriTemplate" => "parse://{className}/count",
885
+ "name" => "Parse class object count",
886
+ "description" => "Total number of objects in a Parse class.",
887
+ "mimeType" => "application/json",
888
+ },
889
+ {
890
+ "uriTemplate" => "parse://{className}/samples",
891
+ "name" => "Parse class sample objects",
892
+ "description" => "Five most recent objects from a Parse class.",
893
+ "mimeType" => "application/json",
894
+ },
895
+ ],
896
+ }
897
+ end
898
+ private_class_method :handle_resources_templates_list
899
+
900
+ # Handle `resources/read`.
901
+ #
902
+ # URI format: `parse://<ClassName>/<kind>` where kind is one of
903
+ # `schema`, `count`, `samples`. The class name must match Parse's
904
+ # identifier shape. Defaults to `schema` when kind is omitted.
905
+ #
906
+ # @param agent [Parse::Agent]
907
+ # @return [Hash] MCP contents envelope or an error hash.
908
+ def self.handle_resources_read(params, agent)
909
+ uri = params["uri"].to_s
910
+ match = uri.match(%r{\Aparse://([A-Za-z_][A-Za-z0-9_]*)(?:/(schema|count|samples))?\z})
911
+ return { error: { "code" => -32602, "message" => "Invalid resource URI: #{uri}" } } unless match
912
+
913
+ class_name = match[1]
914
+ kind = match[2] || "schema"
915
+
916
+ result = case kind
917
+ when "schema"
918
+ agent.execute(:get_schema, class_name: class_name)
919
+ when "count"
920
+ agent.execute(:count_objects, class_name: class_name)
921
+ when "samples"
922
+ agent.execute(:get_sample_objects, class_name: class_name, limit: 5)
923
+ end
924
+
925
+ if result[:success]
926
+ {
927
+ "contents" => [
928
+ {
929
+ "uri" => uri,
930
+ "mimeType" => "application/json",
931
+ "text" => JSON.pretty_generate(result[:data]),
932
+ },
933
+ ],
934
+ }
935
+ else
936
+ { error: { "code" => -32603, "message" => result[:error].to_s } }
937
+ end
938
+ end
939
+ private_class_method :handle_resources_read
940
+
941
+ # Handle `prompts/list`.
942
+ #
943
+ # Delegates to `Parse::Agent::Prompts.list`, which returns an Array of
944
+ # prompt descriptor Hashes. The dispatcher wraps the array into the MCP
945
+ # envelope `{ "prompts" => [...] }`.
946
+ #
947
+ # @return [Hash] `{ "prompts" => [...] }`
948
+ def self.handle_prompts_list(_params)
949
+ { "prompts" => Parse::Agent::Prompts.list }
950
+ end
951
+ private_class_method :handle_prompts_list
952
+
953
+ # Handle `prompts/get`.
954
+ #
955
+ # Fully delegates to `Parse::Agent::Prompts.render(name, args)`, which
956
+ # returns the complete MCP messages envelope:
957
+ # { "description" => String, "messages" => [{ "role" => "user", ... }] }
958
+ #
959
+ # `Prompts.render` raises `Parse::Agent::ValidationError` for unknown
960
+ # prompt names or missing/invalid required arguments. The `dispatch`
961
+ # rescue clause converts those into JSON-RPC -32602 responses so the
962
+ # message text (including "Unknown prompt: <name>") reaches the caller.
963
+ #
964
+ # Additionally, the rendered text is checked against MAX_TOOL_RESPONSE_BYTES.
965
+ # If the first message's content text exceeds the cap, a JSON-RPC -32602
966
+ # error hash is returned so the dispatcher's envelope path handles it
967
+ # without raising. Large data belongs in tools, not prompts.
968
+ #
969
+ # @return [Hash] MCP messages envelope or an error hash with :error key.
970
+ def self.handle_prompts_get(params)
971
+ name = params["name"].to_s
972
+ args = params["arguments"] || {}
973
+ result = Parse::Agent::Prompts.render(name, args)
974
+
975
+ # Guard against oversized prompt renderers. The renderer is untrusted
976
+ # extension code; check defensively before returning to the caller.
977
+ messages = result["messages"]
978
+ if messages.is_a?(Array) && !messages.empty?
979
+ text = messages.first.dig("content", "text").to_s
980
+ if text.bytesize > MAX_TOOL_RESPONSE_BYTES
981
+ return { error: { "code" => -32602, "message" => "Prompt output exceeded #{MAX_TOOL_RESPONSE_BYTES} bytes. Renderers should produce concise prompts; large data goes through tools, not prompts." } }
982
+ end
983
+ end
984
+
985
+ result
986
+ end
987
+ private_class_method :handle_prompts_get
988
+
989
+ # ---------------------------------------------------------------------------
990
+ # Envelope helpers
991
+ # ---------------------------------------------------------------------------
992
+
993
+ # Build a complete JSON-RPC response envelope with string keys.
994
+ #
995
+ # @param id [Any] the JSON-RPC request id (may be nil for notifications).
996
+ # @param result [Hash, nil] the result payload (mutually exclusive with error).
997
+ # @param error [Hash, nil] the error payload (mutually exclusive with result).
998
+ # @return [Hash] JSON-RPC envelope with string keys.
999
+ def self.jsonrpc_envelope(id, result: nil, error: nil)
1000
+ envelope = { "jsonrpc" => "2.0", "id" => id }
1001
+ if error
1002
+ envelope["error"] = error
1003
+ else
1004
+ envelope["result"] = result || {}
1005
+ end
1006
+ envelope
1007
+ end
1008
+ private_class_method :jsonrpc_envelope
1009
+
1010
+ # Build a JSON-RPC error envelope.
1011
+ #
1012
+ # @param id [Any] the request id.
1013
+ # @param code [Integer] JSON-RPC error code.
1014
+ # @param message [String] human-readable error message (must NOT include
1015
+ # raw query content, user data, or internal stack information).
1016
+ # @return [Hash] JSON-RPC error envelope with string keys.
1017
+ def self.jsonrpc_error(id, code, message)
1018
+ jsonrpc_envelope(id, error: { "code" => code, "message" => message })
1019
+ end
1020
+ private_class_method :jsonrpc_error
1021
+ end
1022
+ end
1023
+ end