parse-stack-next 5.1.1 → 5.2.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 +4 -4
- data/.env.sample +12 -0
- data/.env.test +4 -4
- data/CHANGELOG.md +545 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +6 -1
- data/README.md +167 -38
- data/Rakefile +56 -10
- data/docs/atlas_vector_search_guide.md +110 -9
- data/docs/mcp_guide.md +433 -0
- data/docs/mongodb_direct_guide.md +66 -1
- data/docs/mongodb_index_optimization_guide.md +22 -1
- data/docs/usage_guide.md +15 -0
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +90 -19
- data/lib/parse/agent/describe.rb +1 -0
- data/lib/parse/agent/errors.rb +16 -0
- data/lib/parse/agent/mcp_client.rb +9 -0
- data/lib/parse/agent/mcp_dispatcher.rb +139 -7
- data/lib/parse/agent/mcp_rack_app.rb +621 -17
- data/lib/parse/agent/mcp_subscriptions.rb +607 -0
- data/lib/parse/agent/metadata_dsl.rb +58 -0
- data/lib/parse/agent/metadata_registry.rb +141 -1
- data/lib/parse/agent/prompt_hardening.rb +213 -0
- data/lib/parse/agent/result_formatter.rb +18 -3
- data/lib/parse/agent/tools.rb +167 -24
- data/lib/parse/agent.rb +692 -21
- data/lib/parse/client/request.rb +55 -4
- data/lib/parse/client/response.rb +4 -0
- data/lib/parse/client.rb +205 -7
- data/lib/parse/model/classes/installation.rb +27 -10
- data/lib/parse/model/classes/user.rb +8 -0
- data/lib/parse/model/core/actions.rb +58 -4
- data/lib/parse/model/core/embed_managed.rb +19 -14
- data/lib/parse/model/core/indexing.rb +108 -16
- data/lib/parse/model/core/querying.rb +29 -0
- data/lib/parse/model/model.rb +34 -3
- data/lib/parse/model/object.rb +1 -0
- data/lib/parse/query.rb +90 -24
- data/lib/parse/retrieval/agent_tool.rb +369 -0
- data/lib/parse/retrieval/chunk.rb +74 -0
- data/lib/parse/retrieval/chunker.rb +208 -0
- data/lib/parse/retrieval/retriever.rb +274 -0
- data/lib/parse/retrieval.rb +10 -0
- data/lib/parse/schema.rb +69 -20
- data/lib/parse/stack/version.rb +2 -2
- data/parse-stack-next.gemspec +1 -1
- data/scripts/docker/docker-compose.atlas.yml +14 -10
- data/scripts/docker/docker-compose.test.yml +24 -20
- data/scripts/docker/mongo-init.js +3 -3
- data/scripts/start-parse.sh +10 -0
- data/scripts/start_mcp_server.rb +1 -1
- data/scripts/test_server_connection.rb +1 -1
- data/scripts/vector_prototype/create_vector_index.js +1 -1
- data/scripts/vector_prototype/fetch_embeddings.py +2 -2
- data/scripts/vector_prototype/query_prototype.rb +1 -1
- data/scripts/vector_prototype/run.sh +4 -4
- metadata +10 -2
|
@@ -170,7 +170,11 @@ module Parse
|
|
|
170
170
|
# These enable bcrypt-hash and session-token oracle attacks via
|
|
171
171
|
# count deltas even when operators are otherwise clean.
|
|
172
172
|
assert_where_key_permitted!(key)
|
|
173
|
-
result[columnize(key)] =
|
|
173
|
+
result[columnize(key)] = if key == "$relatedTo"
|
|
174
|
+
translate_related_to_value(value, depth: 0, agent: agent)
|
|
175
|
+
else
|
|
176
|
+
translate_value(value, depth: 0, agent: agent)
|
|
177
|
+
end
|
|
174
178
|
end
|
|
175
179
|
end
|
|
176
180
|
|
|
@@ -213,30 +217,48 @@ module Parse
|
|
|
213
217
|
# Check if it's a Parse type (Pointer, Date, File, GeoPoint)
|
|
214
218
|
return hash if parse_type?(hash)
|
|
215
219
|
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
220
|
+
# Classify each key INDEPENDENTLY rather than branching on whether
|
|
221
|
+
# *every* key is an operator. A hash that mixes an operator key with a
|
|
222
|
+
# non-operator (field) sibling — reachable as a `$or`/`$and`/`$nor`
|
|
223
|
+
# array element — must still validate and dispatch the operator. The
|
|
224
|
+
# previous all-or-nothing `keys.all?(operator)` gate routed any mixed
|
|
225
|
+
# hash to the field branch, which skipped `validate_operator!` AND the
|
|
226
|
+
# cross-class / `$relatedTo` accessibility checks: a blocked operator
|
|
227
|
+
# (`$where`) or an off-allowlist cross-class / relation reference could
|
|
228
|
+
# smuggle through alongside a throwaway field key. Per-key dispatch
|
|
229
|
+
# closes that hole while preserving behavior for pure-operator and
|
|
230
|
+
# pure-field hashes.
|
|
231
|
+
hash.transform_keys(&:to_s).each_with_object({}) do |(k, v), result|
|
|
232
|
+
if k.start_with?("$")
|
|
233
|
+
result[k] = translate_operator_value(k, v, depth: depth, agent: agent)
|
|
234
|
+
else
|
|
235
|
+
# Field-name key: enforce the internal-field denylist at every
|
|
236
|
+
# nesting level (so a key nested inside `$and`/`$or`/`$nor` cannot
|
|
237
|
+
# bypass the top-level check), then columnize and recurse.
|
|
234
238
|
assert_where_key_permitted!(k)
|
|
235
239
|
result[columnize(k)] = translate_value(v, depth: depth + 1, agent: agent)
|
|
236
240
|
end
|
|
237
241
|
end
|
|
238
242
|
end
|
|
239
243
|
|
|
244
|
+
# Validate and translate a single operator (`$`-prefixed) key/value pair.
|
|
245
|
+
# Centralized so the operator denylist/whitelist and the cross-class /
|
|
246
|
+
# `$relatedTo` accessibility checks run for operators in pure-operator
|
|
247
|
+
# hashes AND for operators mixed with field-key siblings.
|
|
248
|
+
def translate_operator_value(op, val, depth:, agent: nil)
|
|
249
|
+
validate_operator!(op)
|
|
250
|
+
# NEW-TOOLS-7: validate $regex / $options operands before
|
|
251
|
+
# forwarding to MongoDB.
|
|
252
|
+
assert_regex_operand_safe!(op, val) if op == "$regex" || op == "$options"
|
|
253
|
+
if CROSS_CLASS_OPERATORS.include?(op)
|
|
254
|
+
translate_cross_class_value(op, val, depth: depth + 1, agent: agent)
|
|
255
|
+
elsif op == "$relatedTo"
|
|
256
|
+
translate_related_to_value(val, depth: depth + 1, agent: agent)
|
|
257
|
+
else
|
|
258
|
+
translate_value(val, depth: depth + 1, agent: agent)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
240
262
|
# Translate the value of a cross-class operator
|
|
241
263
|
# (+$inQuery+/+$notInQuery+/+$select+/+$dontSelect+). The value
|
|
242
264
|
# carries an embedded +className+ that must be validated against
|
|
@@ -299,6 +321,55 @@ module Parse
|
|
|
299
321
|
translate_value(val, depth: depth, agent: agent)
|
|
300
322
|
end
|
|
301
323
|
|
|
324
|
+
# Validate the owning-object class named by a +$relatedTo+ constraint.
|
|
325
|
+
#
|
|
326
|
+
# +$relatedTo+ has the shape +{ object: <Pointer>, key: <relation field> }+.
|
|
327
|
+
# Unlike +$inQuery+ / +$select+ it carries no +className+ / inner +where+,
|
|
328
|
+
# so it is NOT a {CROSS_CLASS_OPERATORS} entry — but it DOES reach across
|
|
329
|
+
# to a second class: the owning object whose relation is being read. Left
|
|
330
|
+
# unvalidated, an agent narrowed to one class (or with a class globally
|
|
331
|
+
# +agent_hidden+) could still name a relation anchored on an off-allowlist
|
|
332
|
+
# class via the +object+ pointer. That is the SDK-surface analog of
|
|
333
|
+
# GHSA-wmwx-jr2p-4j4r, where Parse Server's own +$relatedTo+ bypassed the
|
|
334
|
+
# owning object's ACL. Run the owning class through the same accessibility
|
|
335
|
+
# policy as every other cross-class hop, then translate the value normally.
|
|
336
|
+
#
|
|
337
|
+
# Fails closed when the owning class cannot be resolved from +object+: an
|
|
338
|
+
# unresolvable pointer is exactly the shape that would otherwise slip the
|
|
339
|
+
# check, so refuse the constraint rather than skip it.
|
|
340
|
+
def translate_related_to_value(val, depth:, agent: nil)
|
|
341
|
+
owning_class = related_to_owning_class(val)
|
|
342
|
+
if owning_class.nil? || owning_class.to_s.empty?
|
|
343
|
+
raise ConstraintSecurityError.new(
|
|
344
|
+
"SECURITY: $relatedTo requires a resolvable owning-object class; " \
|
|
345
|
+
"none could be determined from its `object` pointer.",
|
|
346
|
+
operator: "$relatedTo",
|
|
347
|
+
reason: :cross_class_denied,
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
assert_embedded_class_accessible!("$relatedTo", owning_class, agent: agent)
|
|
351
|
+
translate_value(val, depth: depth, agent: agent)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Extract the Parse class name of a +$relatedTo+ constraint's owning
|
|
355
|
+
# object from its +object+ slot, which may be a Parse::Pointer, a Parse
|
|
356
|
+
# pointer/relation hash (+{__type:, className:, objectId:}+, string or
|
|
357
|
+
# symbol keys), or a storage-form string (+"ClassName$objectId"+).
|
|
358
|
+
# Returns nil when no class can be resolved so the caller can fail closed.
|
|
359
|
+
def related_to_owning_class(val)
|
|
360
|
+
return nil unless val.is_a?(Hash)
|
|
361
|
+
obj = val["object"] || val[:object]
|
|
362
|
+
return obj.parse_class if obj.respond_to?(:parse_class)
|
|
363
|
+
case obj
|
|
364
|
+
when Hash
|
|
365
|
+
o = obj.transform_keys(&:to_s)
|
|
366
|
+
cn = o["className"]
|
|
367
|
+
cn.nil? || cn.to_s.empty? ? nil : cn
|
|
368
|
+
when String
|
|
369
|
+
obj.include?("$") ? obj.split("$", 2).first : nil
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
302
373
|
# Hook into the agent-side accessibility check when the agent
|
|
303
374
|
# module is loaded; in pure-unit contexts where +Parse::Agent::Tools+
|
|
304
375
|
# has not been loaded, default to a no-op rather than raising —
|
data/lib/parse/agent/describe.rb
CHANGED
data/lib/parse/agent/errors.rb
CHANGED
|
@@ -129,5 +129,21 @@ module Parse
|
|
|
129
129
|
# agent-callable" (a Parse::Error) or "the tier doesn't allow it"
|
|
130
130
|
# (a +:permission_denied+).
|
|
131
131
|
class MethodFiltered < AgentError; end
|
|
132
|
+
|
|
133
|
+
# Raised at semantic-search dispatch time when at least one class in
|
|
134
|
+
# the model registry declares +agent_tenant_scope+ but the class
|
|
135
|
+
# being searched does not. In a tenant-aware deployment an
|
|
136
|
+
# un-scoped searchable surface would let an agent retrieve across
|
|
137
|
+
# tenant boundaries, so the gate is a hard refusal, not a warning.
|
|
138
|
+
# Enforced at dispatch (when all classes are loaded) rather than at
|
|
139
|
+
# +agent_searchable+ declaration time so class-load order can't
|
|
140
|
+
# produce a false negative.
|
|
141
|
+
class MissingTenantScope < AgentError; end
|
|
142
|
+
|
|
143
|
+
# Raised when a tool result contains an operator-registered
|
|
144
|
+
# prompt-injection canary phrase AND `Parse::Agent.canary_action` is
|
|
145
|
+
# `:refuse`. A SecurityError subclass so it routes through execute's
|
|
146
|
+
# security rescue and is never swallowed.
|
|
147
|
+
class PromptInjectionDetected < SecurityError; end
|
|
132
148
|
end
|
|
133
149
|
end
|
|
@@ -528,7 +528,16 @@ module Parse
|
|
|
528
528
|
# @api private
|
|
529
529
|
def wrap_tool_content_for_llm(content)
|
|
530
530
|
s = content.to_s
|
|
531
|
+
# Idempotency first: our own already-wrapped output (marker at the
|
|
532
|
+
# head) passes through untouched, so re-wrapping across turns does
|
|
533
|
+
# not grow or mangle the marker.
|
|
531
534
|
return s if s.start_with?(UNTRUSTED_TOOL_RESULT_MARKER)
|
|
535
|
+
# Fresh content: neutralize any embedded wrapper/marker strings
|
|
536
|
+
# (e.g. a stored `</schema_description>` or a forged marker) BEFORE
|
|
537
|
+
# prepending the real marker, so a stored value can't impersonate or
|
|
538
|
+
# close the wrapper. The prefix we add afterward is therefore never
|
|
539
|
+
# seen as embedded by the scrub.
|
|
540
|
+
s = Parse::Agent::PromptHardening.scrub_marker_injection(s)
|
|
532
541
|
"#{UNTRUSTED_TOOL_RESULT_MARKER}\n#{s}"
|
|
533
542
|
end
|
|
534
543
|
|
|
@@ -132,7 +132,16 @@ module Parse
|
|
|
132
132
|
# are translated into a JSON-RPC `isError` content envelope by
|
|
133
133
|
# {#handle_tools_call}. Cleared from the agent in an ensure block
|
|
134
134
|
# before this method returns.
|
|
135
|
-
|
|
135
|
+
# @param subscription_manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
136
|
+
# the per-transport resource-subscription coordinator. When present and
|
|
137
|
+
# {Parse::Agent::MCPSubscriptions::Manager#supported? supported}, the
|
|
138
|
+
# `initialize` handshake advertises the `resources.subscribe` capability
|
|
139
|
+
# and `resources/subscribe` / `resources/unsubscribe` are routed to it.
|
|
140
|
+
# nil (the default, and the only option on non-streaming transports like
|
|
141
|
+
# the WEBrick MCPServer) leaves the capability unadvertised and those
|
|
142
|
+
# methods returning a "not supported" error.
|
|
143
|
+
def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil,
|
|
144
|
+
subscription_manager: nil, approval_gate: nil)
|
|
136
145
|
# Snapshot any prior callback/token already on the agent (e.g. a
|
|
137
146
|
# token a parent dispatcher installed before a tool handler
|
|
138
147
|
# invoked us recursively, or values pre-set by the application).
|
|
@@ -143,6 +152,7 @@ module Parse
|
|
|
143
152
|
# token.
|
|
144
153
|
prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
|
|
145
154
|
prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
|
|
155
|
+
prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
|
|
146
156
|
|
|
147
157
|
# Install the progress callback and cancellation token on the
|
|
148
158
|
# agent for the duration of the dispatch. Cleared in the ensure
|
|
@@ -156,6 +166,10 @@ module Parse
|
|
|
156
166
|
# is documented to return a fresh agent per request.
|
|
157
167
|
agent.progress_callback = progress_callback if progress_callback && agent.respond_to?(:progress_callback=)
|
|
158
168
|
agent.cancellation_token = cancellation_token if cancellation_token && agent.respond_to?(:cancellation_token=)
|
|
169
|
+
# Install the per-session approval gate (MCP elicitation) so
|
|
170
|
+
# agent.execute can request human approval for destructive tools.
|
|
171
|
+
# Restored in the ensure block like the other per-request state.
|
|
172
|
+
agent.approval_gate = approval_gate if approval_gate && agent.respond_to?(:approval_gate=)
|
|
159
173
|
|
|
160
174
|
# Guard: body must be a Hash with a "method" key.
|
|
161
175
|
unless body.is_a?(Hash) && body.key?("method")
|
|
@@ -175,7 +189,7 @@ module Parse
|
|
|
175
189
|
return { status: 200, body: jsonrpc_error(id, -32600, "Invalid Request: notifications must not carry an id") }
|
|
176
190
|
end
|
|
177
191
|
|
|
178
|
-
result_hash = dispatch(method, params, agent, id, logger)
|
|
192
|
+
result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
|
|
179
193
|
{ status: result_hash[:status], body: result_hash[:body] }
|
|
180
194
|
|
|
181
195
|
rescue Parse::Agent::Unauthorized => e
|
|
@@ -197,6 +211,9 @@ module Parse
|
|
|
197
211
|
if agent.respond_to?(:cancellation_token=)
|
|
198
212
|
agent.cancellation_token = prev_cancellation_token
|
|
199
213
|
end
|
|
214
|
+
if agent.respond_to?(:approval_gate=)
|
|
215
|
+
agent.approval_gate = prev_approval_gate
|
|
216
|
+
end
|
|
200
217
|
end
|
|
201
218
|
|
|
202
219
|
# Emit an internal-error diagnostic. The class+message are operator-only;
|
|
@@ -219,10 +236,10 @@ module Parse
|
|
|
219
236
|
# envelope, and return { status:, body: }.
|
|
220
237
|
#
|
|
221
238
|
# @api private
|
|
222
|
-
def self.dispatch(method, params, agent, id, logger = nil)
|
|
239
|
+
def self.dispatch(method, params, agent, id, logger = nil, subscription_manager = nil)
|
|
223
240
|
result = case method
|
|
224
241
|
when "initialize"
|
|
225
|
-
handle_initialize(params)
|
|
242
|
+
handle_initialize(params, subscription_manager)
|
|
226
243
|
when "tools/list"
|
|
227
244
|
handle_tools_list(params, agent)
|
|
228
245
|
when "tools/call"
|
|
@@ -233,6 +250,10 @@ module Parse
|
|
|
233
250
|
handle_resources_templates_list(params, agent)
|
|
234
251
|
when "resources/read"
|
|
235
252
|
handle_resources_read(params, agent)
|
|
253
|
+
when "resources/subscribe"
|
|
254
|
+
handle_resources_subscribe(params, agent, subscription_manager)
|
|
255
|
+
when "resources/unsubscribe"
|
|
256
|
+
handle_resources_unsubscribe(params, agent, subscription_manager)
|
|
236
257
|
when "prompts/list"
|
|
237
258
|
handle_prompts_list(params)
|
|
238
259
|
when "prompts/get"
|
|
@@ -278,6 +299,12 @@ module Parse
|
|
|
278
299
|
|
|
279
300
|
rescue Parse::Agent::Unauthorized => e
|
|
280
301
|
{ status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
|
|
302
|
+
rescue Parse::Agent::AccessDenied
|
|
303
|
+
# Class-authorization denial (agent_hidden / classes: allowlist), e.g.
|
|
304
|
+
# from the resources/subscribe gate. Map to -32602 with a generic
|
|
305
|
+
# message — do NOT echo the class name, so a denied subscribe can't be
|
|
306
|
+
# used to probe which hidden classes exist.
|
|
307
|
+
{ status: 200, body: jsonrpc_error(id, -32602, "Invalid params") }
|
|
281
308
|
rescue Parse::Agent::SecurityError
|
|
282
309
|
{ status: 200, body: jsonrpc_error(id, -32602, "Invalid params") }
|
|
283
310
|
rescue Parse::Agent::ValidationError => e
|
|
@@ -307,8 +334,11 @@ module Parse
|
|
|
307
334
|
# returning the server's preferred version locks those clients
|
|
308
335
|
# out.
|
|
309
336
|
#
|
|
337
|
+
# @param subscription_manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
338
|
+
# when supported, flips the advertised `resources.subscribe` capability
|
|
339
|
+
# to true. See {#capabilities_for}.
|
|
310
340
|
# @return [Hash] protocol version, capabilities, and server info.
|
|
311
|
-
def self.handle_initialize(params)
|
|
341
|
+
def self.handle_initialize(params, subscription_manager = nil)
|
|
312
342
|
requested = params.is_a?(Hash) ? params["protocolVersion"] : nil
|
|
313
343
|
negotiated =
|
|
314
344
|
if requested.is_a?(String) && SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
|
|
@@ -318,7 +348,7 @@ module Parse
|
|
|
318
348
|
end
|
|
319
349
|
{
|
|
320
350
|
"protocolVersion" => negotiated,
|
|
321
|
-
"capabilities" =>
|
|
351
|
+
"capabilities" => capabilities_for(subscription_manager),
|
|
322
352
|
"serverInfo" => {
|
|
323
353
|
"name" => "parse-stack-mcp",
|
|
324
354
|
"version" => Parse::Stack::VERSION,
|
|
@@ -327,6 +357,27 @@ module Parse
|
|
|
327
357
|
end
|
|
328
358
|
private_class_method :handle_initialize
|
|
329
359
|
|
|
360
|
+
# Compute the advertised capability object for this transport.
|
|
361
|
+
#
|
|
362
|
+
# `resources.subscribe` is advertised as `true` ONLY when a subscription
|
|
363
|
+
# manager is wired AND reports itself supported (LiveQuery enabled +
|
|
364
|
+
# available, on a streaming transport that can hold a listening channel).
|
|
365
|
+
# Advertising a capability is a contract: we never claim `subscribe: true`
|
|
366
|
+
# unless the server can actually deliver `notifications/resources/updated`.
|
|
367
|
+
# On the WEBrick MCPServer (no streaming) and on the Rack app when
|
|
368
|
+
# subscriptions are disabled, this falls back to the base CAPABILITIES
|
|
369
|
+
# with `subscribe: false`.
|
|
370
|
+
#
|
|
371
|
+
# @param manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
372
|
+
# @return [Hash]
|
|
373
|
+
def self.capabilities_for(manager)
|
|
374
|
+
return CAPABILITIES unless manager.respond_to?(:supported?) && manager.supported?
|
|
375
|
+
CAPABILITIES.merge(
|
|
376
|
+
"resources" => CAPABILITIES["resources"].merge("subscribe" => true),
|
|
377
|
+
)
|
|
378
|
+
end
|
|
379
|
+
private_class_method :capabilities_for
|
|
380
|
+
|
|
330
381
|
# Handle `tools/list`.
|
|
331
382
|
#
|
|
332
383
|
# Accepts an optional non-standard `category` param (Parse Stack
|
|
@@ -438,12 +489,24 @@ module Parse
|
|
|
438
489
|
envelope
|
|
439
490
|
end
|
|
440
491
|
else
|
|
441
|
-
|
|
492
|
+
# Forward the structured error metadata the agent already computed
|
|
493
|
+
# (error_code, retry_after, details such as suggested_rewrite /
|
|
494
|
+
# allowed_fields) so a client can branch deterministically and
|
|
495
|
+
# honor retry_after — instead of re-parsing the prose message. Goes
|
|
496
|
+
# in `_meta` (spec-allowed arbitrary metadata) under a `parse.`
|
|
497
|
+
# prefix; the human-readable text content is unchanged.
|
|
498
|
+
envelope = {
|
|
442
499
|
"content" => [
|
|
443
500
|
{ "type" => "text", "text" => result[:error].to_s },
|
|
444
501
|
],
|
|
445
502
|
"isError" => true,
|
|
446
503
|
}
|
|
504
|
+
meta = {}
|
|
505
|
+
meta["parse.error_code"] = result[:error_code].to_s if result[:error_code]
|
|
506
|
+
meta["parse.retry_after"] = result[:retry_after] if result[:retry_after]
|
|
507
|
+
meta["parse.details"] = result[:details] if result[:details].is_a?(Hash) && result[:details].any?
|
|
508
|
+
envelope["_meta"] = meta unless meta.empty?
|
|
509
|
+
envelope
|
|
447
510
|
end
|
|
448
511
|
end
|
|
449
512
|
private_class_method :handle_tools_call
|
|
@@ -938,6 +1001,75 @@ module Parse
|
|
|
938
1001
|
end
|
|
939
1002
|
private_class_method :handle_resources_read
|
|
940
1003
|
|
|
1004
|
+
# Handle `resources/subscribe` (MCP 2025-06-18).
|
|
1005
|
+
#
|
|
1006
|
+
# Registers a LiveQuery-backed subscription for `params["uri"]` keyed by
|
|
1007
|
+
# the agent's session identity (`correlation_id`, sourced from the
|
|
1008
|
+
# `Mcp-Session-Id` header by the transport). Subsequent data changes are
|
|
1009
|
+
# debounced and delivered as `notifications/resources/updated` over the
|
|
1010
|
+
# session's GET listening stream.
|
|
1011
|
+
#
|
|
1012
|
+
# Per the MCP spec a successful subscribe returns an empty result. Errors
|
|
1013
|
+
# propagate as JSON-RPC errors:
|
|
1014
|
+
# - manager absent / unsupported → -32601 (capability not offered)
|
|
1015
|
+
# - malformed or non-subscribable URI → -32602 (ValidationError)
|
|
1016
|
+
# - agent scope with no LiveQuery equivalent → -32602 (SecurityError)
|
|
1017
|
+
#
|
|
1018
|
+
# @param manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
1019
|
+
# @return [Hash] empty result, or an `:error` hash when unsupported.
|
|
1020
|
+
def self.handle_resources_subscribe(params, agent, manager)
|
|
1021
|
+
return subscriptions_unsupported_error unless manager.respond_to?(:supported?) && manager.supported?
|
|
1022
|
+
ok = manager.subscribe(
|
|
1023
|
+
session_id: agent_session_id(agent),
|
|
1024
|
+
uri: params["uri"].to_s,
|
|
1025
|
+
agent: agent,
|
|
1026
|
+
)
|
|
1027
|
+
return {} if ok
|
|
1028
|
+
# subscribe returned false: the session's listening stream was torn down
|
|
1029
|
+
# (detach_listener) while the network subscribe was in flight, so no
|
|
1030
|
+
# subscription exists and no notifications/resources/updated will arrive.
|
|
1031
|
+
# Surface an error rather than a false empty-success ack (per the MCP
|
|
1032
|
+
# contract an empty result == subscribed) so the client reopens its GET
|
|
1033
|
+
# stream and retries instead of waiting forever for updates.
|
|
1034
|
+
{ error: { "code" => -32602,
|
|
1035
|
+
"message" => "resources/subscribe: the session no longer has an open " \
|
|
1036
|
+
"listening stream; reopen the GET stream and retry" } }
|
|
1037
|
+
end
|
|
1038
|
+
private_class_method :handle_resources_subscribe
|
|
1039
|
+
|
|
1040
|
+
# Handle `resources/unsubscribe` (MCP 2025-06-18). Idempotent — stops the
|
|
1041
|
+
# LiveQuery subscription for the URI if one exists, returns an empty
|
|
1042
|
+
# result regardless.
|
|
1043
|
+
#
|
|
1044
|
+
# @param manager [Parse::Agent::MCPSubscriptions::Manager, nil]
|
|
1045
|
+
# @return [Hash] empty result, or an `:error` hash when unsupported.
|
|
1046
|
+
def self.handle_resources_unsubscribe(params, agent, manager)
|
|
1047
|
+
return subscriptions_unsupported_error unless manager.respond_to?(:supported?) && manager.supported?
|
|
1048
|
+
manager.unsubscribe(
|
|
1049
|
+
session_id: agent_session_id(agent),
|
|
1050
|
+
uri: params["uri"].to_s,
|
|
1051
|
+
)
|
|
1052
|
+
{}
|
|
1053
|
+
end
|
|
1054
|
+
private_class_method :handle_resources_unsubscribe
|
|
1055
|
+
|
|
1056
|
+
# The session identity used to key resource subscriptions. The transport
|
|
1057
|
+
# populates `agent.correlation_id` from `Mcp-Session-Id`.
|
|
1058
|
+
# @return [String, nil]
|
|
1059
|
+
def self.agent_session_id(agent)
|
|
1060
|
+
agent.respond_to?(:correlation_id) ? agent.correlation_id : nil
|
|
1061
|
+
end
|
|
1062
|
+
private_class_method :agent_session_id
|
|
1063
|
+
|
|
1064
|
+
# Error hash returned when a subscribe/unsubscribe arrives but this
|
|
1065
|
+
# transport does not offer the capability. -32601 (method not found) is
|
|
1066
|
+
# the correct code per JSON-RPC for an unoffered method.
|
|
1067
|
+
# @return [Hash]
|
|
1068
|
+
def self.subscriptions_unsupported_error
|
|
1069
|
+
{ error: { "code" => -32601, "message" => "Resource subscriptions are not supported by this server" } }
|
|
1070
|
+
end
|
|
1071
|
+
private_class_method :subscriptions_unsupported_error
|
|
1072
|
+
|
|
941
1073
|
# Handle `prompts/list`.
|
|
942
1074
|
#
|
|
943
1075
|
# Delegates to `Parse::Agent::Prompts.list`, which returns an Array of
|