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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
@@ -14,22 +14,22 @@ Parse.setup(
14
14
  )
15
15
 
16
16
  # Define example models
17
- class Team < Parse::Object
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 Membership < Parse::Object
23
+ class Subscription < Parse::Object
24
24
  property :user, :pointer, class_name: 'User'
25
- property :team, :pointer, class_name: 'Team'
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 :team, :pointer, class_name: 'Team'
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 membership
43
- new_owner_membership = Membership.first(
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 = Membership.new(
49
+ new_owner_membership = Subscription.new(
50
50
  project: project,
51
- team: project.team,
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 membership
62
+ # Demote old owner if they have a subscription
63
63
  if old_owner.present?
64
- old_owner_membership = Membership.first(
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 membership
93
- new_owner_membership = Membership.first(
92
+ # Find or create new owner subscription
93
+ new_owner_membership = Subscription.first(
94
94
  project: project,
95
95
  user: new_owner
96
- ) || Membership.new(
96
+ ) || Subscription.new(
97
97
  project: project,
98
- team: project.team,
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 = Membership.first(
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(team, new_members, new_owner)
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 team
143
- team.owner = new_owner
144
- team.member_count = new_members.count
145
- batch.add(team)
142
+ # Update workspace
143
+ workspace.owner = new_owner
144
+ workspace.member_count = new_members.count
145
+ batch.add(workspace)
146
146
 
147
- # Create memberships for all new members
147
+ # Create subscriptions for all new members
148
148
  new_members.each do |member|
149
- membership = Membership.new(
150
- team: team,
149
+ subscription = Subscription.new(
150
+ workspace: workspace,
151
151
  user: member,
152
- grant: 'team',
152
+ grant: 'workspace',
153
153
  access_level: member == new_owner ? 'owner' : 'member'
154
154
  )
155
- batch.add(membership)
155
+ batch.add(subscription)
156
156
  end
157
157
 
158
- # Create a project for the team
158
+ # Create a project for the workspace
159
159
  project = Project.new(
160
- name: "#{team.name} Project",
161
- team: team,
160
+ name: "#{workspace.name} Project",
161
+ workspace: workspace,
162
162
  owner: new_owner
163
163
  )
164
164
  batch.add(project)
@@ -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 `"*"` membership in the `$in` set.
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:}`. Capture the target from
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, &block)
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
- # X-MCP-Session-Id header. Only fills it when the factory
368
- # hasn't already assigned one — application code that needs to
369
- # override the client-supplied id (e.g., bind to an internal
370
- # session record) can do so in the factory and we don't
371
- # stomp on it. The Parse::Agent#correlation_id= setter
372
- # sanitizes the value; an invalid header is silently dropped.
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["HTTP_X_MCP_SESSION_ID"])
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 X-MCP-Session-Id
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], json_headers, [""]] if result[:body].nil?
534
+ return [result[:status], headers, [""]] if result[:body].nil?
431
535
 
432
- [result[:status], json_headers, [JSON.generate(result[:body])]]
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
- [200, sse_headers, sse_body]
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, &dispatcher_blk)
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
- dispatcher_thread.join(@interval)
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
- # `X-MCP-Session-Id` (sanitized into `agent.correlation_id`) to
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 Team < Parse::Object
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 team's display name"
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 Team < Parse::Object
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 `Membership` row the
227
+ # a direct query, but when it's joined onto a `Subscription` row the
228
228
  # agent usually only needs `firstName`, `lastName`, `email`,
229
- # `internalTag` — not the `teams[]` pointer array or the
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 Membership < Parse::Object
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, :teams, :organizations, :last_active_at,
261
- # :internal_tag
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, :internal_tag
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 `isRemoved`, hides
317
- # rows via `on_timeline: false`, or has any other always-applied
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 Capture < Parse::Object
336
- # property :isRemoved, :boolean
337
- # property :onTimeline, :boolean
338
- # agent_canonical_filter "isRemoved" => { "$ne" => true },
339
- # "onTimeline" => true
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
@@ -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 team, projects by status)",
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 team)",
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. Team)", "required" => true },
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. team)", "required" => true },
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. \"Team$abc123\") — strip the \"ClassName$\" prefix to recover the objectId, then optionally call get_object on a few to label them. " \
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