parse-stack-next 5.3.0 → 5.4.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
@@ -20,6 +20,34 @@ module Parse
20
20
  # (method, content-type, body-size, and JSON-parse checks) and then
21
21
  # delegates to Parse::Agent::MCPDispatcher.call for all protocol handling.
22
22
  #
23
+ # == Transport (`transport: :streamable_http`)
24
+ #
25
+ # The MCP 2025-06-18 "Streamable HTTP" transport is the recommended,
26
+ # primary transport. Rather than toggling its constituent pieces
27
+ # individually (`streaming:` for POST→SSE, `notifications:` for the
28
+ # server→client `GET /` stream), pass `transport: :streamable_http` to
29
+ # enable the whole transport with one switch:
30
+ #
31
+ # app = Parse::Agent::MCPRackApp.new(transport: :streamable_http) { |env| ... }
32
+ #
33
+ # That is exactly equivalent to `streaming: true, notifications: true`.
34
+ # `resource_subscriptions: true` may still be added alongside it to
35
+ # upgrade the server→client bus from the plain notification posture to
36
+ # the LiveQuery-backed resource-subscription posture.
37
+ #
38
+ # `transport:` is a closed enum — `:streamable_http`, `:legacy`, or `nil`.
39
+ # `:legacy` and `nil` both select the historical default (no streaming, no
40
+ # server→client stream); the standalone SSE/JSON behavior remains a
41
+ # supported fallback. Passing `transport: :streamable_http` together with
42
+ # an explicit `streaming:` or `notifications:` raises `ArgumentError`,
43
+ # since the switch already owns those toggles.
44
+ #
45
+ # The default is unchanged (`transport: nil`): an existing
46
+ # `MCPRackApp.new { ... }` keeps its non-streaming JSON behavior. A
47
+ # streaming-capable Rack server (Puma, Falcon, Unicorn) is required for
48
+ # `:streamable_http` to have any effect — the WEBrick-backed `MCPServer`
49
+ # buffers responses and cannot deliver it.
50
+ #
23
51
  # == SSE Streaming (MCP progress notifications)
24
52
  #
25
53
  # When constructed with `streaming: true`, requests that include
@@ -83,6 +111,16 @@ module Parse
83
111
  # Default heartbeat interval in seconds when streaming is enabled.
84
112
  DEFAULT_HEARTBEAT_INTERVAL = 2
85
113
 
114
+ # Default bound on concurrently-active streaming dispatchers — and,
115
+ # separately, on concurrently-open listening streams — when the
116
+ # `max_concurrent_dispatchers:` constructor argument is omitted. Finite by
117
+ # default so that enabling a streaming surface (request-scoped SSE or the
118
+ # long-lived `GET /` stream) does not silently expose an unbounded
119
+ # orphan-thread DoS surface. The cap is applied SEPARATELY to each
120
+ # surface, so the effective ceiling across both is up to 2x this value.
121
+ # Pass an explicit `nil` to knowingly opt into the unbounded surface.
122
+ DEFAULT_MAX_CONCURRENT_DISPATCHERS = 100
123
+
86
124
  # Seconds to wait for a human's elicitation reply before failing
87
125
  # closed (refusing the destructive op). Generous by default — a
88
126
  # human-in-the-loop approver needs time the tool timeout doesn't
@@ -110,6 +148,27 @@ module Parse
110
148
  @listening_stream_count = 0
111
149
  @listening_stream_mutex = Mutex.new
112
150
 
151
+ # Process-wide CUMULATIVE counter of GENUINE orphaned dispatchers — a
152
+ # client disconnected (stream closed before its response was delivered)
153
+ # WHILE the dispatcher thread was still running (see
154
+ # {.abandoned_dispatcher_count}). It deliberately excludes the
155
+ # already-finished-but-undelivered case (dispatcher had pushed its
156
+ # response but the client dropped before {#each} popped it), which is a
157
+ # delivery miss, not an orphan holding a connection-pool slot. A monotonic
158
+ # counter (not a live gauge like the two above): operators watch its rate
159
+ # of increase to detect a disconnect storm against slow tools, which is the
160
+ # orphan-thread pressure signal. (The companion
161
+ # `parse.agent.mcp_dispatcher_abandoned` notification fires for EVERY
162
+ # premature close and carries a `dispatcher_alive` flag, so subscribers can
163
+ # also observe the delivery-miss case and filter on `dispatcher_alive:
164
+ # true` for orphans.) The orphaned dispatcher is cooperatively cancelled
165
+ # (its token is tripped) and bounded in duration by the per-tool Timeout
166
+ # and the clean MongoDB/REST I/O timeouts; it is intentionally NOT
167
+ # force-killed (see {SSEBody#close} for why a hard kill would risk
168
+ # connection-pool corruption).
169
+ @abandoned_dispatcher_count = 0
170
+ @abandoned_dispatcher_mutex = Mutex.new
171
+
113
172
  # Drop env keys that would have come from underscore-form HTTP header
114
173
  # names. The Rack-spec-compliant interpretation of HTTP headers maps
115
174
  # `X-MCP-API-Key` and `X_MCP_API_KEY` to the same env key
@@ -171,13 +230,20 @@ module Parse
171
230
  # @param heartbeat_interval [Numeric] seconds between progress heartbeat
172
231
  # events when streaming is active. Defaults to DEFAULT_HEARTBEAT_INTERVAL.
173
232
  # Ignored when `streaming: false`.
174
- # @param max_concurrent_dispatchers [Integer, nil] when set, limits the
175
- # number of concurrently active dispatcher threads across all SSE
176
- # connections served by this app instance. When the limit is reached a
177
- # new SSE request immediately receives a 503 JSON-RPC error envelope
178
- # (`-32000` "server busy") rather than spawning another dispatcher.
179
- # Defaults to `nil` (unlimited). Use `active_dispatcher_count` to
180
- # monitor current concurrency from operator tooling.
233
+ # @param max_concurrent_dispatchers [Integer, nil] limits the number of
234
+ # concurrently active dispatcher threads across all SSE connections
235
+ # served by this app instance (and, separately, the number of open
236
+ # listening streams). When the limit is reached a new SSE request
237
+ # immediately receives a 503 JSON-RPC error envelope (`-32000` "server
238
+ # busy") rather than spawning another dispatcher.
239
+ #
240
+ # Defaults to a finite {DEFAULT_MAX_CONCURRENT_DISPATCHERS} (100) — so a
241
+ # streaming surface is bounded out of the box rather than unbounded.
242
+ # Pass an explicit positive `Integer` to set the cap, or `nil` to
243
+ # knowingly opt into the unbounded surface (which warns at
244
+ # construction). A non-positive or non-integer value raises
245
+ # `ArgumentError`. Use `active_dispatcher_count` to monitor current
246
+ # concurrency from operator tooling.
181
247
  # @param pre_auth_rate_limiter [#check!, nil] optional rate limiter
182
248
  # consulted at the top of every request, BEFORE the agent_factory is
183
249
  # invoked. Closes the factory-amplification DoS where each malformed
@@ -234,17 +300,30 @@ module Parse
234
300
  # adapter). Takes precedence over `resource_subscriptions:`. When nil
235
301
  # and `resource_subscriptions: true`, a default in-process manager is
236
302
  # constructed.
303
+ # @param transport [Symbol, nil] MCP transport selector. Pass
304
+ # `:streamable_http` to enable the full MCP 2025-06-18 Streamable HTTP
305
+ # transport in one switch — exactly equivalent to `streaming: true,
306
+ # notifications: true` (POST→SSE plus the server→client `GET /`
307
+ # stream). `resource_subscriptions: true` may still be combined to
308
+ # upgrade the bus to its LiveQuery-backed posture. `:legacy` (or the
309
+ # default `nil`) selects the historical non-streaming behavior; the
310
+ # standalone SSE/JSON path stays a supported fallback. Any other value
311
+ # raises `ArgumentError`. Passing `:streamable_http` together with an
312
+ # explicit `streaming:` or `notifications:` also raises, since the
313
+ # switch already owns those toggles. Requires a streaming-capable Rack
314
+ # server (Puma, Falcon, Unicorn); has no effect under WEBrick.
237
315
  # @raise [ArgumentError] if both or neither of agent_factory/block are given.
238
316
  def initialize(agent_factory: nil, max_body_size: DEFAULT_MAX_BODY_SIZE,
239
- logger: nil, streaming: false,
317
+ logger: nil, streaming: nil,
240
318
  heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
241
- max_concurrent_dispatchers: nil,
319
+ max_concurrent_dispatchers: DEFAULT_MAX_CONCURRENT_DISPATCHERS,
242
320
  pre_auth_rate_limiter: nil,
243
321
  allowed_origins: nil,
244
322
  require_custom_header: nil,
245
323
  resource_subscriptions: false,
246
324
  subscription_manager: nil,
247
- notifications: false,
325
+ notifications: nil,
326
+ transport: nil,
248
327
  approval_timeout: DEFAULT_APPROVAL_TIMEOUT,
249
328
  principal_resolver: nil,
250
329
  health_path: nil, &block)
@@ -258,11 +337,41 @@ module Parse
258
337
  raise ArgumentError, "pre_auth_rate_limiter must respond to #check!"
259
338
  end
260
339
 
340
+ # `transport:` is the consolidation switch over the granular
341
+ # `streaming:` / `notifications:` toggles. `streaming` and
342
+ # `notifications` default to nil (not false) precisely so we can tell
343
+ # "operator left it alone" from "operator explicitly set it" and raise
344
+ # on a conflicting combination instead of silently letting the switch
345
+ # win. Closed enum — unknown values fail closed.
346
+ unless transport.nil? || %i[legacy streamable_http].include?(transport)
347
+ raise ArgumentError,
348
+ "transport: must be :streamable_http, :legacy, or nil, got #{transport.inspect}"
349
+ end
350
+ if transport == :streamable_http
351
+ unless streaming.nil? && notifications.nil?
352
+ raise ArgumentError,
353
+ "transport: :streamable_http already enables streaming and the server-initiated " \
354
+ "notification stream; do not also pass streaming:/notifications: " \
355
+ "(resource_subscriptions: may still be combined to upgrade the bus to LiveQuery)"
356
+ end
357
+ streaming = true
358
+ notifications = true
359
+ end
360
+ # Collapse the nil sentinel to the historical default for the
361
+ # remainder of the constructor (and @streaming below).
362
+ streaming = false if streaming.nil?
363
+ notifications = false if notifications.nil?
364
+
261
365
  @agent_factory = agent_factory || block
262
366
  @max_body_size = max_body_size
263
367
  @logger = logger
264
368
  @streaming = streaming
265
369
  @heartbeat_interval = heartbeat_interval
370
+ # The dispatcher cap defaults to the finite DEFAULT_MAX_CONCURRENT_DISPATCHERS
371
+ # (set in the signature). An explicit positive Integer overrides it; an
372
+ # explicit nil knowingly opts into the unbounded surface; anything else
373
+ # is a config error and raises.
374
+ validate_max_concurrent_dispatchers!(max_concurrent_dispatchers)
266
375
  @max_concurrent_dispatchers = max_concurrent_dispatchers
267
376
  @pre_auth_rate_limiter = pre_auth_rate_limiter
268
377
  @allowed_origins = normalize_allowed_origins(allowed_origins)
@@ -319,21 +428,24 @@ module Parse
319
428
  Parse::Agent::MCPSubscriptions::Manager.new(logger: @logger, supported: false)
320
429
  end
321
430
 
322
- # Warn operators who enable a streaming surface without a concurrency
323
- # cap. Both request-scoped SSE (streaming:) and the long-lived GET
324
- # listening stream (resource_subscriptions:/notifications:, which set
431
+ # Warn operators who enable a streaming surface AND have explicitly
432
+ # opted into an unbounded dispatcher cap. Both request-scoped SSE
433
+ # (streaming:) and the long-lived GET listening stream
434
+ # (resource_subscriptions:/notifications:, which set
325
435
  # @subscription_manager) spawn per-connection threads; an unbounded
326
436
  # endpoint is a practical DoS surface — a slow or hostile client opening
327
437
  # connections faster than they close can exhaust the host thread pool and
328
438
  # downstream Parse connection pool. The cap bounds each surface
329
439
  # SEPARATELY, so the effective ceiling is up to 2x max_concurrent_dispatchers
330
- # across both. Leaving the default `nil` (unlimited) preserves backward
331
- # compatibility, but we tell the operator once at construction.
440
+ # across both. The default is now the finite DEFAULT_MAX_CONCURRENT_DISPATCHERS,
441
+ # so a nil here means the operator deliberately chose `nil` (unbounded) —
442
+ # we warn once at construction so the choice is visible.
332
443
  if (streaming || @subscription_manager) && @max_concurrent_dispatchers.nil?
333
444
  surface = streaming ? "streaming: true" : "resource_subscriptions/notifications"
334
- line = "[Parse::Agent::MCPRackApp] #{surface} with max_concurrent_dispatchers: nil (unlimited). " \
335
- "Set a finite cap (e.g. 100, or 2x your Puma max_threads) to bound the orphan-thread DoS surface. " \
336
- "See docs/mcp_guide.md for sizing guidance."
445
+ line = "[Parse::Agent::MCPRackApp] #{surface} with an explicitly unbounded dispatcher cap " \
446
+ "(max_concurrent_dispatchers: nil). This is an orphan-thread DoS surface. " \
447
+ "Prefer the finite default (#{DEFAULT_MAX_CONCURRENT_DISPATCHERS}) or pass a value sized to " \
448
+ "~2x your Puma max_threads. See docs/mcp_guide.md for sizing guidance."
337
449
  if @logger
338
450
  @logger.warn(line)
339
451
  else
@@ -409,6 +521,28 @@ module Parse
409
521
  @listening_stream_mutex.synchronize { @listening_stream_count += delta }
410
522
  end
411
523
 
524
+ # Process-wide CUMULATIVE count of GENUINE orphaned dispatchers — a client
525
+ # disconnect that closed the stream while the dispatcher thread was still
526
+ # running. Excludes already-finished-but-undelivered closes (a delivery
527
+ # miss, not an orphan). Unlike {.active_dispatcher_count} /
528
+ # {.active_listening_stream_count} this is a monotonic total, not a live
529
+ # gauge — operators alert on its *rate* of increase, the orphan-thread
530
+ # pressure signal under a disconnect-against-slow-tools storm. EVERY
531
+ # premature close (orphan or delivery-miss) also emits a
532
+ # `parse.agent.mcp_dispatcher_abandoned` ActiveSupport::Notifications event
533
+ # carrying `dispatcher_alive:`, so subscribers wanting the broader
534
+ # delivery-miss signal can filter there. Reset is not supported (counters
535
+ # are process-lifetime); subtract a baseline if you need a windowed delta.
536
+ def self.abandoned_dispatcher_count
537
+ @abandoned_dispatcher_mutex.synchronize { @abandoned_dispatcher_count }
538
+ end
539
+
540
+ # @api private — increment the cumulative abandoned-dispatcher counter.
541
+ # Called by {SSEBody#close} on the client-disconnect path.
542
+ def self.record_abandoned_dispatcher!
543
+ @abandoned_dispatcher_mutex.synchronize { @abandoned_dispatcher_count += 1 }
544
+ end
545
+
412
546
  # Rack interface.
413
547
  #
414
548
  # @param env [Hash] Rack environment
@@ -1104,6 +1238,10 @@ module Parse
1104
1238
  ->(t, i) { t.join(i) }
1105
1239
  @queue = Queue.new
1106
1240
  @worker = nil
1241
+ # The dispatcher thread spawned inside @worker. Published under
1242
+ # @close_mutex once started so {#close} can snapshot its liveness for
1243
+ # the abandonment signal. Never force-killed (see #close).
1244
+ @dispatcher_thread = nil
1107
1245
  # Flipped to true by #each when the DONE sentinel is consumed.
1108
1246
  # #close uses this to decide whether to trip the cancellation
1109
1247
  # token (false = client disconnect) or skip the trip (true =
@@ -1158,38 +1296,56 @@ module Parse
1158
1296
  # sentinel was not consumed by {#each}), this is interpreted as
1159
1297
  # a client disconnect and:
1160
1298
  #
1161
- # 1. The cancellation token (if any) is tripped BEFORE the
1162
- # worker is killed, so tools that observe `agent.cancelled?`
1163
- # at a checkpoint can exit cooperatively. The kill becomes
1164
- # the fallback for tools stuck inside a blocking I/O call.
1299
+ # 1. The cancellation token (if any) is tripped, so a tool that
1300
+ # observes `agent.cancelled?` at a checkpoint exits
1301
+ # cooperatively. The orphaned dispatcher is NOT force-killed
1302
+ # (see below); its lifetime is bounded by the per-tool
1303
+ # Timeout and the clean MongoDB/REST I/O deadlines.
1304
+ # 2. The abandonment is recorded — a `parse.agent.mcp_dispatcher_abandoned`
1305
+ # notification is emitted for every premature close, and the
1306
+ # process-wide {MCPRackApp.abandoned_dispatcher_count} counter is
1307
+ # bumped when the dispatcher was still running (a genuine orphan) —
1308
+ # so operators can see disconnect-against-slow-tool pressure even
1309
+ # though each orphan is individually bounded.
1165
1310
  #
1166
- # When called AFTER normal completion, the token is NOT tripped
1167
- # — the request finished on its own; cancellation would only
1168
- # confuse a tool that races to check the flag.
1311
+ # When called AFTER normal completion, neither happens the
1312
+ # request finished on its own; cancellation would only confuse a
1313
+ # tool that races to check the flag, and there is nothing to
1314
+ # report.
1169
1315
  #
1170
1316
  # Either path:
1171
- # - Kills the worker thread if still alive.
1317
+ # - Kills the WORKER thread (the heartbeat loop) if still alive.
1172
1318
  # - Invokes the on_close hook so MCPRackApp can deregister
1173
1319
  # the token from its per-app registry. Failures in the hook
1174
1320
  # are logged and swallowed — close must always succeed.
1175
1321
  #
1176
- # Cancellation note: blocking I/O calls (MongoDB query, Parse
1177
- # REST roundtrip) do not observe the token until they return.
1178
- # The Ruby-level `Timeout.timeout` already wrapping each tool is
1179
- # the hard upper bound on wasted work; cancellation reduces it,
1180
- # not eliminates it.
1322
+ # Why the dispatcher is not force-killed: a `Thread#kill` (or a
1323
+ # foreign `Thread#raise`) skips the DB driver's rescue-based
1324
+ # connection-invalidation, so `connection_pool`'s `ensure` could
1325
+ # return a half-used connection to the pool and corrupt a later
1326
+ # request that reuses it. Blocking I/O calls do not observe the
1327
+ # cancellation token, but they ARE bounded by the per-tool
1328
+ # `Timeout.timeout` (Tools::TOOL_TIMEOUTS, 5–60s) and the clean
1329
+ # MongoDB `socket_timeout` (10s) / REST `timeout` (30s) deadlines,
1330
+ # which reclaim the connection-pool slot through the driver's
1331
+ # clean error path. Cooperative cancellation reduces wasted work;
1332
+ # the bounded timeouts cap it; a forcible kill is intentionally
1333
+ # avoided.
1181
1334
  def close
1182
1335
  # Idempotent — concurrent invocations from the I/O fiber and
1183
1336
  # a disconnect-handler thread short-circuit after the first
1184
1337
  # caller wins the mutex.
1185
1338
  completed_normally = nil
1339
+ dispatcher_alive = false
1186
1340
  @close_mutex.synchronize do
1187
1341
  return if @closed
1188
1342
  @closed = true
1189
1343
  completed_normally = @completed_normally
1344
+ dispatcher_alive = @dispatcher_thread&.alive? || false
1190
1345
  end
1191
1346
  unless completed_normally
1192
1347
  @cancellation_token&.cancel!(reason: :client_disconnect)
1348
+ record_abandonment(dispatcher_alive)
1193
1349
  end
1194
1350
  @worker&.kill if @worker&.alive?
1195
1351
  @worker = nil
@@ -1227,6 +1383,36 @@ module Parse
1227
1383
 
1228
1384
  private
1229
1385
 
1386
+ # Record a client-disconnect abandonment. `dispatcher_alive` reports
1387
+ # whether the dispatcher was still running at close time (true = a
1388
+ # genuine mid-flight orphan holding its slot; false = it had already
1389
+ # finished but the DONE sentinel was never consumed — a delivery miss).
1390
+ #
1391
+ # The cumulative counter tracks GENUINE orphans only (gated on
1392
+ # `dispatcher_alive`), matching {MCPRackApp.abandoned_dispatcher_count}'s
1393
+ # contract. The `parse.agent.mcp_dispatcher_abandoned` notification fires
1394
+ # for EVERY premature close and carries the flag, so subscribers can see
1395
+ # delivery misses too and filter on `dispatcher_alive: true` for orphans.
1396
+ # Best-effort and fully guarded — observability must never break stream
1397
+ # teardown.
1398
+ #
1399
+ # Subscriber discipline matches the rest of the SDK's instrumentation:
1400
+ # subscribers run synchronously on the thread that calls close (a Rack
1401
+ # I/O fiber or a disconnect-handler thread); keep them cheap.
1402
+ def record_abandonment(dispatcher_alive)
1403
+ MCPRackApp.record_abandoned_dispatcher! if dispatcher_alive
1404
+ return unless defined?(ActiveSupport::Notifications)
1405
+ ActiveSupport::Notifications.instrument(
1406
+ "parse.agent.mcp_dispatcher_abandoned",
1407
+ reason: :client_disconnect,
1408
+ dispatcher_alive: dispatcher_alive,
1409
+ request_id: @req_id,
1410
+ )
1411
+ rescue StandardError => e
1412
+ line = "[Parse::Agent::MCPRackApp::SSEBody] abandonment-record error: #{e.class}: #{e.message}"
1413
+ @logger ? @logger.warn(line) : warn(line)
1414
+ end
1415
+
1230
1416
  def start_worker
1231
1417
  # Subscribe to listChanged events BEFORE spawning the worker
1232
1418
  # so any registry mutation that races with the start of the
@@ -1250,40 +1436,62 @@ module Parse
1250
1436
  # every @interval seconds until the call completes OR until a
1251
1437
  # tool starts reporting its own progress (@tool_progress_reported).
1252
1438
  #
1253
- # Cancellation note: if the consumer disconnects (close is called),
1254
- # the outer @worker is killed but dispatcher_thread is orphaned and
1255
- # runs to completion. A proper cancellation mechanism (e.g. passing
1256
- # a cancel token into MCPDispatcher) is a separate deferred item
1257
- # (see CHANGELOG / project plans).
1439
+ # Cancellation contract on client disconnect (close is called):
1440
+ # the outer @worker is killed and the dispatcher thread is
1441
+ # cooperatively cancelled {#close} trips the cancellation token
1442
+ # so a tool checking `agent.cancelled?` at a checkpoint exits
1443
+ # promptly. The dispatcher is NOT force-killed: a `Thread#kill`
1444
+ # (or a foreign `Thread#raise`) would skip the DB driver's
1445
+ # rescue-based connection-invalidation, so connection_pool's
1446
+ # ensure could check a half-used connection back in and corrupt a
1447
+ # subsequent request. Instead the orphan's lifetime is bounded by
1448
+ # (a) for BUILT-IN tools, the per-tool `Timeout.timeout` budget
1449
+ # (Tools::TOOL_TIMEOUTS, 5–60s, applied inside each built-in tool
1450
+ # via Tools.with_timeout) and (b) the clean MongoDB `socket_timeout`
1451
+ # (10s) / REST `timeout` (30s) I/O deadlines, which DO route through
1452
+ # the driver's clean error path. For CUSTOM registered tools the
1453
+ # handler IS wrapped by Tools.invoke in its declared `timeout:`
1454
+ # (default 30s; register rejects a non-positive value), so a
1455
+ # blocking or looping custom handler is bounded just like a
1456
+ # built-in. (A handler that swallows ToolTimeoutError or blocks in
1457
+ # an uninterruptible C call can still evade Timeout, but the default
1458
+ # path is bounded.) The
1459
+ # max_concurrent_dispatchers: cap still bounds how MANY orphans can
1460
+ # exist; the abandonment counter + `parse.agent.mcp_dispatcher_abandoned`
1461
+ # notification surface how OFTEN it happens (see {#close} and
1462
+ # {.abandoned_dispatcher_count}).
1258
1463
  #
1259
- # Each dispatcher_thread is tagged with :parse_mcp_dispatcher so
1260
- # operators can observe concurrency via
1261
- # Parse::Agent::MCPRackApp.active_dispatcher_count. Orphaned
1262
- # dispatchers (from client disconnects) are counted until they
1263
- # complete naturally. Forcible kill is intentionally not attempted
1264
- # here killing threads inside MCPDispatcher.call risks leaving
1265
- # agent state corrupt. The max_concurrent_dispatchers: constructor
1266
- # option provides a concurrency cap that fires 503 before a new
1267
- # dispatcher is admitted.
1268
- dispatcher_thread = Thread.new do
1269
- Thread.current[:parse_mcp_dispatcher] = true
1270
- begin
1271
- # The block receives the SSEBody's progress callback so
1272
- # tools running inside MCPDispatcher.call can emit
1273
- # notifications/progress events without coupling to
1274
- # SSEBody internals.
1275
- result = @dispatcher_blk.call(@progress_callback)
1276
- rescue StandardError => e
1277
- # Log the unexpected failure (MCPDispatcher.call normally catches
1278
- # StandardError internally; anything reaching here is unusual).
1279
- line = "[Parse::Agent::MCPRackApp::SSEBody] Dispatcher error: #{e.class}: #{e.message}"
1280
- if @logger
1281
- @logger.warn(line)
1282
- else
1283
- warn line
1464
+ # Each dispatcher thread is tagged with :parse_mcp_dispatcher so
1465
+ # operators can observe live concurrency via
1466
+ # {.active_dispatcher_count}. The spawn and the publish to
1467
+ # @dispatcher_thread happen together under @close_mutex so a
1468
+ # concurrent {#close} (e.g. an out-of-band disconnect-handler
1469
+ # thread) observes the thread as either entirely absent or fully
1470
+ # published never a created-but-unpublished orphan it would
1471
+ # miscount as a delivery miss.
1472
+ dispatcher_thread = nil
1473
+ @close_mutex.synchronize do
1474
+ dispatcher_thread = Thread.new do
1475
+ Thread.current[:parse_mcp_dispatcher] = true
1476
+ begin
1477
+ # The block receives the SSEBody's progress callback so
1478
+ # tools running inside MCPDispatcher.call can emit
1479
+ # notifications/progress events without coupling to
1480
+ # SSEBody internals.
1481
+ result = @dispatcher_blk.call(@progress_callback)
1482
+ rescue StandardError => e
1483
+ # Log the unexpected failure (MCPDispatcher.call normally catches
1484
+ # StandardError internally; anything reaching here is unusual).
1485
+ line = "[Parse::Agent::MCPRackApp::SSEBody] Dispatcher error: #{e.class}: #{e.message}"
1486
+ if @logger
1487
+ @logger.warn(line)
1488
+ else
1489
+ warn line
1490
+ end
1491
+ result = { status: 200, body: build_error_envelope(e) }
1284
1492
  end
1285
- result = { status: 200, body: build_error_envelope(e) }
1286
1493
  end
1494
+ @dispatcher_thread = dispatcher_thread
1287
1495
  end
1288
1496
 
1289
1497
  while dispatcher_thread.alive?
@@ -1850,6 +2058,21 @@ module Parse
1850
2058
  })
1851
2059
  end
1852
2060
 
2061
+ # Validate the `max_concurrent_dispatchers:` argument. A positive Integer
2062
+ # caps the streaming surface; an explicit `nil` is a knowing opt-in to the
2063
+ # unbounded surface (warned about at construction); anything else is a
2064
+ # code-level config error and raises loudly.
2065
+ #
2066
+ # @param value [Object] the constructor argument.
2067
+ # @raise [ArgumentError] when value is neither nil nor a positive Integer.
2068
+ def validate_max_concurrent_dispatchers!(value)
2069
+ return if value.nil?
2070
+ unless value.is_a?(Integer) && value >= 1
2071
+ raise ArgumentError,
2072
+ "max_concurrent_dispatchers must be a positive Integer or nil (unbounded), got #{value.inspect}"
2073
+ end
2074
+ end
2075
+
1853
2076
  # Normalize the allowed-origins kwarg into a frozen Array of
1854
2077
  # downcased entries. Returns nil when the caller passed nil or an
1855
2078
  # empty array (no check configured). Each entry retains its
@@ -1066,7 +1066,10 @@ module Parse
1066
1066
  # @param description [String] human-readable description (required)
1067
1067
  # @param parameters [Hash] JSON Schema object definition (required)
1068
1068
  # @param permission [Symbol] :readonly, :write, or :admin (required)
1069
- # @param timeout [Integer] seconds before ToolTimeoutError (default: 30)
1069
+ # @param timeout [Integer] positive seconds before ToolTimeoutError
1070
+ # (default: 30). Enforced by Tools.invoke, which wraps the handler in
1071
+ # Timeout.timeout. Must be >= 1 (a non-positive value raises
1072
+ # ArgumentError, since Timeout.timeout(0) would disable the bound).
1070
1073
  # @param handler [Proc] lambda(agent, **args) -> Hash (required)
1071
1074
  # @param client_safe [Boolean] when +true+, the tool is dispatchable
1072
1075
  # from a client-mode agent (one whose client has no master_key).
@@ -1089,9 +1092,14 @@ module Parse
1089
1092
  # - Bypass the agent_fields allowlist enforced by built-in tools when
1090
1093
  # they return raw Parse::Object instances. Project fields manually
1091
1094
  # in the handler.
1092
- # - Receive a Timeout.timeout budget derived from TOOL_TIMEOUTS (or the
1093
- # custom :timeout kwarg), but note that Parse Server's REST surface
1094
- # does not accept maxTimeMS the only timeout is the Ruby-level one.
1095
+ # - Be wrapped by Tools.invoke in a Timeout.timeout budget equal to the
1096
+ # handler's declared :timeout kwarg (default 30s) so a blocking or
1097
+ # looping handler is bounded and raises ToolTimeoutError. (Built-in
1098
+ # tools derive their budget from TOOL_TIMEOUTS; a registered handler
1099
+ # uses its own :timeout.) Note that Parse Server's REST surface does
1100
+ # not accept maxTimeMS — the only timeout is this Ruby-level one, so
1101
+ # a handler that ignores `agent.cancelled?` is interrupted only when
1102
+ # the Timeout fires.
1095
1103
  #
1096
1104
  # Treat the handler list as part of your application's trust boundary:
1097
1105
  # register at boot from code you control; never accept registrations
@@ -1120,6 +1128,17 @@ module Parse
1120
1128
  end
1121
1129
  category_str = category.to_s
1122
1130
  raise ArgumentError, "category must be a non-empty string" if category_str.empty?
1131
+ # Guarantee an enforceable wall-clock bound: Tools.invoke wraps the
1132
+ # handler in Timeout.timeout(timeout), and Timeout.timeout(0) means
1133
+ # "no timeout" — so a 0 (or fractional value that floors to 0) would
1134
+ # silently leave the handler unbounded. Require a positive integer of
1135
+ # seconds. Operators who genuinely need a long-running tool pass a
1136
+ # large value, not 0.
1137
+ if timeout.to_i < 1
1138
+ raise ArgumentError,
1139
+ "timeout must be a positive integer number of seconds (got #{timeout.inspect}); " \
1140
+ "Timeout.timeout(0) would disable the bound"
1141
+ end
1123
1142
 
1124
1143
  sym = name.to_sym
1125
1144
  # NEW-TOOLS-6: refuse names that collide with a builtin tool. The
@@ -1207,15 +1226,28 @@ module Parse
1207
1226
  # Dispatch a tool call. Registered tools take precedence over builtins
1208
1227
  # only when both share a name; otherwise each path is exclusive.
1209
1228
  #
1229
+ # A registered handler is wrapped in `with_timeout(sym)` so its declared
1230
+ # `timeout:` (default DEFAULT_TIMEOUT, 30s) is actually enforced —
1231
+ # without this, a custom handler that blocks or loops forever has no
1232
+ # wall-clock bound and (over the MCP streaming transport) can hold a
1233
+ # dispatcher slot indefinitely after a client disconnect. Built-in tools
1234
+ # are NOT wrapped here: each built-in already applies `with_timeout`
1235
+ # inside its own body, so wrapping the `else` branch would double-wrap.
1236
+ # `register` rejects a non-positive `timeout:`, so the budget here is
1237
+ # always >= 1s (Timeout.timeout(0) would otherwise mean "no timeout").
1238
+ #
1210
1239
  # @param agent [Parse::Agent] the agent instance
1211
1240
  # @param name [Symbol, String] tool name
1212
1241
  # @param kwargs [Hash] keyword arguments forwarded to handler or builtin
1242
+ # @raise [Parse::Agent::ToolTimeoutError] if a registered handler exceeds
1243
+ # its declared timeout (handled by Agent#execute and the approval
1244
+ # preview, which both rescue it).
1213
1245
  def invoke(agent, name, **kwargs)
1214
1246
  sym = name.to_sym
1215
1247
  entry = REGISTRY_MUTEX.synchronize { @registry[sym] }
1216
1248
 
1217
1249
  if entry
1218
- entry[:handler].call(agent, **kwargs)
1250
+ with_timeout(sym) { entry[:handler].call(agent, **kwargs) }
1219
1251
  else
1220
1252
  Tools.send(sym, agent, **kwargs)
1221
1253
  end
@@ -4901,6 +4933,14 @@ module Parse
4901
4933
  response = agent.client.find_objects(class_name, query, **agent.request_opts)
4902
4934
 
4903
4935
  unless response.success?
4936
+ # Parse Server 9.0+ defaults `allowPublicExplain` to false, so a
4937
+ # non-master agent's explain is rejected. Surface that as actionable
4938
+ # guidance instead of a bare permission error.
4939
+ if response.respond_to?(:permission_denied?) && response.permission_denied?
4940
+ raise "Explain failed: #{response.error} — Parse Server 9.0+ defaults " \
4941
+ "allowPublicExplain to false; query explain requires a master-key agent " \
4942
+ "or `allowPublicExplain: true` in the server's databaseOptions."
4943
+ end
4904
4944
  raise "Explain failed: #{response.error}"
4905
4945
  end
4906
4946
 
@@ -66,10 +66,16 @@ module Parse
66
66
  # @param pipeline [Array] the MongoDB aggregation pipeline stages.
67
67
  # @param opts [Hash] additional options to pass to the {Parse::Client} request.
68
68
  # @param headers [Hash] additional HTTP headers to send with the request.
69
+ # @param raw_values [Boolean] when true, adds +rawValues: true+ to the request
70
+ # so Parse Server returns un-decoded field values (PS 9.9.0+, #10438).
71
+ # @param raw_field_names [Boolean] when true, adds +rawFieldNames: true+ to the
72
+ # request so Parse Server returns original (un-decoded) field names (PS 9.9.0+).
69
73
  # @return [Parse::Response]
70
74
  # @see Parse::Query
71
- def aggregate_pipeline(className, pipeline = [], headers: {}, **opts)
75
+ def aggregate_pipeline(className, pipeline = [], headers: {}, raw_values: false, raw_field_names: false, **opts)
72
76
  query = { pipeline: pipeline.to_json }
77
+ query[:rawValues] = true if raw_values
78
+ query[:rawFieldNames] = true if raw_field_names
73
79
  response = request :get, aggregate_uri_path(className), query: query, headers: headers, opts: opts
74
80
  response.parse_class = className if response.present?
75
81
  response
@@ -12,10 +12,16 @@ module Parse
12
12
  # @param opts [Hash] additional options for the request.
13
13
  # @option opts [String] :session_token The session token for authenticated requests.
14
14
  # @option opts [String] :master_key Whether to use the master key for this request.
15
+ # @param context [Hash, nil] an optional caller context forwarded as the
16
+ # +X-Parse-Cloud-Context+ header. Parse Server maps it to
17
+ # +req.info.context+ in the cloud function handler.
18
+ # Omit or pass +nil+ to leave behavior unchanged.
15
19
  # @return [Parse::Response]
16
- def call_function(name, body = {}, opts: {})
20
+ def call_function(name, body = {}, opts: {}, context: nil)
17
21
  safe = Parse::API::PathSegment.identifier!(name, kind: "function name")
18
- request :post, "functions/#{safe}", body: body, opts: opts
22
+ headers = {}
23
+ headers[Parse::Protocol::CLOUD_CONTEXT] = context.to_json unless context.nil?
24
+ request :post, "functions/#{safe}", body: body, headers: headers, opts: opts
19
25
  end
20
26
 
21
27
  # Trigger a job.
@@ -35,11 +41,13 @@ module Parse
35
41
  # @param name [String] the name of the cloud function.
36
42
  # @param body [Hash] the parameters to forward to the function.
37
43
  # @param session_token [String] the session token for authenticated requests.
44
+ # @param context [Hash, nil] an optional caller context forwarded as the
45
+ # +X-Parse-Cloud-Context+ header.
38
46
  # @return [Parse::Response]
39
- def call_function_with_session(name, body = {}, session_token)
47
+ def call_function_with_session(name, body = {}, session_token, context: nil)
40
48
  opts = {}
41
49
  opts[:session_token] = session_token if session_token.present?
42
- call_function(name, body, opts: opts)
50
+ call_function(name, body, opts: opts, context: context)
43
51
  end
44
52
 
45
53
  # Trigger a job with a specific session token.