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
|
@@ -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
|