parse-stack-next 4.5.0 → 5.0.1
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/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- data/parse-stack.png +0 -0
|
@@ -14,22 +14,22 @@ Parse.setup(
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
# Define example models
|
|
17
|
-
class
|
|
17
|
+
class Workspace < Parse::Object
|
|
18
18
|
property :name
|
|
19
19
|
property :owner, :pointer, class_name: 'User'
|
|
20
20
|
property :member_count, :integer, default: 0
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
class
|
|
23
|
+
class Subscription < Parse::Object
|
|
24
24
|
property :user, :pointer, class_name: 'User'
|
|
25
|
-
property :
|
|
25
|
+
property :workspace, :pointer, class_name: 'Workspace'
|
|
26
26
|
property :access_level, :string
|
|
27
27
|
property :grant, :string
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
class Project < Parse::Object
|
|
31
31
|
property :name
|
|
32
|
-
property :
|
|
32
|
+
property :workspace, :pointer, class_name: 'Workspace'
|
|
33
33
|
property :owner, :pointer, class_name: 'User'
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -39,16 +39,16 @@ def transfer_project_ownership_basic(project, new_owner)
|
|
|
39
39
|
# Get old owner
|
|
40
40
|
old_owner = project.owner
|
|
41
41
|
|
|
42
|
-
# Find or create new owner
|
|
43
|
-
new_owner_membership =
|
|
42
|
+
# Find or create new owner subscription
|
|
43
|
+
new_owner_membership = Subscription.first(
|
|
44
44
|
project: project,
|
|
45
45
|
user: new_owner
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
if new_owner_membership.nil?
|
|
49
|
-
new_owner_membership =
|
|
49
|
+
new_owner_membership = Subscription.new(
|
|
50
50
|
project: project,
|
|
51
|
-
|
|
51
|
+
workspace: project.workspace,
|
|
52
52
|
user: new_owner,
|
|
53
53
|
grant: 'project',
|
|
54
54
|
access_level: 'owner'
|
|
@@ -59,9 +59,9 @@ def transfer_project_ownership_basic(project, new_owner)
|
|
|
59
59
|
batch.add(new_owner_membership)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
# Demote old owner if they have a
|
|
62
|
+
# Demote old owner if they have a subscription
|
|
63
63
|
if old_owner.present?
|
|
64
|
-
old_owner_membership =
|
|
64
|
+
old_owner_membership = Subscription.first(
|
|
65
65
|
project: project,
|
|
66
66
|
user: old_owner
|
|
67
67
|
)
|
|
@@ -89,13 +89,13 @@ def transfer_project_ownership_auto(project, new_owner)
|
|
|
89
89
|
old_owner = project.owner
|
|
90
90
|
objects_to_save = []
|
|
91
91
|
|
|
92
|
-
# Find or create new owner
|
|
93
|
-
new_owner_membership =
|
|
92
|
+
# Find or create new owner subscription
|
|
93
|
+
new_owner_membership = Subscription.first(
|
|
94
94
|
project: project,
|
|
95
95
|
user: new_owner
|
|
96
|
-
) ||
|
|
96
|
+
) || Subscription.new(
|
|
97
97
|
project: project,
|
|
98
|
-
|
|
98
|
+
workspace: project.workspace,
|
|
99
99
|
user: new_owner,
|
|
100
100
|
grant: 'project'
|
|
101
101
|
)
|
|
@@ -105,7 +105,7 @@ def transfer_project_ownership_auto(project, new_owner)
|
|
|
105
105
|
|
|
106
106
|
# Demote old owner
|
|
107
107
|
if old_owner.present?
|
|
108
|
-
old_owner_membership =
|
|
108
|
+
old_owner_membership = Subscription.first(
|
|
109
109
|
project: project,
|
|
110
110
|
user: old_owner
|
|
111
111
|
)
|
|
@@ -132,33 +132,33 @@ rescue Parse::Error => e
|
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
# Example 3: Complex transaction with validation
|
|
135
|
-
def complex_team_operation(
|
|
135
|
+
def complex_team_operation(workspace, new_members, new_owner)
|
|
136
136
|
Parse::Object.transaction(retries: 3) do |batch|
|
|
137
137
|
# Validate new owner is in new members list
|
|
138
138
|
unless new_members.include?(new_owner)
|
|
139
139
|
raise Parse::Error, "New owner must be in members list"
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
-
# Update
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
batch.add(
|
|
142
|
+
# Update workspace
|
|
143
|
+
workspace.owner = new_owner
|
|
144
|
+
workspace.member_count = new_members.count
|
|
145
|
+
batch.add(workspace)
|
|
146
146
|
|
|
147
|
-
# Create
|
|
147
|
+
# Create subscriptions for all new members
|
|
148
148
|
new_members.each do |member|
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
subscription = Subscription.new(
|
|
150
|
+
workspace: workspace,
|
|
151
151
|
user: member,
|
|
152
|
-
grant: '
|
|
152
|
+
grant: 'workspace',
|
|
153
153
|
access_level: member == new_owner ? 'owner' : 'member'
|
|
154
154
|
)
|
|
155
|
-
batch.add(
|
|
155
|
+
batch.add(subscription)
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
-
# Create a project for the
|
|
158
|
+
# Create a project for the workspace
|
|
159
159
|
project = Project.new(
|
|
160
|
-
name: "#{
|
|
161
|
-
|
|
160
|
+
name: "#{workspace.name} Project",
|
|
161
|
+
workspace: workspace,
|
|
162
162
|
owner: new_owner
|
|
163
163
|
)
|
|
164
164
|
batch.add(project)
|
data/lib/parse/acl_scope.rb
CHANGED
|
@@ -63,7 +63,7 @@ module Parse
|
|
|
63
63
|
# `false` for backwards compatibility. Note: even with
|
|
64
64
|
# `strict_role: true`, rows with NO `_rperm` field still pass
|
|
65
65
|
# (Parse-Server treats absent `_rperm` as public-default); the
|
|
66
|
-
# knob only suppresses the `"*"`
|
|
66
|
+
# knob only suppresses the `"*"` subscription in the `$in` set.
|
|
67
67
|
Resolution = Struct.new(:mode, :permission_strings, :user_id, :session, :strict_role, keyword_init: true) do
|
|
68
68
|
def master?; mode == :master; end
|
|
69
69
|
def session?; mode == :session; end
|
|
@@ -369,7 +369,7 @@ module Parse
|
|
|
369
369
|
|
|
370
370
|
def rewrite_union_with(spec, acl_match, perms)
|
|
371
371
|
# `$unionWith` accepts either a String (collection name only)
|
|
372
|
-
# or a Hash `{coll:, pipeline:}`.
|
|
372
|
+
# or a Hash `{coll:, pipeline:}`. Post the target from
|
|
373
373
|
# either shape so the CLP gate fires before the String→Hash
|
|
374
374
|
# upgrade — denying access to the joined class BEFORE we go
|
|
375
375
|
# to the trouble of building out an upgraded sub-pipeline.
|
|
@@ -188,6 +188,21 @@ module Parse
|
|
|
188
188
|
# CSRF and force a CORS preflight on browser `fetch()`, so this
|
|
189
189
|
# gate closes the browser-driven attack surface entirely. Pair
|
|
190
190
|
# with `allowed_origins` for defense in depth.
|
|
191
|
+
# @param health_path [String, nil] when set (e.g. `"/health"`),
|
|
192
|
+
# `GET` requests to that exact path return `200 {"status":"ok"}`
|
|
193
|
+
# without invoking the agent_factory, without authentication,
|
|
194
|
+
# without rate-limiting, and without applying the
|
|
195
|
+
# `allowed_origins` / `require_custom_header` CSRF gates.
|
|
196
|
+
# Intended as a liveness probe for load balancers and
|
|
197
|
+
# orchestrators (Kubernetes, ECS, Consul) that cannot present a
|
|
198
|
+
# matching `Origin` or custom header. Because the probe sits
|
|
199
|
+
# ahead of the pre-auth rate limiter, operators should
|
|
200
|
+
# front-edge rate-limit the path at the LB/Nginx layer if
|
|
201
|
+
# public-facing. The response intentionally contains no
|
|
202
|
+
# version, build, or counter information — fingerprint-minimal
|
|
203
|
+
# by design. `nil` (default) disables the endpoint entirely;
|
|
204
|
+
# empty-string values are coerced to `nil`. Any non-GET method
|
|
205
|
+
# on the path falls through to the standard 405 handler.
|
|
191
206
|
# @raise [ArgumentError] if both or neither of agent_factory/block are given.
|
|
192
207
|
def initialize(agent_factory: nil, max_body_size: DEFAULT_MAX_BODY_SIZE,
|
|
193
208
|
logger: nil, streaming: false,
|
|
@@ -195,7 +210,8 @@ module Parse
|
|
|
195
210
|
max_concurrent_dispatchers: nil,
|
|
196
211
|
pre_auth_rate_limiter: nil,
|
|
197
212
|
allowed_origins: nil,
|
|
198
|
-
require_custom_header: nil,
|
|
213
|
+
require_custom_header: nil,
|
|
214
|
+
health_path: nil, &block)
|
|
199
215
|
if agent_factory && block
|
|
200
216
|
raise ArgumentError, "Provide agent_factory: OR a block, not both"
|
|
201
217
|
end
|
|
@@ -215,6 +231,7 @@ module Parse
|
|
|
215
231
|
@pre_auth_rate_limiter = pre_auth_rate_limiter
|
|
216
232
|
@allowed_origins = normalize_allowed_origins(allowed_origins)
|
|
217
233
|
@required_custom_header = normalize_required_custom_header(require_custom_header)
|
|
234
|
+
@health_path = health_path.is_a?(String) && !health_path.empty? ? health_path : nil
|
|
218
235
|
# Per-app registry of in-flight cancellable requests. Keyed by
|
|
219
236
|
# [correlation_id, request_id]. A `notifications/cancelled` POST
|
|
220
237
|
# whose `params.requestId` matches an entry trips the registered
|
|
@@ -267,6 +284,15 @@ module Parse
|
|
|
267
284
|
# underscored form collapses-and-overwrites the trusted slot.
|
|
268
285
|
self.class.strip_underscore_smuggled_headers!(env)
|
|
269
286
|
|
|
287
|
+
# 0a. Liveness probe. When `health_path:` is configured, a GET to
|
|
288
|
+
# that exact path returns `{"status":"ok"}` without auth,
|
|
289
|
+
# rate-limiting, or factory invocation. Intentionally
|
|
290
|
+
# fingerprint-minimal: no version, no build, no counter —
|
|
291
|
+
# a load balancer needs "is it up?", not "what is it?".
|
|
292
|
+
if @health_path && env["PATH_INFO"] == @health_path && env["REQUEST_METHOD"] == "GET"
|
|
293
|
+
return [200, json_headers, ['{"status":"ok"}']]
|
|
294
|
+
end
|
|
295
|
+
|
|
270
296
|
# 0b. NEW-MCP-6: pre-auth rate limit. Runs BEFORE the agent_factory
|
|
271
297
|
# so a malformed body / missing key / empty `{}` cannot force
|
|
272
298
|
# the operator-supplied factory to round-trip to Parse Server
|
|
@@ -282,6 +308,38 @@ module Parse
|
|
|
282
308
|
end
|
|
283
309
|
end
|
|
284
310
|
|
|
311
|
+
# 0c. DELETE / — MCP 2025-06-18 Streamable HTTP session
|
|
312
|
+
# termination. A client signals it is done with a session by
|
|
313
|
+
# sending DELETE with the same `Mcp-Session-Id` header it
|
|
314
|
+
# received from initialize. Per spec the server MAY support
|
|
315
|
+
# this; if it doesn't, it MUST return 405. We support it.
|
|
316
|
+
#
|
|
317
|
+
# Stateless-agent reality: the factory builds a fresh agent
|
|
318
|
+
# per request, so there is no server-side session store to
|
|
319
|
+
# evict. What DELETE meaningfully does is cancel any
|
|
320
|
+
# in-flight requests still running under that correlation_id
|
|
321
|
+
# so worker threads exit instead of completing wasted work.
|
|
322
|
+
# The cancellation_registry returns 0 when nothing matches
|
|
323
|
+
# (also the "unknown session" case) — we don't probe-leak by
|
|
324
|
+
# differentiating known vs unknown ids in the response.
|
|
325
|
+
#
|
|
326
|
+
# Sanitized through Parse::Agent#correlation_id= via a
|
|
327
|
+
# throwaway agent so a malicious header value (CRLF, shell
|
|
328
|
+
# metachars) is silently rejected rather than reaching the
|
|
329
|
+
# registry as a key.
|
|
330
|
+
if env["REQUEST_METHOD"] == "DELETE"
|
|
331
|
+
sid = env["HTTP_MCP_SESSION_ID"].to_s
|
|
332
|
+
if sid.empty?
|
|
333
|
+
return [400, json_headers, [json_rpc_error(-32_600, "Missing Mcp-Session-Id")]]
|
|
334
|
+
end
|
|
335
|
+
clean_sid = sanitize_session_id(sid)
|
|
336
|
+
if clean_sid.nil?
|
|
337
|
+
return [400, json_headers, [json_rpc_error(-32_600, "Invalid Mcp-Session-Id")]]
|
|
338
|
+
end
|
|
339
|
+
@cancellation_registry.cancel_all_for(clean_sid, reason: :session_terminated)
|
|
340
|
+
return [204, json_headers, [""]]
|
|
341
|
+
end
|
|
342
|
+
|
|
285
343
|
# 1. Method check — only POST is accepted.
|
|
286
344
|
unless env["REQUEST_METHOD"] == "POST"
|
|
287
345
|
return [405,
|
|
@@ -348,6 +406,32 @@ module Parse
|
|
|
348
406
|
return [400, json_headers, [json_rpc_error(-32_600, "Invalid Request")]]
|
|
349
407
|
end
|
|
350
408
|
|
|
409
|
+
# 4c. MCP-Protocol-Version header validation (MCP 2025-06-18
|
|
410
|
+
# Streamable HTTP). The spec says:
|
|
411
|
+
# - The client MUST send `MCP-Protocol-Version: <ver>`
|
|
412
|
+
# on every request AFTER initialize.
|
|
413
|
+
# - If absent on a non-initialize request, the server
|
|
414
|
+
# SHOULD assume `2025-03-26` for backwards compatibility.
|
|
415
|
+
# - If present but not a version the server supports,
|
|
416
|
+
# the server MUST respond `400 Bad Request`.
|
|
417
|
+
# Initialize requests are exempt — initialize IS the
|
|
418
|
+
# negotiation, so the header is meaningless there.
|
|
419
|
+
# Cancellation notifications are also exempt because they
|
|
420
|
+
# may be sent by a client that has not (yet) completed
|
|
421
|
+
# initialize against this transport instance (e.g. a
|
|
422
|
+
# reconnecting client cancelling a pre-disconnect request).
|
|
423
|
+
unless body["method"] == "initialize" ||
|
|
424
|
+
body["method"] == "notifications/cancelled"
|
|
425
|
+
requested = env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
426
|
+
if requested.is_a?(String) && !requested.empty? &&
|
|
427
|
+
!Parse::Agent::MCPDispatcher::SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
|
|
428
|
+
return [400, json_headers,
|
|
429
|
+
[json_rpc_error(-32_600,
|
|
430
|
+
"Unsupported MCP-Protocol-Version: #{requested}",
|
|
431
|
+
id: body["id"])]]
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
351
435
|
# 5. Agent factory — auth gate. Rescue Unauthorized first, then catch-all
|
|
352
436
|
# for unexpected factory errors.
|
|
353
437
|
begin
|
|
@@ -363,24 +447,42 @@ module Parse
|
|
|
363
447
|
return [500, json_headers, [json_rpc_error(-32_603, "Internal error")]]
|
|
364
448
|
end
|
|
365
449
|
|
|
366
|
-
# 5b. Thread the conversation correlation id through. Source
|
|
367
|
-
#
|
|
368
|
-
#
|
|
369
|
-
#
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
450
|
+
# 5b. Thread the conversation correlation id through. Source
|
|
451
|
+
# header: the MCP 2025-06-18 Streamable HTTP spec-canonical
|
|
452
|
+
# `Mcp-Session-Id` (Rack env key `HTTP_MCP_SESSION_ID`).
|
|
453
|
+
#
|
|
454
|
+
# Only fills it when the factory hasn't already assigned one
|
|
455
|
+
# — application code that needs to override the
|
|
456
|
+
# client-supplied id (e.g., bind to an internal session
|
|
457
|
+
# record) can do so in the factory and we don't stomp on it.
|
|
458
|
+
# The Parse::Agent#correlation_id= setter sanitizes the
|
|
459
|
+
# value; an invalid header is silently dropped.
|
|
373
460
|
if agent && agent.respond_to?(:correlation_id=) &&
|
|
374
461
|
agent.correlation_id.nil? &&
|
|
375
|
-
(sid = env["
|
|
462
|
+
(sid = env["HTTP_MCP_SESSION_ID"])
|
|
376
463
|
agent.correlation_id = sid
|
|
377
464
|
end
|
|
378
465
|
|
|
466
|
+
# 5b-i. Server-assigned Mcp-Session-Id on initialize. Per MCP
|
|
467
|
+
# 2025-06-18 Streamable HTTP, the server SHOULD assign a
|
|
468
|
+
# fresh session id during initialize when the client did not
|
|
469
|
+
# supply one, and return it on the response so the client
|
|
470
|
+
# can echo it on subsequent requests. Stateless-agent
|
|
471
|
+
# reality: the SDK does not maintain a server-side session
|
|
472
|
+
# store — the id is best-effort correlation only (used for
|
|
473
|
+
# audit-log threading and cancellation routing). We do not
|
|
474
|
+
# refuse subsequent requests carrying an "unknown" id.
|
|
475
|
+
if body.is_a?(Hash) && body["method"] == "initialize" &&
|
|
476
|
+
agent && agent.respond_to?(:correlation_id=) &&
|
|
477
|
+
agent.correlation_id.nil?
|
|
478
|
+
agent.correlation_id = SecureRandom.uuid
|
|
479
|
+
end
|
|
480
|
+
|
|
379
481
|
# 5c. notifications/cancelled — special-cased BEFORE the dispatcher.
|
|
380
482
|
# A JSON-RPC notification has no `id`, expects no response
|
|
381
483
|
# body, and must trip the in-flight request whose
|
|
382
484
|
# `(correlation_id, request_id)` matches. We require the
|
|
383
|
-
# cancelling request to carry the same
|
|
485
|
+
# cancelling request to carry the same Mcp-Session-Id
|
|
384
486
|
# (sanitized into agent.correlation_id above) as the original
|
|
385
487
|
# request — otherwise an attacker who guesses sequential
|
|
386
488
|
# JSON-RPC ids could cancel arbitrary in-flight requests.
|
|
@@ -422,14 +524,16 @@ module Parse
|
|
|
422
524
|
# @return [Array] Rack triple with Array<String> body.
|
|
423
525
|
def serve_json(body, agent)
|
|
424
526
|
result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent, logger: @logger)
|
|
527
|
+
headers = json_headers
|
|
528
|
+
merge_session_header!(headers, body, agent)
|
|
425
529
|
# When the dispatcher returns body: nil (a JSON-RPC notification
|
|
426
530
|
# like notifications/cancelled has no response), the Rack body
|
|
427
531
|
# is an empty string — NOT the literal "null". The HTTP-level
|
|
428
532
|
# success/empty-body shape is what the spec calls for and is
|
|
429
533
|
# what every MCP client expects after sending a notification.
|
|
430
|
-
return [result[:status],
|
|
534
|
+
return [result[:status], headers, [""]] if result[:body].nil?
|
|
431
535
|
|
|
432
|
-
[result[:status],
|
|
536
|
+
[result[:status], headers, [JSON.generate(result[:body])]]
|
|
433
537
|
end
|
|
434
538
|
|
|
435
539
|
# Return a streaming Rack response that emits SSE progress events while
|
|
@@ -504,7 +608,9 @@ module Parse
|
|
|
504
608
|
)
|
|
505
609
|
end
|
|
506
610
|
|
|
507
|
-
|
|
611
|
+
headers = sse_headers
|
|
612
|
+
merge_session_header!(headers, body, agent)
|
|
613
|
+
[200, headers, sse_body]
|
|
508
614
|
end
|
|
509
615
|
|
|
510
616
|
# ---------------------------------------------------------------------------
|
|
@@ -577,8 +683,16 @@ module Parse
|
|
|
577
683
|
# @param dispatcher_blk [Proc] called with one argument (the
|
|
578
684
|
# {#progress_callback} Proc); must return the same
|
|
579
685
|
# `{ status:, body: }` hash that MCPDispatcher.call returns.
|
|
686
|
+
# @param heartbeat_waiter [Proc, nil] test hook. Called as
|
|
687
|
+
# `waiter.call(dispatcher_thread, interval)` once per heartbeat
|
|
688
|
+
# iteration; must block until either the dispatcher finishes or
|
|
689
|
+
# `interval` elapses. Default delegates to
|
|
690
|
+
# `dispatcher_thread.join(interval)`. Tests inject a queue-driven
|
|
691
|
+
# waiter so heartbeat cadence is deterministic and not subject
|
|
692
|
+
# to OS scheduler jitter.
|
|
580
693
|
def initialize(progress_token, req_id, interval, logger,
|
|
581
|
-
cancellation_token: nil, on_close: nil,
|
|
694
|
+
cancellation_token: nil, on_close: nil,
|
|
695
|
+
heartbeat_waiter: nil, &dispatcher_blk)
|
|
582
696
|
@progress_token = progress_token
|
|
583
697
|
# Heartbeats use a dedicated server-generated progressToken so
|
|
584
698
|
# the elapsed-seconds scale of heartbeats never appears on the
|
|
@@ -593,6 +707,9 @@ module Parse
|
|
|
593
707
|
@dispatcher_blk = dispatcher_blk
|
|
594
708
|
@cancellation_token = cancellation_token
|
|
595
709
|
@on_close = on_close
|
|
710
|
+
@heartbeat_waiter = heartbeat_waiter ||
|
|
711
|
+
Thread.current[:parse_mcp_sse_heartbeat_waiter] ||
|
|
712
|
+
->(t, i) { t.join(i) }
|
|
596
713
|
@queue = Queue.new
|
|
597
714
|
@worker = nil
|
|
598
715
|
# Flipped to true by #each when the DONE sentinel is consumed.
|
|
@@ -778,7 +895,7 @@ module Parse
|
|
|
778
895
|
end
|
|
779
896
|
|
|
780
897
|
while dispatcher_thread.alive?
|
|
781
|
-
|
|
898
|
+
@heartbeat_waiter.call(dispatcher_thread, @interval)
|
|
782
899
|
# Skip the heartbeat when the tool has already reported
|
|
783
900
|
# work-unit progress on the same progressToken. Mixing
|
|
784
901
|
# elapsed-seconds heartbeats with work-unit values would
|
|
@@ -945,7 +1062,7 @@ module Parse
|
|
|
945
1062
|
# matching CancellationToken.
|
|
946
1063
|
#
|
|
947
1064
|
# Identity binding: cancellation requires the cancelling request's
|
|
948
|
-
# `
|
|
1065
|
+
# `Mcp-Session-Id` (sanitized into `agent.correlation_id`) to
|
|
949
1066
|
# match the original request's. This prevents an attacker who
|
|
950
1067
|
# guesses sequential JSON-RPC request ids from cancelling other
|
|
951
1068
|
# clients' in-flight requests. A registration with a nil
|
|
@@ -1021,6 +1138,24 @@ module Parse
|
|
|
1021
1138
|
true
|
|
1022
1139
|
end
|
|
1023
1140
|
|
|
1141
|
+
# Trip every token registered under the given correlation_id.
|
|
1142
|
+
# Used by `DELETE /` session termination — when a client tears
|
|
1143
|
+
# down its session, any in-flight requests still running under
|
|
1144
|
+
# that correlation_id are cancelled so worker threads exit
|
|
1145
|
+
# promptly instead of carrying a doomed result to completion.
|
|
1146
|
+
#
|
|
1147
|
+
# Silent no-op when no entries match (or correlation_id is
|
|
1148
|
+
# blank). Returns the number of tokens tripped.
|
|
1149
|
+
def cancel_all_for(correlation_id, reason: :session_terminated)
|
|
1150
|
+
return 0 if correlation_id.nil? || correlation_id.to_s.empty?
|
|
1151
|
+
tokens = @mutex.synchronize do
|
|
1152
|
+
keys = @entries.keys.select { |(cid, _)| cid == correlation_id }
|
|
1153
|
+
keys.map { |k| @entries.delete(k)[1] }
|
|
1154
|
+
end
|
|
1155
|
+
tokens.each { |t| t.cancel!(reason: reason) }
|
|
1156
|
+
tokens.size
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1024
1159
|
# @return [Integer] number of currently-registered tokens. Used
|
|
1025
1160
|
# by tests and operator dashboards.
|
|
1026
1161
|
def size
|
|
@@ -1046,6 +1181,39 @@ module Parse
|
|
|
1046
1181
|
SSE_HEADERS.dup
|
|
1047
1182
|
end
|
|
1048
1183
|
|
|
1184
|
+
# Sanitize an `Mcp-Session-Id` header value with the same rules as
|
|
1185
|
+
# {Parse::Agent#correlation_id=} (URL-safe ASCII, ≤128 chars).
|
|
1186
|
+
# Returns the cleaned string, or nil if the input fails the regex.
|
|
1187
|
+
# Used by the DELETE handler before passing the value to the
|
|
1188
|
+
# cancellation registry; reproducing the regex here keeps the
|
|
1189
|
+
# transport layer from instantiating a throwaway Parse::Agent just
|
|
1190
|
+
# to borrow its setter.
|
|
1191
|
+
# Same character set as {Parse::Agent::CORRELATION_ID_RE}, redeclared
|
|
1192
|
+
# here so this file doesn't depend on agent.rb's load order.
|
|
1193
|
+
SESSION_ID_RE = /\A[A-Za-z0-9._\-]+\z/.freeze
|
|
1194
|
+
|
|
1195
|
+
def sanitize_session_id(value)
|
|
1196
|
+
return nil if value.nil?
|
|
1197
|
+
s = value.to_s
|
|
1198
|
+
return nil unless SESSION_ID_RE.match?(s)
|
|
1199
|
+
s[0, 128]
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
# When the request being responded to is `initialize` and the agent
|
|
1203
|
+
# carries a (server-assigned or factory-bound) correlation_id,
|
|
1204
|
+
# advertise it as the spec-canonical `Mcp-Session-Id` response
|
|
1205
|
+
# header so the client can echo it on subsequent requests. The
|
|
1206
|
+
# header is emitted ONLY on the initialize response — non-init
|
|
1207
|
+
# responses don't carry it, both to avoid leaking the id on every
|
|
1208
|
+
# reply and because the client already knows it.
|
|
1209
|
+
def merge_session_header!(headers, body, agent)
|
|
1210
|
+
return unless body.is_a?(Hash) && body["method"] == "initialize"
|
|
1211
|
+
return unless agent && agent.respond_to?(:correlation_id)
|
|
1212
|
+
sid = agent.correlation_id
|
|
1213
|
+
return if sid.nil? || sid.to_s.empty?
|
|
1214
|
+
headers["Mcp-Session-Id"] = sid
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1049
1217
|
# ---------------------------------------------------------------------------
|
|
1050
1218
|
# JSON-RPC envelope helpers
|
|
1051
1219
|
# ---------------------------------------------------------------------------
|
|
@@ -8,10 +8,10 @@ module Parse
|
|
|
8
8
|
# to the Parse Agent for LLM interaction.
|
|
9
9
|
#
|
|
10
10
|
# @example Define a model with agent metadata
|
|
11
|
-
# class
|
|
11
|
+
# class Workspace < Parse::Object
|
|
12
12
|
# agent_description "A group of users contributing to a Project"
|
|
13
13
|
#
|
|
14
|
-
# property :name, :string, description: "The
|
|
14
|
+
# property :name, :string, description: "The workspace's display name"
|
|
15
15
|
# property :member_count, :integer, description: "Number of active members"
|
|
16
16
|
#
|
|
17
17
|
# agent_method :active_projects, "Returns projects currently in progress"
|
|
@@ -190,7 +190,7 @@ module Parse
|
|
|
190
190
|
# Called without arguments, returns the current allowlist.
|
|
191
191
|
#
|
|
192
192
|
# @example Limit agent visibility to analytics-relevant fields
|
|
193
|
-
# class
|
|
193
|
+
# class Workspace < Parse::Object
|
|
194
194
|
# agent_fields :name, :status, :member_count, :owner
|
|
195
195
|
# end
|
|
196
196
|
#
|
|
@@ -224,9 +224,9 @@ module Parse
|
|
|
224
224
|
# allowlist is typically the full "what the agent may see" set;
|
|
225
225
|
# the join-projection list is the narrower "what's interesting when
|
|
226
226
|
# I'm a foreign key" set. Example: `_User` may surface 18 fields on
|
|
227
|
-
# a direct query, but when it's joined onto a `
|
|
227
|
+
# a direct query, but when it's joined onto a `Subscription` row the
|
|
228
228
|
# agent usually only needs `firstName`, `lastName`, `email`,
|
|
229
|
-
# `
|
|
229
|
+
# `category` — not the `workspaces[]` pointer array or the
|
|
230
230
|
# `iconImage` presigned URL.
|
|
231
231
|
#
|
|
232
232
|
# **Subset invariant**: when both `agent_fields` and
|
|
@@ -247,7 +247,7 @@ module Parse
|
|
|
247
247
|
# pointer.
|
|
248
248
|
#
|
|
249
249
|
# @example
|
|
250
|
-
# class
|
|
250
|
+
# class Subscription < Parse::Object
|
|
251
251
|
# belongs_to :user
|
|
252
252
|
# property :title, :string
|
|
253
253
|
# property :active, :boolean
|
|
@@ -257,11 +257,11 @@ module Parse
|
|
|
257
257
|
# # In the _User reopen / customization:
|
|
258
258
|
# class Parse::User
|
|
259
259
|
# agent_fields :first_name, :last_name, :email, :icon_image,
|
|
260
|
-
# :source_image, :
|
|
261
|
-
# :
|
|
260
|
+
# :source_image, :workspaces, :tenants, :last_active_at,
|
|
261
|
+
# :category
|
|
262
262
|
# agent_large_fields :icon_image, :source_image
|
|
263
263
|
# agent_join_fields :first_name, :last_name, :email,
|
|
264
|
-
# :last_active_at, :
|
|
264
|
+
# :last_active_at, :category
|
|
265
265
|
# end
|
|
266
266
|
#
|
|
267
267
|
# @param names [Array<Symbol, String>] field names to project on join
|
|
@@ -313,8 +313,8 @@ module Parse
|
|
|
313
313
|
# Declare a canonical "valid state" filter for this class that the
|
|
314
314
|
# agent's read tools (`query_class`, `count_objects`, `aggregate`)
|
|
315
315
|
# apply BY DEFAULT to every call. Closes the silently-suspect-
|
|
316
|
-
# counts gap: when a class soft-deletes via `
|
|
317
|
-
# rows via `
|
|
316
|
+
# counts gap: when a class soft-deletes via `archived`, hides
|
|
317
|
+
# rows via `published: false`, or has any other always-applied
|
|
318
318
|
# validity predicate, the canonical filter ensures an LLM that
|
|
319
319
|
# drops to raw aggregate doesn't accidentally include the
|
|
320
320
|
# excluded rows.
|
|
@@ -332,11 +332,11 @@ module Parse
|
|
|
332
332
|
# caller can reproduce it manually.
|
|
333
333
|
#
|
|
334
334
|
# @example
|
|
335
|
-
# class
|
|
336
|
-
# property :
|
|
337
|
-
# property :
|
|
338
|
-
# agent_canonical_filter "
|
|
339
|
-
# "
|
|
335
|
+
# class Post < Parse::Object
|
|
336
|
+
# property :archived, :boolean
|
|
337
|
+
# property :published, :boolean
|
|
338
|
+
# agent_canonical_filter "archived" => { "$ne" => true },
|
|
339
|
+
# "published" => true
|
|
340
340
|
# end
|
|
341
341
|
#
|
|
342
342
|
# @param filter [Hash, nil] a where-style hash. Pass nil to
|
|
@@ -51,13 +51,22 @@ module Parse
|
|
|
51
51
|
# Validate an aggregation pipeline for security issues.
|
|
52
52
|
# Delegates to {Parse::PipelineSecurity.validate_pipeline!} and
|
|
53
53
|
# translates its error into {PipelineSecurityError} for backwards
|
|
54
|
-
# compatibility.
|
|
54
|
+
# compatibility. Additionally refuses Atlas-stage-0-only operators
|
|
55
|
+
# (`$search`, `$searchMeta`, `$vectorSearch`, `$listSearchIndexes`)
|
|
56
|
+
# which are legal SDK-emitted stages but must NOT appear in a
|
|
57
|
+
# caller-supplied agent pipeline — the agent surface for those is
|
|
58
|
+
# the dedicated `atlas_search` / `semantic_search` tools, and the
|
|
59
|
+
# Agent's tenant-scope `$match` prepend would push them off
|
|
60
|
+
# stage 0 anyway. See
|
|
61
|
+
# {Parse::PipelineSecurity::STAGE0_ONLY_ATLAS_STAGES}.
|
|
55
62
|
#
|
|
56
63
|
# @param pipeline [Array<Hash>] the aggregation pipeline stages
|
|
57
64
|
# @raise [PipelineSecurityError] if pipeline contains blocked or unknown stages
|
|
58
65
|
# @return [true] if pipeline is valid
|
|
59
66
|
def validate!(pipeline)
|
|
60
67
|
Parse::PipelineSecurity.validate_pipeline!(pipeline)
|
|
68
|
+
refuse_stage0_only_atlas_stages!(pipeline)
|
|
69
|
+
true
|
|
61
70
|
rescue Parse::PipelineSecurity::Error => e
|
|
62
71
|
raise PipelineSecurityError.new(
|
|
63
72
|
e.message,
|
|
@@ -67,6 +76,24 @@ module Parse
|
|
|
67
76
|
)
|
|
68
77
|
end
|
|
69
78
|
|
|
79
|
+
# @api private
|
|
80
|
+
def refuse_stage0_only_atlas_stages!(pipeline)
|
|
81
|
+
return unless pipeline.is_a?(Array)
|
|
82
|
+
pipeline.each do |stage|
|
|
83
|
+
next unless stage.is_a?(Hash)
|
|
84
|
+
stage.each_key do |k|
|
|
85
|
+
key = k.to_s
|
|
86
|
+
next unless Parse::PipelineSecurity::STAGE0_ONLY_ATLAS_STAGES.include?(key)
|
|
87
|
+
raise PipelineSecurityError.new(
|
|
88
|
+
"Stage #{key} is not allowed in caller-supplied agent pipelines. " \
|
|
89
|
+
"Use the dedicated atlas_search / semantic_search agent tool instead.",
|
|
90
|
+
stage: key,
|
|
91
|
+
reason: :stage0_only_atlas_stage,
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
70
97
|
# Check if a pipeline is valid without raising.
|
|
71
98
|
#
|
|
72
99
|
# @param pipeline [Array<Hash>] the aggregation pipeline
|
data/lib/parse/agent/prompts.rb
CHANGED
|
@@ -106,7 +106,7 @@ module Parse
|
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
"name" => "count_by",
|
|
109
|
-
"description" => "Count objects in a class grouped by a field (e.g. users by
|
|
109
|
+
"description" => "Count objects in a class grouped by a field (e.g. users by workspace, projects by status)",
|
|
110
110
|
"arguments" => [
|
|
111
111
|
{ "name" => "class_name", "description" => "Parse class to count", "required" => true },
|
|
112
112
|
{ "name" => "group_by", "description" => "Field to group by", "required" => true },
|
|
@@ -122,12 +122,12 @@ module Parse
|
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
"name" => "find_relationship",
|
|
125
|
-
"description" => "Find objects in one class related to a given object in another (e.g. members of a
|
|
125
|
+
"description" => "Find objects in one class related to a given object in another (e.g. members of a workspace)",
|
|
126
126
|
"arguments" => [
|
|
127
|
-
{ "name" => "parent_class", "description" => "Class of the parent object (e.g.
|
|
127
|
+
{ "name" => "parent_class", "description" => "Class of the parent object (e.g. Workspace)", "required" => true },
|
|
128
128
|
{ "name" => "parent_id", "description" => "objectId of the parent", "required" => true },
|
|
129
129
|
{ "name" => "child_class", "description" => "Class to query (e.g. _User)", "required" => true },
|
|
130
|
-
{ "name" => "pointer_field", "description" => "Field on child_class that points to parent (e.g.
|
|
130
|
+
{ "name" => "pointer_field", "description" => "Field on child_class that points to parent (e.g. workspace)", "required" => true },
|
|
131
131
|
],
|
|
132
132
|
},
|
|
133
133
|
{
|
|
@@ -185,7 +185,7 @@ module Parse
|
|
|
185
185
|
{ "$limit" => 25 },
|
|
186
186
|
]
|
|
187
187
|
"Count #{cn} objects grouped by #{gb}. Use aggregate with class_name=\"#{cn}\" and pipeline #{pipeline.to_json}. " \
|
|
188
|
-
"If #{gb} is a pointer field, Parse returns each `_id` as the literal string \"ClassName$objectId\" (e.g. \"
|
|
188
|
+
"If #{gb} is a pointer field, Parse returns each `_id` as the literal string \"ClassName$objectId\" (e.g. \"Workspace$abc123\") — strip the \"ClassName$\" prefix to recover the objectId, then optionally call get_object on a few to label them. " \
|
|
189
189
|
"Report the top groups, call out any null/missing values, and give the total."
|
|
190
190
|
},
|
|
191
191
|
|