parse-stack-next 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
data/README.md CHANGED
@@ -4,6 +4,15 @@
4
4
 
5
5
  A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [parse-stack-next](https://github.com/neurosynq/parse-stack-next) is a Ruby client SDK, REST client, and Active Model ORM for [Parse Server](http://parseplatform.org/), combining a low-level API client, a query engine, an object-relational mapper (ORM), and a Cloud Code Webhooks rack application in a single gem.
6
6
 
7
+ ### What's new in 5.4
8
+
9
+ - **5.4.0 — Hybrid search + reranking for RAG** — `Class.hybrid_search(text:, lexical:, vector:, k:, fusion:)` fuses a lexical Atlas Search branch with a `$vectorSearch` branch using reciprocal-rank fusion (RRF): lexical search nails exact tokens (codes, proper nouns), vector search nails paraphrase, and fusing the two beats either alone. Each branch enforces ACL/CLP independently before fusion (no separate hydration fetch to secure); results carry `#hybrid_score` / `#hybrid_ranks`. `Parse::VectorSearch::Hybrid.rank_fusion_supported?` detects Atlas 8.0+ native `$rankFusion` by a cached behavioural probe (native execution is opt-in; client-side RRF is the always-enforced default). `Parse::Retrieval::Reranker` adds cross-encoder reranking (`Reranker::Cohere` over `/v2/rerank`, plus a deterministic `Reranker::Fixture`), wired into `Parse::Retrieval.retrieve(hybrid:, rerank:)`. `Parse::Embeddings::SpendCap` adds an opt-in per-tenant embedding token cap (hard-refuse) at the `semantic_search` agent-tool boundary. See [CHANGELOG.md](./CHANGELOG.md) and [`docs/atlas_vector_search_guide.md`](./docs/atlas_vector_search_guide.md)
10
+ - **5.4.0 — Vector backfill, visibility, and webhook redaction** — `Class.embed_pending!` backfills embeddings for records whose managed `:vector` field is null (objectId-cursor pagination); `Parse::Object#compute_embedding!` forces an in-place recompute without a save; `vector_visibility :owner_only | :public` controls whether a class's vectors appear in `as_json` by default; and webhook trigger payloads now strip declared `:vector` columns by default (a `:public` class keeps them). See [CHANGELOG.md](./CHANGELOG.md)
11
+ - **5.4.0 — TOTP multi-factor auth works end to end** — the `Parse::User` MFA lifecycle is now fully functional and exercised against a real MFA-enabled Parse Server. `setup_mfa!(secret:, token:)` enrolls TOTP and returns recovery codes; `Parse::User.login_with_mfa(user, pass, code)` completes a second-factor login; `mfa_enabled?` / `mfa_status` report enrollment after an ordinary fetch — the SDK strips the raw TOTP secret and recovery codes that Parse Server returns in `authData` but preserves a leak-safe `{status: "enabled"}` projection so the status reads correctly without exposing the secret; `disable_mfa!(current_token:)` turns MFA off after re-validating the current code (a wrong code raises `Parse::MFA::VerificationError`), and `disable_mfa_master_key!(authorized_by:)` is the operator override. Each MFA write also no longer raises an internal argument error before reaching the server. Interactively, `rake client:console` now prompts for a TOTP / recovery code (or reads `PARSE_LOGIN_MFA`) when logging into an enrolled account. See [CHANGELOG.md](./CHANGELOG.md)
12
+ - **5.4.0 — Request email-address verification** — `Parse::User.request_email_verification(email)` and the instance `Parse::User#request_email_verification` ask Parse Server to (re)send the verification email for a registered, not-yet-verified user, mirroring `request_password_reset` (per-email rate limiting, Boolean return). Requires a server email adapter with `verifyUserEmails` enabled. See [CHANGELOG.md](./CHANGELOG.md)
13
+ - **5.4.0 — Audience hash queries persist correctly** — `Parse::Audience#query` is now stored as a JSON string on the wire to match Parse Server's `_Audience.query` column type, so saving an audience with a `Hash` query no longer fails the server schema check. The public API is unchanged — assign a `Hash`, read a `Hash` back. See [CHANGELOG.md](./CHANGELOG.md)
14
+ - **5.4.0 — Faster AtlasSearch role-cache expiry** — `Parse::AtlasSearch` `role_cache_ttl` now defaults to 30 seconds (was 120) so a role grant or revoke is reflected in `$search` ACL decisions sooner, at the cost of slightly more frequent role lookups. See [CHANGELOG.md](./CHANGELOG.md)
15
+
7
16
  ### What's new in 5.3
8
17
 
9
18
  - **5.3.0 — Run webhook handlers (and clients) as the calling user** — Parse Server embeds the caller's live session token in every trigger webhook fired by a logged-in user. A handler can now opt in to acting on the server *as that user* — full ACL/CLP/`protectedFields` enforcement, no master key. `payload.session_token` exposes the captured token (`nil` for master-key requests; still scrubbed from `payload.user`/`payload.object`/`as_json`/logs); `payload.user_agent` returns a client-mode `Parse::Agent`, and `payload.user_client` a non-master `Parse::Client` with the token **bound** so even raw REST calls authorize as the user. The same user-scoped client is available client-side via `Parse::User#session_client` and the `Parse::Client#become(token)` primitive, with `Parse::Client#with_session { … }` for block scoping. Backed by a new `Parse::Client.new(session_token:)` option. See [Acting as the calling user](#acting-as-the-calling-user)
@@ -209,9 +218,20 @@ result = Parse.call_function :myFunctionName, {param: value}
209
218
 
210
219
  ```
211
220
 
221
+ ## Examples
222
+
223
+ Runnable, self-contained scripts live in [`examples/`](examples/) — see
224
+ [`examples/README.md`](examples/README.md) for the full index. Highlights:
225
+
226
+ - [`basic_server.rb`](examples/basic_server.rb) — master-key setup: models, schema, CRUD + queries.
227
+ - [`basic_client.rb`](examples/basic_client.rb) — unprivileged client with row-level ACL enforcement.
228
+ - [`live_query_listener.rb`](examples/live_query_listener.rb) — interactive LiveQuery console scoped to a user's session.
229
+ - [`rag_chatbot.rb`](examples/rag_chatbot.rb) — retrieval-augmented generation with `semantic_search` + an OpenAI/Anthropic add-in.
230
+ - [`transaction_example.rb`](examples/transaction_example.rb) — atomic multi-object transactions.
231
+
212
232
  ## Release History
213
233
 
214
- **Current version: 5.0.1** | **Ruby 3.2+ required**
234
+ **Current version: 5.4.0** | **Ruby 3.2+ required**
215
235
 
216
236
  The 5.0 highlights (vector search / RAG, pooled Redis cache, AS::N instrumentation, MCP transport hardening, GraphQL type generation) are summarized in the [What's new in 5.0](#whats-new-in-50) section above. Earlier releases are recorded below.
217
237
 
@@ -1586,8 +1606,11 @@ user.mfa_status # => :enabled, :disabled, or :unknown
1586
1606
  # Disable MFA (requires current token)
1587
1607
  user.disable_mfa!(current_token: "123456")
1588
1608
 
1589
- # Admin reset (master key) — authorized_by must be a Parse::User
1590
- user.disable_mfa_master_key!(authorized_by: admin_user)
1609
+ # Admin reset (master key) — fails closed: pass either an admin_role:
1610
+ # for the library to verify, or allow_unverified: true to assert that
1611
+ # you have already authorized the operator out-of-band.
1612
+ user.disable_mfa_master_key!(authorized_by: admin_user, admin_role: "Admin")
1613
+ # or: user.disable_mfa_master_key!(authorized_by: admin_user, allow_unverified: true)
1591
1614
  ```
1592
1615
 
1593
1616
  **SMS MFA (requires Parse Server SMS callback):**
@@ -4917,6 +4940,32 @@ The `parse_object` handed to your handler is the **full object as Parse Server s
4917
4940
 
4918
4941
  For any `after_*` hook, return values are not needed since Parse does not utilize them. You may also register as many `after_save` or `after_delete` handlers as you prefer, all of them will be called.
4919
4942
 
4943
+ For `before_save` (and functions), the handler's value **is** the response Parse Server acts on — return the (possibly mutated) `parse_object` to allow the write, or `false` / `error!` to reject it. You can set that value with an explicit `return` or as the block's last expression; both work, as do the proc idioms `next value` / `break value`:
4944
+
4945
+ ```ruby
4946
+ Parse::Webhooks.route :before_save, :Artist do
4947
+ artist = parse_object
4948
+ return artist if artist.name.present? # explicit early return
4949
+ error! "name is required" # raise to reject the save
4950
+ end
4951
+ ```
4952
+
4953
+ `self` inside the block is the `Parse::Webhooks::Payload`, so `parse_object`, `params`, and `error!` are available directly. As anywhere in Ruby, `return` ends the handler immediately — to run work *after* the response is sent, use `after_response` (below) rather than code after the `return`.
4954
+
4955
+ #### Deferring work until after the response
4956
+
4957
+ `payload.after_response { … }` (alias `defer`) registers work to run **after** the webhook response has been sent, off the critical path of the save or function the client is waiting on. The handler returns its value synchronously; the deferred block runs afterward — ideal for search indexing, cache warming, or fan-out that should not add latency.
4958
+
4959
+ ```ruby
4960
+ Parse::Webhooks.route :after_save, :Post do
4961
+ post = parse_object
4962
+ after_response { SearchIndex.reindex(post.id) } # runs after the reply is sent
4963
+ post
4964
+ end
4965
+ ```
4966
+
4967
+ Under Puma or Unicorn the block runs via `rack.after_reply` once the response is flushed (same worker thread, zero added round-trip latency); on a server without it (e.g. WEBrick) it falls back to a detached thread. Multiple blocks run in order and are isolated — one raising affects neither the response nor the others. Notes: deferred blocks run **only on the success path** (a rejected `before_save` runs none), "after the response" is **not** "after the row commits" (don't rely on the persisted row inside the block), and the work is **in-process and best-effort** — it dies with the worker, so for anything that *must* happen use a durable job queue (Sidekiq / ActiveJob). Blocks are drained only when the payload runs through the mounted `Parse::Webhooks` Rack app (a no-op under direct `run_function` / `call_route`). See the [Cloud Code Webhooks Guide](docs/webhooks_guide.md#deferring-work-until-after-the-response).
4968
+
4920
4969
  > **Your model's `after_save` callbacks run here too.** When an `after_save` /
4921
4970
  > `after_create` trigger fires, the webhook rebuilds the `Parse::Object` from the
4922
4971
  > payload and runs that model's ActiveModel `after_save` / `after_create`
@@ -4928,6 +4977,57 @@ For any `after_*` hook, return values are not needed since Parse does not utiliz
4928
4977
  > for saves from other clients (JS / iOS / REST), the webhook runs them, since
4929
4978
  > the SDK never had the chance.
4930
4979
 
4980
+ #### ActiveModel callbacks vs. Parse Server triggers
4981
+
4982
+ The SDK exposes the full ActiveModel lifecycle on every model
4983
+ (`before_validation`, `before_save`/`after_save`, `before_create`/`after_create`,
4984
+ `before_update`/`after_update`, `before_destroy`/`after_destroy`). Parse Server,
4985
+ separately, exposes a fixed set of **webhook trigger types**. They are not
4986
+ one-to-one — the SDK maps between them, and a webhook must be **registered** for
4987
+ your ActiveModel logic to run server-side for non-Ruby clients (JS / iOS / REST /
4988
+ Dashboard). Without a registered webhook, that logic runs only in the Ruby
4989
+ process that initiated the save.
4990
+
4991
+ Supported Parse Server trigger types: `beforeSave`/`afterSave`,
4992
+ `beforeDelete`/`afterDelete`, `beforeFind`/`afterFind`, `beforeLogin`/`afterLogin`,
4993
+ `afterLogout`, `beforePasswordResetRequest`, `beforeConnect`,
4994
+ `beforeSubscribe`/`afterEvent`, and file triggers on the `@File` pseudo-class.
4995
+
4996
+ The **authentication** triggers (`beforeLogin`/`afterLogin`/`afterLogout`/
4997
+ `beforePasswordResetRequest`) and **LiveQuery** triggers (`beforeConnect`/
4998
+ `beforeSubscribe`/`afterEvent`) route as first-class shapes — predicates
4999
+ (`before_login?` … `after_event?`, `auth_trigger?`/`live_query_trigger?`), an
5000
+ `event` accessor, and top-level `sessionToken` capture into `payload.session_token`.
5001
+ None of them run ActiveModel `save`/`create`/`destroy` callbacks, even though the
5002
+ auth triggers carry a `_User`/`_Session`. Parse Server **ignores the response body**
5003
+ for all of them, so the only signal that affects the operation is rejection, and
5004
+ only on the `before*` variants: returning `false` (or calling `error!`) from a
5005
+ `before_login`/`before_connect`/`before_subscribe`/`before_password_reset_request`
5006
+ handler denies the operation, while anything else is a success no-op. (LiveQuery
5007
+ triggers are delivered over HTTP only in a co-located single-process setup;
5008
+ `beforeConnect` is effectively in-process-only.)
5009
+
5010
+ Key relationship — **`beforeSave`/`afterSave` carry the create variants**. Parse
5011
+ Server has **no `beforeCreate`/`afterCreate` trigger** (it rejects them). The SDK
5012
+ runs your `before_create`/`after_create` callbacks *inside* the
5013
+ `beforeSave`/`afterSave` handler for new objects, in ActiveModel order
5014
+ (`before_save → before_create`, `after_create → after_save`). So **registering a
5015
+ `beforeSave` webhook enables both `before_save` and `before_create`**, and
5016
+ `afterSave` enables both `after_save` and `after_create`. Requesting a create
5017
+ webhook raises with guidance pointing you at the save trigger.
5018
+
5019
+ > **`after_save` is synchronous and on the critical path.** Parse Server waits
5020
+ > for the webhook to return before completing the client's write — even on
5021
+ > `afterSave`, whose return value is a no-op. Treat `after_save` as a place to
5022
+ > **enqueue** background work, not to run long logic inline, and avoid saving
5023
+ > other objects inside it (each cascading save fires more webhooks). `beforeSave`
5024
+ > can mutate or reject the write, so it is necessarily inline — keep it lean.
5025
+
5026
+ For the full picture — trigger types, registration, the synchronous-latency
5027
+ model, the Ruby-initiated dedup, and inbound replay/freshness protection — see
5028
+ the [Cloud Code Webhooks Guide](docs/webhooks_guide.md) and
5029
+ [`examples/webhook_server.rb`](examples/webhook_server.rb).
5030
+
4931
5031
  #### Trigger object state
4932
5032
 
4933
5033
  Because the trigger payload is server-authoritative, the `parse_object` your
@@ -5764,6 +5864,13 @@ The integration tests use Docker Compose to spin up a Parse Server instance with
5764
5864
  - Docker and Docker Compose installed
5765
5865
  - Ruby environment with bundler
5766
5866
 
5867
+ > **Always run the suite with `bundle exec`.** Newer `minitest` (6.0+) moved
5868
+ > `minitest/mock` out into a separate gem, so a bare `ruby`/`rake` invocation
5869
+ > activates minitest 6 and then fails to load `minitest/mock`, aborting every
5870
+ > test at load time with `cannot load such file -- minitest/mock (LoadError)`.
5871
+ > Running through bundler pins the locked versions and avoids this. If you hit
5872
+ > that LoadError, prefix the command with `bundle exec`.
5873
+
5767
5874
  #### Setup and Running Tests
5768
5875
 
5769
5876
  1. **Enable Docker Tests**: Set the environment variable to enable Docker-based tests:
@@ -5848,6 +5955,56 @@ docker compose -f scripts/docker/docker-compose.test.yml up -d
5848
5955
  curl -s http://localhost:29337/parse/health # -> {"status":"ok"}
5849
5956
  ```
5850
5957
 
5958
+ #### Network Exposure and the Preflight Guard
5959
+
5960
+ Every service binds to loopback (`127.0.0.1`) by default, and the default
5961
+ credentials above are committed to this repository — safe in combination,
5962
+ since nothing off the host can reach them. Each bind is overridable
5963
+ (`PARSE_BIND`, `MONGO_BIND`, `REDIS_BIND`, `DASHBOARD_BIND`) for the
5964
+ occasional need to attach a remote client while debugging.
5965
+
5966
+ That override is a footgun: pointing a bind at `0.0.0.0` while the default
5967
+ credentials are still in force would publish an admin-credentialed stack
5968
+ (Mongo `admin:password`, master key `psnextItMasterKey`) onto your LAN. A
5969
+ `preflight` service runs before anything else and **refuses to start the
5970
+ stack** in exactly that case. To proceed, do one of:
5971
+
5972
+ ```bash
5973
+ # 1. Keep it loopback (the default) — just omit the *_BIND override.
5974
+
5975
+ # 2. Supply real credentials instead of the committed test defaults.
5976
+ PARSE_MASTER_KEY="$(openssl rand -hex 24)" \
5977
+ MONGO_ROOT_PASSWORD="$(openssl rand -hex 24)" \
5978
+ MONGO_BIND=0.0.0.0 \
5979
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
5980
+
5981
+ # 3. Acknowledge the exposure on a trusted, isolated network.
5982
+ ALLOW_INSECURE_BIND=1 MONGO_BIND=0.0.0.0 \
5983
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
5984
+ ```
5985
+
5986
+ #### Secret Injection (real credentials)
5987
+
5988
+ The committed defaults are deliberately non-secret, so the loopback stack
5989
+ needs no secrets manager. If you point the stack at *real or shared*
5990
+ credentials (option 2 above, or a staging Mongo), keep them out of your
5991
+ shell history and the compose file by injecting them at launch. The stack
5992
+ reads plain environment variables, so any injector works:
5993
+
5994
+ ```bash
5995
+ # 1Password CLI — secrets resolved from an op:// .env reference file.
5996
+ op run --env-file=.env.secrets -- \
5997
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
5998
+
5999
+ # Doppler — secrets pulled from a configured project/config.
6000
+ doppler run -- \
6001
+ docker compose -f scripts/docker/docker-compose.test.yml up -d
6002
+ ```
6003
+
6004
+ Use the committed `.env.sample` as the reference for which variables each
6005
+ side expects; copy it to a gitignored `.env` (or an `op://`-referenced
6006
+ `.env.secrets`) and fill in real values there.
6007
+
5851
6008
  #### Environment Variables
5852
6009
 
5853
6010
  The defaults above are baked into the Compose file and the test helpers, so the
data/Rakefile CHANGED
@@ -77,12 +77,57 @@ def client_console_token!
77
77
  pwd = $stdin.gets.to_s
78
78
  end
79
79
  end
80
- u = Parse::User.login(user, pwd.chomp)
80
+ u = console_login_with_optional_mfa(user, pwd.chomp)
81
81
  abort "[client:console] login failed for #{user.inspect}" if u.nil? || u.session_token.to_s.empty?
82
82
  puts "Logged in as #{u.username} (#{u.id})."
83
83
  u.session_token
84
84
  end
85
85
 
86
+ # Log `user` in, transparently handling an MFA-enrolled account. If the server
87
+ # reports that additional MFA auth is required, prompt for a TOTP / recovery
88
+ # code (or read +PARSE_LOGIN_MFA+ for non-interactive use) and retry via
89
+ # {Parse::User.login_with_mfa}. Returns a logged-in {Parse::User}, or nil when
90
+ # the credentials themselves are rejected (so the caller's "login failed" abort
91
+ # still fires for a bad password).
92
+ def console_login_with_optional_mfa(user, pwd)
93
+ # Parse Server signals "this account needs an MFA token" two ways depending on
94
+ # the error code path: a returned error response ("Missing additional
95
+ # authData ...") or a raised Parse::Error for the OTHER_CAUSE (code <= 100)
96
+ # variant. Treat both as "prompt for MFA"; anything else is a real credential
97
+ # failure and must NOT trigger an MFA prompt.
98
+ mfa_indicator = /additional\s+authData|missing.*mfa|\bMFA\b/i
99
+ begin
100
+ response = Parse.client.login(user, pwd)
101
+ if response.success?
102
+ return Parse::User.with_authdata_trust { Parse::User.build(response.result) }
103
+ end
104
+ return nil unless response.error.to_s.match?(mfa_indicator)
105
+ rescue Parse::Error, Parse::Client::ResponseError => e
106
+ raise unless e.message.to_s.match?(mfa_indicator)
107
+ end
108
+
109
+ token = ENV["PARSE_LOGIN_MFA"].to_s.strip
110
+ if token.empty?
111
+ print "MFA token (authenticator code or recovery code): "
112
+ token = $stdin.gets.to_s.strip
113
+ end
114
+ abort "[client:console] MFA token required for #{user.inspect}" if token.empty?
115
+
116
+ # A wrong/expired token can surface either as Parse::MFA::VerificationError or,
117
+ # depending on the server error code path, as a generic Parse::Error (e.g.
118
+ # ServiceUnavailableError for the OTHER_CAUSE code) or a nil return. Since a
119
+ # token was supplied here, treat any failure as an MFA verification failure
120
+ # and abort cleanly rather than letting an unhandled exception escape.
121
+ result =
122
+ begin
123
+ Parse::User.login_with_mfa(user, pwd, token)
124
+ rescue Parse::MFA::VerificationError, Parse::Error => e
125
+ abort "[client:console] MFA verification failed for #{user.inspect}: #{e.message}"
126
+ end
127
+ abort "[client:console] MFA verification failed for #{user.inspect}" if result.nil?
128
+ result
129
+ end
130
+
86
131
  # Default test task runs all tests with Docker enabled.
87
132
  #
88
133
  # `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
@@ -131,7 +176,11 @@ def run_test_files!(label, files, log:)
131
176
  puts "[#{n}/#{total}] #{file}"
132
177
  puts "=" * 80
133
178
  t0 = Time.now
134
- ok = system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}")
179
+ # Always go through `bundle exec` so the locked gem versions win. With a
180
+ # bare `ruby`, RubyGems activates the newest installed minitest (6.0.x),
181
+ # which dropped the bundled `minitest/mock`; the standalone `minitest-mock`
182
+ # gem then can't co-activate and `test_helper.rb` fails to load every file.
183
+ ok = system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}")
135
184
  dt = Time.now - t0
136
185
  results << [file, ok, dt]
137
186
  summary = format("[%d/%d] %-4s %7.1fs %s", n, total, ok ? "PASS" : "FAIL", dt, file)
@@ -203,7 +252,7 @@ namespace :test do
203
252
  puts "=" * 80
204
253
  # Each file runs in its own process so a server outage in one cannot
205
254
  # bleed into the next.
206
- system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || begin
255
+ system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}") || begin
207
256
  # A disruptive test may have left the server down on failure; bring
208
257
  # it back so a follow-up run / other tasks start from a clean state.
209
258
  system("docker start #{ENV["PSNEXT_PREFIX"] || "psnext-it"}-server", out: IO::NULL, err: IO::NULL)
@@ -372,6 +372,10 @@ embed-time chunking), use one of these patterns:
372
372
 
373
373
  ## Retrieval (RAG)
374
374
 
375
+ > For an end-to-end runnable script — managed `embed`, `agent_searchable`,
376
+ > `semantic_search`, and an OpenAI/Anthropic generation add-in — see
377
+ > [`examples/rag_chatbot.rb`](../examples/rag_chatbot.rb).
378
+
375
379
  `Parse::Retrieval` (`Parse::RAG` is an alias) sits on top of
376
380
  `find_similar`. `Parse::Retrieval.retrieve` embeds a natural-language
377
381
  query, runs Atlas `$vectorSearch` through `find_similar` (so ACL/CLP are
@@ -395,8 +399,88 @@ chunks = Parse::Retrieval.retrieve(
395
399
  # => Array<Parse::Retrieval::Chunk> — { id, score, content, source, metadata }
396
400
  ```
397
401
 
398
- `rerank:` and `hybrid:` are reserved on the signature and raise
399
- `NotImplementedError` if supplied.
402
+ `retrieve` also accepts `hybrid:` (fuse a lexical branch with the vector
403
+ branch see [Hybrid search](#hybrid-search-vector--lexical) below) and
404
+ `rerank:` (reorder retrieved documents with a cross-encoder before
405
+ chunking — see [Reranking](#reranking)). Both were reserved in earlier
406
+ releases and now ship in 5.4.0.
407
+
408
+ ### Hybrid search (vector + lexical)
409
+
410
+ `Class.hybrid_search` runs a lexical Atlas Search (`$search`) branch and a
411
+ `$vectorSearch` branch as **two independent aggregations**, then fuses
412
+ their ranked results with reciprocal-rank fusion (RRF). Two aggregations
413
+ (not a single `$facet`) is mandatory: `$vectorSearch` is prohibited inside
414
+ `$facet` / `$lookup` / `$unionWith` and must be stage 0 of its pipeline.
415
+ Each branch enforces ACL/CLP/`protectedFields` independently before
416
+ fusion (via `Parse::AtlasSearch.search` and `Parse::VectorSearch.search`),
417
+ so the fused rows are already access-filtered — there is no separate
418
+ hydration fetch.
419
+
420
+ ```ruby
421
+ hits = Article.hybrid_search(
422
+ text: "how do I reset my password", # embedded for the vector branch;
423
+ # also the default lexical query
424
+ lexical: { index: "article_search", fields: %w[title body] },
425
+ vector: { index: "article_embedding_idx", num_candidates: 200 },
426
+ k: 20,
427
+ fusion: { k_constant: 60, weights: { lexical: 0.4, vector: 0.6 } },
428
+ session_token: user.session_token, # ACL scope, applied to BOTH branches
429
+ )
430
+ # => Array<Parse::Object>; each carries #hybrid_score, #hybrid_ranks,
431
+ # and #vector_score / #search_score when that branch contributed.
432
+ ```
433
+
434
+ **RRF math.** `fused_score(d) = Σ_b weight_b / (k_constant + rank_b(d))`,
435
+ where `rank_b(d)` is the document's 1-based rank in branch `b`. A larger
436
+ `k_constant` (default 60) flattens the contribution curve. `weights`
437
+ defaults to 1.0 per branch. `Parse::VectorSearch::Hybrid.rrf` exposes the
438
+ pure fusion if you want to fuse pre-fetched ranked lists yourself.
439
+
440
+ **Native `$rankFusion` (Atlas 8.0+).**
441
+ `Parse::VectorSearch::Hybrid.rank_fusion_supported?(collection)` detects
442
+ the native server-side fusion stage via a cached behavioural probe (1-hour
443
+ TTL — not version-string parsing). Native execution is **opt-in**
444
+ (`fusion: { method: :rrf_native }`) and falls back to the client-side path
445
+ when the cluster does not support it; the default `:rrf` always fuses
446
+ client-side, which is the fully-enforced, deterministic path. `$rankFusion`
447
+ is admitted to `PipelineSecurity::ALLOWED_STAGES` for the native path.
448
+
449
+ `Parse::Retrieval.retrieve(hybrid: true, ...)` routes through
450
+ `hybrid_search` and chunks the fused results; pass `hybrid: { lexical:,
451
+ vector:, fusion: }` to configure the branches. Tenant scope is folded into
452
+ **both** branches (the vector Atlas pre-filter and the lexical
453
+ post-`$search` `$match`) so neither leaks cross-tenant document existence.
454
+
455
+ ### Reranking
456
+
457
+ A reranker reorders retrieved documents by a cross-encoder relevance score
458
+ **before** chunking. Pass any object answering
459
+ `#rerank(query:, documents:, top_n:)` — typically a
460
+ `Parse::Retrieval::Reranker::Base` subclass:
461
+
462
+ ```ruby
463
+ reranker = Parse::Retrieval::Reranker::Cohere.new(
464
+ api_key: ENV.fetch("COHERE_API_KEY"), model: "rerank-v3.5",
465
+ )
466
+ chunks = Parse::Retrieval.retrieve(
467
+ query: "reset my password", klass: Article, k: 30,
468
+ rerank: reranker, rerank_top_n: 5, # keep the 5 most relevant docs
469
+ )
470
+ # Reranked chunks' score is the cross-encoder relevance_score.
471
+ ```
472
+
473
+ `Reranker::Fixture` is a deterministic, zero-network reranker (lexical
474
+ token overlap) for tests. The `Reranker::Base` protocol validates inputs,
475
+ bounds `top_n`, rejects out-of-range indices, and sorts descending —
476
+ adapters implement only the network call (`#rerank_scores`).
477
+
478
+ > **Spend cap.** The `semantic_search` agent tool charges the estimated
479
+ > query-embedding tokens against the caller's tenant budget via
480
+ > `Parse::Embeddings::SpendCap` (opt-in; `configure(limit_tokens:,
481
+ > window:)`). A breach hard-refuses (surfaced to the agent as a
482
+ > rate-limited tool error). Admin agents are exempt; direct
483
+ > `find_similar` / `retrieve` callers are not metered.
400
484
 
401
485
  ### Chunkers
402
486
 
@@ -11,6 +11,11 @@ go over REST, and authorization is carried by the user's `sessionToken`.
11
11
  Every claim below is locked in by the integration tests under
12
12
  `test/lib/parse/client_*_integration_test.rb`.
13
13
 
14
+ For a runnable starting point, see
15
+ [`examples/basic_client.rb`](../examples/basic_client.rb) (a no-master client
16
+ with a row-level ACL-enforcement demo) and its master-key counterpart
17
+ [`examples/basic_server.rb`](../examples/basic_server.rb).
18
+
14
19
  ---
15
20
 
16
21
  ## Why a separate guide?
data/docs/mcp_guide.md CHANGED
@@ -7,7 +7,7 @@ The Model Context Protocol (MCP) is a standardized JSON-RPC 2.0-based interface
7
7
  Three deployment modes are available:
8
8
 
9
9
  - **Standalone HTTP server (`MCPServer`)** — a WEBrick process for dedicated MCP deployments.
10
- - **Rack-mountable adapter (`MCPRackApp`)** — embeds inside an existing Sinatra or Rails application.
10
+ - **Rack-mountable adapter (`MCPRackApp`)** — embeds inside an existing Sinatra or Rails application. This is the primary deployment for the MCP 2025-06-18 Streamable HTTP transport; enable it with `transport: :streamable_http` (see [Streamable HTTP transport](#streamable-http-transport-primary)).
11
11
  - **Direct in-process dispatcher (`MCPDispatcher`)** — a pure function for in-process usage, custom transports, and testing.
12
12
 
13
13
  ---
@@ -191,6 +191,42 @@ map("/mcp") { run mcp_app }
191
191
  map("/") { run ->(env) { [200, {"Content-Type" => "text/plain"}, ["ok"]] } }
192
192
  ```
193
193
 
194
+ #### Streamable HTTP transport (primary)
195
+
196
+ The MCP 2025-06-18 **Streamable HTTP** transport is the recommended transport for `MCPRackApp`. It is a single connection model in which the client `POST`s JSON-RPC requests (receiving either a buffered JSON reply or, with `Accept: text/event-stream`, a streamed SSE reply) and holds open a long-lived `GET` request to receive server-initiated notifications. Session termination is signalled with `DELETE` carrying the `Mcp-Session-Id`.
197
+
198
+ Enable the whole transport with one switch:
199
+
200
+ ```ruby
201
+ mcp_app = Parse::Agent.rack_app(transport: :streamable_http) do |env|
202
+ # ... auth factory ...
203
+ end
204
+ ```
205
+
206
+ `transport: :streamable_http` is exactly equivalent to `streaming: true, notifications: true` — it turns on POST→SSE streaming and the server→client `GET /` notification stream together. Add `resource_subscriptions: true` alongside it to upgrade the server→client bus from the plain notification posture to the LiveQuery-backed resource-subscription posture:
207
+
208
+ ```ruby
209
+ mcp_app = Parse::Agent.rack_app(
210
+ transport: :streamable_http,
211
+ resource_subscriptions: true, # optional: bridge LiveQuery resource updates
212
+ ) do |env|
213
+ # ...
214
+ end
215
+ ```
216
+
217
+ `transport:` is a closed enum:
218
+
219
+ | Value | Effect |
220
+ |-------|--------|
221
+ | `:streamable_http` | Full Streamable HTTP transport (`streaming: true` + `notifications: true`). |
222
+ | `:legacy` / `nil` (default) | Historical behavior: buffered JSON responses, no server→client stream. The standalone SSE/JSON path below remains a supported fallback. |
223
+
224
+ Passing `transport: :streamable_http` together with an explicit `streaming:` or `notifications:` raises `ArgumentError` (the switch already owns those toggles); any value other than the two above also raises. The default is unchanged, so an existing `Parse::Agent.rack_app { ... }` keeps its non-streaming JSON behavior until you opt in.
225
+
226
+ **WEBrick cannot deliver Streamable HTTP.** The switch — like `streaming:` — has no effect under the WEBrick-backed standalone `MCPServer`, which buffers responses and cannot hold the `GET` stream open. Use Puma, Falcon, or Unicorn for a real Streamable HTTP deployment.
227
+
228
+ The remaining subsections document the individual toggles `transport: :streamable_http` consolidates, for operators who need finer control or are reading older configurations.
229
+
194
230
  #### MCP progress notifications via SSE (opt-in)
195
231
 
196
232
  **WEBrick cannot stream.** The standalone `MCPServer` is WEBrick-based and buffers the full response before sending. Setting `streaming: true` on an `MCPRackApp` mounted under WEBrick silently degrades to a single buffered response with concatenated SSE events. SSE streaming requires a Rack server that supports streaming response bodies — **Puma, Falcon, or Unicorn**. Verify your deployment uses one of these before relying on `streaming: true`.
@@ -537,10 +573,29 @@ Parse Server version and its `masterKeyIps` configuration.)
537
573
  soft cap *equal to* `max_concurrent_dispatchers`. So the effective steady-state
538
574
  ceiling across both surfaces is up to **2× `max_concurrent_dispatchers`** (up
539
575
  to N request-scoped SSE dispatchers plus N listening streams). Size the value
540
- with that 2× factor in mind (e.g. relative to your Puma `max_threads`). Leaving
541
- it unset (the default `nil`) leaves both surfaces uncapped; the app logs a
576
+ with that 2× factor in mind (e.g. relative to your Puma `max_threads`).
577
+ `max_concurrent_dispatchers:` defaults to a finite **100**
578
+ (`Parse::Agent::MCPRackApp::DEFAULT_MAX_CONCURRENT_DISPATCHERS`), so a
579
+ streaming surface is bounded out of the box — once the cap is reached a new
580
+ SSE request or listening stream is refused with a `503` JSON-RPC `-32000`
581
+ ("server busy"). Pass an explicit positive integer to resize it, or
582
+ `max_concurrent_dispatchers: nil` to knowingly run uncapped (the app logs a
542
583
  one-time warning at construction when a streaming or subscription/notification
543
- surface is enabled without a cap.
584
+ surface is enabled with `nil`). A non-positive or non-integer value raises
585
+ `ArgumentError`.
586
+ - **Client disconnect mid-tool-call.** When a client drops the connection while
587
+ a tool is still running, the SSE worker is torn down and the dispatcher's
588
+ cancellation token is tripped, so a cooperative tool (one that checks
589
+ `agent.cancelled?` at a checkpoint) exits promptly. A tool blocked inside a
590
+ Mongo/REST roundtrip cannot observe the token, but its slot is reclaimed when
591
+ the per-tool `Timeout` or the clean MongoDB `socket_timeout` (10s) / REST
592
+ `timeout` (30s) deadline fires — through the driver's clean error path. The
593
+ orphaned dispatcher is **intentionally not force-killed**: a `Thread#kill`
594
+ would bypass the driver's connection-invalidation and could return a half-used
595
+ pooled connection to a later request. To observe how often disconnects abandon
596
+ in-flight work, watch the cumulative
597
+ `Parse::Agent::MCPRackApp.abandoned_dispatcher_count` or subscribe to the
598
+ `parse.agent.mcp_dispatcher_abandoned` `ActiveSupport::Notifications` event.
544
599
 
545
600
  ### Listening-stream ownership
546
601
 
@@ -173,6 +173,58 @@ set the same kwargs on the query for chainable composition.
173
173
  Related: `first_direct(n)` for the first N rows, `count_direct` for a
174
174
  count-only query. Both accept the same auth kwargs.
175
175
 
176
+ #### Field projection: `keys` and `exclude_keys`
177
+
178
+ The two field-selection options behave differently on the direct path
179
+ because MongoDB's `$project` is an allowlist, not a denylist:
180
+
181
+ - **`keys` (allowlist)** compiles to a `$project` stage in the direct
182
+ pipeline, so the projection runs server-side in MongoDB — only the
183
+ named fields (plus the reserved envelope: `_id`, `_created_at`,
184
+ `_updated_at`, `_acl`) leave the database.
185
+
186
+ - **`exclude_keys` (denylist)** has no `$project` equivalent, so Parse
187
+ Stack honors it as a **post-fetch sanitize**: the pipeline is
188
+ unchanged, and the SDK recursively strips every key with a matching
189
+ name from the decoded results in Ruby. The fields still travel from
190
+ MongoDB to the client — this is a result-shaping convenience, not a
191
+ data-minimization or access-control boundary.
192
+
193
+ ```ruby
194
+ # Allowlist — projected server-side via $project
195
+ Song.query.keys(:title, :artist).results_direct
196
+
197
+ # Denylist — stripped client-side after fetch
198
+ Song.query.exclude_keys(:internal_notes).results_direct
199
+ ```
200
+
201
+ Two consequences specific to the direct path:
202
+
203
+ 1. **Recursive by name.** `exclude_keys(:name)` removes `name` at every
204
+ depth, including inside included/nested objects — so a query that
205
+ includes a pointer also strips the pointed-to object's `name`. This
206
+ is broader than Parse Server's REST `excludeKeys`, which is
207
+ path-scoped (top-level or dotted) and would leave the nested field
208
+ intact. The same query can therefore return different shapes on the
209
+ REST and direct paths.
210
+
211
+ 2. **Reserved fields are never stripped.** `objectId`, `className`,
212
+ `__type`, `createdAt`, `updatedAt`, `ACL`, and their Mongo
213
+ storage-form names (`_id`, `_created_at`, `_updated_at`, `_acl`) are
214
+ always retained, so excluding one of them is a no-op rather than a
215
+ way to break object reconstruction.
216
+
217
+ The sanitize applies to the object/decoded result paths
218
+ (`results_direct`, `first_direct`, and the auto-promoted
219
+ `$inQuery`/`$notInQuery` aggregation). The raw aggregation accessor
220
+ (`aggregate(...).raw`) returns documents untouched.
221
+
222
+ Because `exclude_keys` here is a projection convenience and not an
223
+ ACL/CLP/`protectedFields` boundary, the security contract in
224
+ [Security](#security) is unaffected — to keep a field from leaving the
225
+ database, use `keys` (allowlist) or `protectedFields`, not
226
+ `exclude_keys`.
227
+
176
228
  ### `Query#aggregate(pipeline, mongo_direct: true)`
177
229
 
178
230
  ```ruby
@@ -233,9 +285,35 @@ raw = Parse::MongoDB.find(
233
285
  ```
234
286
 
235
287
  Convenience wrapper around `db.find`. Accepts `limit:`, `skip:`, `sort:`,
236
- `projection:`, `max_time_ms:`. When `:limit` is omitted the call applies
288
+ `projection:`, `hint:`, `max_time_ms:`. When `:limit` is omitted the call applies
237
289
  `DEFAULT_FIND_LIMIT = 1000` and warns; pass `limit: 0` to opt out.
238
290
 
291
+ ### Forcing an index with `hint`
292
+
293
+ When the query planner picks a sub-optimal index on a large collection,
294
+ `Query#hint` forces a specific one. It applies on **both** paths — the REST body
295
+ (`hint` parameter, Parse Server 7.4.0+) and the mongo-direct path — so a plan you
296
+ diagnosed with `Query#explain` can be corrected without dropping to `mongosh`.
297
+
298
+ ```ruby
299
+ # Diagnose, then force the index, on the mongo-direct path:
300
+ Post.query(:status => "published").order(:created_at.desc).hint("status_1_created_at_-1")
301
+ .results_direct
302
+
303
+ # A key pattern works too:
304
+ Post.query(:status => "published").hint({ "status" => 1, "createdAt" => -1 }).count_direct
305
+ ```
306
+
307
+ On the mongo-direct path the hint is forwarded to the driver as the Mongo `hint`
308
+ option: `results_direct` / `count_direct` / `distinct_direct` pass it to
309
+ `Parse::MongoDB.aggregate` (`hint:` → the aggregation `hint` option), and the
310
+ primitives `Parse::MongoDB.aggregate(..., hint:)` and
311
+ `Parse::MongoDB.find(..., hint:)` accept it directly. The index name (a `String`)
312
+ or a key pattern (`Hash`) are both accepted; an unknown index name is rejected by
313
+ MongoDB, which is the intended fail-fast signal that the hint is stale.
314
+
315
+ `hint` is unset by default (the planner chooses); it is purely an override.
316
+
239
317
  ### Geo queries
240
318
 
241
319
  Three geo query constraints land in v4.4.0 alongside a direct
@@ -620,6 +698,20 @@ ACL/CLP enforcement if the SDK applies it.
620
698
  As of **v4.4.0**, the SDK applies that enforcement on the mongo-direct
621
699
  path when the caller supplies a scope. Five layers compose:
622
700
 
701
+ > **Atlas index entry points share this enforcement.** The Atlas-index
702
+ > stages (`$vectorSearch`, `$search`, `$rankFusion`) must be stage 0 of
703
+ > their pipeline, so they cannot route through `Parse::MongoDB.aggregate`
704
+ > (which prepends an ACL `$match` at stage 0). `Parse::VectorSearch.search`
705
+ > (`find_similar`), `Parse::AtlasSearch.search`, and
706
+ > `Parse::VectorSearch::Hybrid` (`Class.hybrid_search`, v5.4.0) therefore
707
+ > reproduce the same enforcement chain **inline** — the ACL `_rperm`
708
+ > `$match` is appended AFTER the index stage, and CLP / `protectedFields` /
709
+ > the internal-fields denylist run post-fetch — so the same scope kwargs
710
+ > (`session_token:` / `acl_user:` / `acl_role:` / `master:`) and the same
711
+ > contract apply. Hybrid search fuses two independently-enforced branches,
712
+ > so fused rows are already access-filtered. `$rankFusion` was added to the
713
+ > strict-mode allowlist (Layer 1) in v5.4.0 for the opt-in native path.
714
+
623
715
  ### Layer 1: Pipeline-security denylist (always on)
624
716
 
625
717
  `Parse::PipelineSecurity` refuses dangerous operators at any depth in
data/docs/usage_guide.md CHANGED
@@ -83,10 +83,20 @@ Song.query.order(:plays.desc).skip(10).limit(20).results
83
83
  # Include related objects
84
84
  Song.all(includes: [:album, :comments])
85
85
 
86
- # Select specific fields
86
+ # Select specific fields (allowlist)
87
87
  Song.all(keys: [:title, :artist])
88
+
89
+ # Omit specific fields (denylist)
90
+ Song.query.exclude_keys(:internal_notes).results
88
91
  ```
89
92
 
93
+ > On the mongo-direct read path, `keys` is projected server-side while
94
+ > `exclude_keys` is applied as a recursive post-fetch sanitize (it strips
95
+ > matching field names at every depth and never removes reserved fields
96
+ > such as `objectId`). See the
97
+ > [Direct MongoDB Integration Guide](mongodb_direct_guide.md) for the
98
+ > exact semantics and how it differs from the REST path.
99
+
90
100
  ## Aggregation
91
101
 
92
102
  ```ruby