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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.env.sample +12 -0
  3. data/.env.test +4 -4
  4. data/CHANGELOG.md +545 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +6 -1
  7. data/README.md +167 -38
  8. data/Rakefile +56 -10
  9. data/docs/atlas_vector_search_guide.md +110 -9
  10. data/docs/mcp_guide.md +433 -0
  11. data/docs/mongodb_direct_guide.md +66 -1
  12. data/docs/mongodb_index_optimization_guide.md +22 -1
  13. data/docs/usage_guide.md +15 -0
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +90 -19
  16. data/lib/parse/agent/describe.rb +1 -0
  17. data/lib/parse/agent/errors.rb +16 -0
  18. data/lib/parse/agent/mcp_client.rb +9 -0
  19. data/lib/parse/agent/mcp_dispatcher.rb +139 -7
  20. data/lib/parse/agent/mcp_rack_app.rb +621 -17
  21. data/lib/parse/agent/mcp_subscriptions.rb +607 -0
  22. data/lib/parse/agent/metadata_dsl.rb +58 -0
  23. data/lib/parse/agent/metadata_registry.rb +141 -1
  24. data/lib/parse/agent/prompt_hardening.rb +213 -0
  25. data/lib/parse/agent/result_formatter.rb +18 -3
  26. data/lib/parse/agent/tools.rb +167 -24
  27. data/lib/parse/agent.rb +692 -21
  28. data/lib/parse/client/request.rb +55 -4
  29. data/lib/parse/client/response.rb +4 -0
  30. data/lib/parse/client.rb +205 -7
  31. data/lib/parse/model/classes/installation.rb +27 -10
  32. data/lib/parse/model/classes/user.rb +8 -0
  33. data/lib/parse/model/core/actions.rb +58 -4
  34. data/lib/parse/model/core/embed_managed.rb +19 -14
  35. data/lib/parse/model/core/indexing.rb +108 -16
  36. data/lib/parse/model/core/querying.rb +29 -0
  37. data/lib/parse/model/model.rb +34 -3
  38. data/lib/parse/model/object.rb +1 -0
  39. data/lib/parse/query.rb +90 -24
  40. data/lib/parse/retrieval/agent_tool.rb +369 -0
  41. data/lib/parse/retrieval/chunk.rb +74 -0
  42. data/lib/parse/retrieval/chunker.rb +208 -0
  43. data/lib/parse/retrieval/retriever.rb +274 -0
  44. data/lib/parse/retrieval.rb +10 -0
  45. data/lib/parse/schema.rb +69 -20
  46. data/lib/parse/stack/version.rb +2 -2
  47. data/parse-stack-next.gemspec +1 -1
  48. data/scripts/docker/docker-compose.atlas.yml +14 -10
  49. data/scripts/docker/docker-compose.test.yml +24 -20
  50. data/scripts/docker/mongo-init.js +3 -3
  51. data/scripts/start-parse.sh +10 -0
  52. data/scripts/start_mcp_server.rb +1 -1
  53. data/scripts/test_server_connection.rb +1 -1
  54. data/scripts/vector_prototype/create_vector_index.js +1 -1
  55. data/scripts/vector_prototype/fetch_embeddings.py +2 -2
  56. data/scripts/vector_prototype/query_prototype.rb +1 -1
  57. data/scripts/vector_prototype/run.sh +4 -4
  58. 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)] = translate_value(value, depth: 0, agent: agent)
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
- # Check if all keys are operators
217
- if hash.keys.all? { |k| k.to_s.start_with?("$") }
218
- hash.transform_keys(&:to_s).each_with_object({}) do |(op, val), result|
219
- validate_operator!(op)
220
- # NEW-TOOLS-7: validate $regex / $options operands before
221
- # forwarding to MongoDB.
222
- assert_regex_operand_safe!(op, val) if op == "$regex" || op == "$options"
223
- result[op] = if CROSS_CLASS_OPERATORS.include?(op)
224
- translate_cross_class_value(op, val, depth: depth + 1, agent: agent)
225
- else
226
- translate_value(val, depth: depth + 1, agent: agent)
227
- end
228
- end
229
- else
230
- # Regular nested object - translate keys to columnized format.
231
- # Apply the internal-field key denylist at every nesting level so
232
- # a key nested inside $and/$or/$nor cannot bypass the top-level check.
233
- hash.transform_keys(&:to_s).each_with_object({}) do |(k, v), result|
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 —
@@ -185,6 +185,7 @@ module Parse
185
185
  class_filter: strict_class_filter?,
186
186
  },
187
187
  correlation_id: @correlation_id,
188
+ prompt: { version: Parse::Agent::PROMPT_VERSION },
188
189
  }
189
190
  end
190
191
 
@@ -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
- def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil)
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" => 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