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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/CHANGELOG.md +461 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +160 -3
- data/Rakefile +52 -3
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +5 -0
- data/docs/mcp_guide.md +59 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +55 -2
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +180 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/querying.rb +3 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +17 -0
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +252 -7
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- 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]
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
# (`-32000` "server
|
|
179
|
-
#
|
|
180
|
-
#
|
|
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:
|
|
317
|
+
logger: nil, streaming: nil,
|
|
240
318
|
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
|
|
241
|
-
max_concurrent_dispatchers:
|
|
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:
|
|
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
|
|
323
|
-
# cap. Both request-scoped SSE
|
|
324
|
-
#
|
|
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.
|
|
331
|
-
#
|
|
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
|
|
335
|
-
"
|
|
336
|
-
"
|
|
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
|
|
1162
|
-
#
|
|
1163
|
-
#
|
|
1164
|
-
#
|
|
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,
|
|
1167
|
-
#
|
|
1168
|
-
#
|
|
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
|
|
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
|
-
#
|
|
1177
|
-
#
|
|
1178
|
-
#
|
|
1179
|
-
#
|
|
1180
|
-
#
|
|
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
|
|
1254
|
-
# the outer @worker is killed
|
|
1255
|
-
#
|
|
1256
|
-
# a
|
|
1257
|
-
#
|
|
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
|
|
1260
|
-
# operators can observe concurrency via
|
|
1261
|
-
#
|
|
1262
|
-
#
|
|
1263
|
-
#
|
|
1264
|
-
#
|
|
1265
|
-
#
|
|
1266
|
-
#
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
Thread.
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
@logger
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
data/lib/parse/agent/tools.rb
CHANGED
|
@@ -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
|
|
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
|
-
# -
|
|
1093
|
-
#
|
|
1094
|
-
#
|
|
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
|
|
data/lib/parse/api/aggregate.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|