Skip to content

v5.4.0 - Parse Server 8/9 Compatibility, Hybrid-Search RAG, MCP Streaming

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 06 Jun 19:51
· 30 commits to main since this release

Feature Release

A Parse Server 8.x / 9.x compatibility pass with a capability-detection layer, full webhook trigger coverage (including the auth and LiveQuery triggers and a fix that makes beforeFind / afterFind actually route), hybrid (lexical + vector) retrieval with reranking, and a one-switch MCP Streamable HTTP transport with disconnection hardening. It also carries MFA, email-verification, Parse::Audience, and console fixes.

Breaking Changes

  • BREAKING: Parse::User#disable_mfa_master_key! now fails closed. Because it bypasses MFA verification via the master key, it refuses to run without an authorization signal: pass admin_role: (the library verifies the operator's role membership) or allow_unverified: true (assert the operator was authorized out-of-band). Callers that previously passed only authorized_by: now raise Parse::MFA::ForbiddenError. Migration: add admin_role: or allow_unverified: true.
  • BREAKING: cloud-function results now decode __type-encoded Parse objects back into Parse::Object / Parse::Pointer (Parse Server 8.0 began encoding returned objects; 9.0 made it unconditional). This makes Parse.call_function results ORM-typed for registered classes when read in-process in Ruby: a field read goes through the property getter, so an enum / symbolize: property yields a Symbol (:active, not "active"), a date yields a Parse::Date, and a pointer yields a Parse::Pointer, where a pre-5.4.0 caller read a raw JSON String/Hash. HTTP/JSON consumers are unaffected — JSON has no symbols, so values re-serialize to strings on the way out; webhook trigger returns are also unaffected (those go to Parse Server, not through the result decoder). Decoding is conservative: an unregistered class is left as a raw Hash and plain data passes through untouched. Migration: a cloud function whose JSON shape is a contract should return an explicit plain Hash and coerce typed fields (status: obj.status.to_s) rather than whole Parse::Objects; Ruby callers reading typed fields should expect the ORM type or normalize at the read site.

Changes

Parse Server 8.x / 9.x compatibility

  • FIXED: Query#read_pref now rides the REST query body (readPreference), not just the X-Parse-Read-Preference header — Parse Server maps no such header, so over REST the preference was silently ignored and every scoped read hit the primary. The mongo-direct path was already correct.
  • FIXED: LiveQuery field projection now emits the keys subscription option (Parse Server 7.0 renamed it from fields), with fields kept as an alias for older servers — a projected subscription was otherwise silently receiving every column on 7.0+.
  • NEW: Parse.server_supports?(:capability) and Parse.server_features (plus client-level equivalents) expose a capability probe built on the memoized serverInfo fetch, so future server changes can be feature-gated rather than discovered by breakage. It prefers the advertised features block and falls back to version inference, failing open to the current server line when the version is unknown.
  • IMPROVED: Query#explain surfaces actionable guidance against Parse Server 9.0's allowPublicExplain: false default — a proactive one-shot warning, a reactive message, and the same guidance from the explain_query agent tool — instead of a bare 403. Suppressed for master-key and unknown-version calls.

Webhook trigger coverage

  • NEW: first-class routing for the non-object trigger shapes — the authentication triggers (beforeLogin, afterLogin, afterLogout, beforePasswordResetRequest) and the LiveQuery triggers (beforeConnect, beforeSubscribe, afterEvent). Parse::Webhooks::Payload gains matching predicates (before_login?after_event?, plus auth_trigger? / live_query_trigger?), an event accessor, clients / subscriptions connection counters, and captures the top-level sessionToken that connect/subscribe carry into #session_token (so #user_client / #user_agent work, while keeping the token out of as_json and the request log). Dispatch matches Parse Server's response contract: the body is ignored for all seven, so a before* handler returning false (which Parse Server would resolve as {success:false} and allow) is converted to a rejection. None of these run ActiveModel save / create / destroy callbacks.
  • FIXED: beforeFind / afterFind webhook triggers now route. Parse Server omits the class name from the find payload entirely, so the SDK could not resolve parse_class and the dispatcher never invoked the handler; the class is now threaded from the webhook URL path (<endpoint>/<trigger>/<className>) into the Payload. This is also a correctness fix: an unrouted afterFind returned {"success": true} instead of an objects array, which Parse Server rejects — so a registered afterFind previously broke every matching query with a connection error. The path segment is charset-validated before use as a routing key.
  • FIXED: :vector columns are now stripped from afterFind webhook payload objects (the route-derived class is the only way to resolve the model, since the find payload carries no className; vector_visibility :public classes keep them).
  • NEW: file (@File) and connection (@Connect) triggers now have a full register / fetch / delete lifecycle; Parse::API::PathSegment.trigger_class_name! accepts the @-prefixed pseudo-classes.
  • CHANGED: beforeCreate / afterCreate are no longer presented as registerable webhook triggers (Parse Server has no such type); they remain ActiveModel callbacks that run inside the beforeSave / afterSave handler. Registering a create trigger now raises a clear error pointing to the save trigger.
  • NEW: Parse::Webhooks.trigger_audit — a master-key operator audit that cross-references three sources of trigger truth across every registered class (a model's ActiveModel callbacks, the locally registered webhook blocks, and the triggers registered with Parse Server) and reports where they drift. It surfaces the non-obvious rule that a callback runs server-side for non-Ruby clients only when both a local webhook block and the matching server trigger are registered. Findings: callbacks_inert, route_not_registered, orphan_server_trigger, and local_only_callbacks. Returns a Hash, or a human-readable summary with pretty: true; network: false audits against local routes without a master key.

Webhook handler ergonomics

  • IMPROVED: a registered webhook handler can now use an explicit return value. Handlers previously ran via instance_exec, so a bare return raised LocalJumpError when the handler was defined inside a method (initializer, class body, config block); they now run as a method on the payload, giving return ordinary semantics. The legacy idioms (last expression, next, break) still set the result, and self is still the payload.
  • NEW: payload.after_response { … } (alias defer) runs work after the webhook response is sent, off the client's critical path (search indexing, cache warming, fan-out). Uses rack.after_reply (Puma / Unicorn) when available, else a detached thread; callbacks run in registration order, are isolated, and fire only on the success path. In-process only — use a durable queue for work that must happen.

Parse Server feature coverage

  • NEW: context: propagation on create_object / update_object, call_function / call_function_with_session, and Parse.call_function — serialized to X-Parse-Cloud-Context and exposed to Cloud Code triggers; Webhooks::Payload#context reads it on the receive side.
  • NEW: Parse::User#verify_password(password) / Parse::API::Users#verify_password(username, password) validate credentials via POST /verifyPassword (credentials in the body, mirroring login, so the plaintext password stays out of URLs and logs) without minting a session — a step-up / re-auth primitive.
  • NEW: Parse::Error::EmailNotVerifiedError from Parse::User.login! distinguishes "verify your email" (preventLoginWithUnverifiedEmail, code 205) from bad credentials. It subclasses Parse::Error::AuthenticationError, so existing rescue AuthenticationError handlers keep catching it (non-breaking).
  • NEW: Query#exclude_keys(*fields) (excludeKeys), LiveQuery subscribe(watch: [...]) (update events only when named fields change, 7.0+), Query#aggregate(pipeline, raw_values:, raw_field_names:) (9.9.0 rawValues / rawFieldNames), Query#hint(index_name) (REST + mongo-direct), and the :field.contained_by => [...] ($containedBy) constraint.
  • IMPROVED: Query#exclude_keys now also takes effect on the mongo-direct read path (results_direct, first_direct, and aggregations that auto-promote to direct MongoDB). Because MongoDB's $project is allowlist-only, the SDK applies the denylist as a recursive post-fetch sanitize over the decoded results; decode-critical reserved fields are never stripped. exclude_keys remains a result-shaping convenience, not an ACL/CLP boundary — use keys or protectedFields to keep a field from leaving the database.

Retrieval (RAG): hybrid search and reranking

  • NEW: Class.hybrid_search(text:, lexical:, vector:, k:, fusion:) fuses a lexical Atlas Search branch with a $vectorSearch branch via reciprocal-rank fusion (RRF). Each branch enforces ACL / CLP / protectedFields independently before fusion, so fused rows are already access-filtered. Results carry #hybrid_score, #hybrid_ranks, and (when the branch contributed) #vector_score / #search_score.
  • NEW: Parse::VectorSearch::Hybrid.rrf (pure fusion math) and .rank_fusion_supported? (Atlas 8.0+ native $rankFusion detection via a cached behavioural probe, not version-string parsing).
  • NEW: Parse::Retrieval::Reranker cross-encoder protocol with a deterministic Reranker::Fixture and a Reranker::Cohere adapter (/v2/rerank); Parse::Retrieval.retrieve now accepts hybrid: and rerank: (previously reserved, raising NotImplementedError), with tenant_scope: enforced authoritatively in both branches.
  • NEW: Parse::Embeddings::SpendCap — opt-in per-tenant cumulative embedding-token cap with hard-refuse, charged at the semantic_search agent-tool boundary (admin agents exempt). The token estimate takes the larger of a character- and byte-based heuristic so multibyte input is not undercounted.
  • CHANGED: PipelineSecurity admits $rankFusion (read-only, stage-0 Atlas operator) for the opt-in native path.

Retrieval (RAG): completeness

  • NEW: Class.embed_pending! backfills null :vector fields via objectId-cursor pagination (field:, batch_size:, limit:, where:); Parse::Object#compute_embedding! forces a digest-tracked in-place recompute without a save.
  • NEW: vector_visibility :owner_only | :public controls whether a class's :vector properties appear in as_json by default (:owner_only is the safe default; an explicit include_vectors: always wins).
  • IMPROVED: webhook trigger payloads now strip declared :vector columns from object / original / update / objects by default (a :public class keeps them).

MCP: Streamable HTTP transport and disconnection hardening

  • NEW: Parse::Agent::MCPRackApp.new(transport: :streamable_http) (and Parse::Agent.rack_app(transport:)) enables the full MCP 2025-06-18 Streamable HTTP transport in one switch — POST→SSE streaming plus the server→client GET / notification stream — equivalent to streaming: true, notifications: true. Streamable HTTP is now documented as the primary embedded-Rack transport. transport: is a closed enum (:streamable_http / :legacy / nil); passing it alongside an explicit streaming: / notifications:, or an unknown value, raises ArgumentError.
  • CHANGED: max_concurrent_dispatchers: now defaults to a finite 100 (was unlimited), so a streaming surface is bounded out of the box — the cap fires a 503 JSON-RPC -32000 instead of spawning unbounded orphan-prone threads. Pass an explicit integer to resize, or nil to knowingly run uncapped (logs a one-time warning); a non-positive / non-integer value raises ArgumentError.
  • NEW: disconnect observability — MCPRackApp.abandoned_dispatcher_count (process-wide counter) plus a parse.agent.mcp_dispatcher_abandoned ActiveSupport::Notifications event on every premature close. On disconnect the dispatcher's cancellation token is tripped and the orphan is bounded by the per-tool Timeout and clean I/O deadlines; it is intentionally not force-killed (a Thread#kill would risk returning a half-used pooled connection).
  • CHANGED: custom tools registered via Parse::Agent::Tools.register now have their declared timeout: (default 30s) actually enforced — Tools.invoke wraps the handler in Timeout.timeout, raising ToolTimeoutError (previously the custom-handler path ran unbounded). register rejects a non-positive timeout:. Migration: a custom tool that legitimately runs longer than 30s must now declare an explicit timeout:.

Auth and accounts

  • FIXED: Parse::User MFA lifecycle — setup_mfa!, setup_sms_mfa!, confirm_sms_mfa!, disable_mfa!, disable_mfa_master_key! no longer raise an internal argument error before reaching the server; mfa_enabled? / mfa_status report correctly after an ordinary fetch (a leak-safe {status: "enabled"} projection is preserved while the TOTP secret and recovery codes are stripped). Self-service disable_mfa! proves possession of the current code, then unlinks the provider, confirming the disable from the server's own view.
  • NEW: interactive console MFA login — rake client:console prompts for a TOTP / recovery code (or reads PARSE_LOGIN_MFA) when logging into an enrolled account.
  • NEW: Parse::User.request_email_verification(email) (and the instance form) re-sends the verification email for a registered, unverified user, mirroring request_password_reset.
  • FIXED: Parse::Audience#query is stored as a JSON string on the wire to match Parse Server's _Audience.query column type, so saving a hash query no longer fails the server schema check. Public API unchanged (assign / read a Hash).

Performance and tooling

  • CHANGED: Parse::AtlasSearch role_cache_ttl now defaults to 30s (was 120) so role grants / revokes reflect in $search ACL decisions sooner.
  • CHANGED: test tasks run through Bundler (bundle exec ruby) to avoid a minitest activation/load error on individual files; README documents the requirement.
  • IMPROVED: ACL documentation clarifies the default :owner_else_private policy, its private fallback, and how to override it via set_default_acl / acl_policy. Added a Cloud Code Webhooks guide, a runnable examples/webhook_server.rb, and a README section on how ActiveModel callbacks relate to Parse Server trigger types.

Behavior Notes

  • Hybrid fusion runs client-side by default. The native single-roundtrip $rankFusion path is opt-in (fusion: { method: :rrf_native }) and falls back to client-side fusion when the cluster does not support it. When native fusion executes, top-level rows are re-verified against the scope's _rperm and fail closed.
  • LiveQuery webhook triggers are delivered over HTTP only in a co-located single-process setup; beforeConnect is effectively in-process only.
  • MCP forcible disconnect-reclaim is a deliberate non-goal. An orphaned dispatcher is bounded (cap + per-tool Timeout + clean I/O deadlines) but not force-killed, to avoid connection-pool corruption.

Commit: abdcf11
Author: Adrian Curtin
Date: June 6, 2026