v5.4.0 - Parse Server 8/9 Compatibility, Hybrid-Search RAG, MCP Streaming
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: passadmin_role:(the library verifies the operator's role membership) orallow_unverified: true(assert the operator was authorized out-of-band). Callers that previously passed onlyauthorized_by:now raiseParse::MFA::ForbiddenError. Migration: addadmin_role:orallow_unverified: true. - BREAKING: cloud-function results now decode
__type-encoded Parse objects back intoParse::Object/Parse::Pointer(Parse Server 8.0 began encoding returned objects; 9.0 made it unconditional). This makesParse.call_functionresults ORM-typed for registered classes when read in-process in Ruby: a field read goes through the property getter, so anenum/symbolize:property yields a Symbol (:active, not"active"), a date yields aParse::Date, and a pointer yields aParse::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 wholeParse::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_prefnow rides the REST query body (readPreference), not just theX-Parse-Read-Preferenceheader — 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
keyssubscription option (Parse Server 7.0 renamed it fromfields), withfieldskept as an alias for older servers — a projected subscription was otherwise silently receiving every column on 7.0+. - NEW:
Parse.server_supports?(:capability)andParse.server_features(plus client-level equivalents) expose a capability probe built on the memoizedserverInfofetch, so future server changes can be feature-gated rather than discovered by breakage. It prefers the advertisedfeaturesblock and falls back to version inference, failing open to the current server line when the version is unknown. - IMPROVED:
Query#explainsurfaces actionable guidance against Parse Server 9.0'sallowPublicExplain: falsedefault — a proactive one-shot warning, a reactive message, and the same guidance from theexplain_queryagent 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::Payloadgains matching predicates (before_login?…after_event?, plusauth_trigger?/live_query_trigger?), aneventaccessor,clients/subscriptionsconnection counters, and captures the top-levelsessionTokenthat connect/subscribe carry into#session_token(so#user_client/#user_agentwork, while keeping the token out ofas_jsonand the request log). Dispatch matches Parse Server's response contract: the body is ignored for all seven, so abefore*handler returningfalse(which Parse Server would resolve as{success:false}and allow) is converted to a rejection. None of these run ActiveModelsave/create/destroycallbacks. - FIXED:
beforeFind/afterFindwebhook triggers now route. Parse Server omits the class name from the find payload entirely, so the SDK could not resolveparse_classand the dispatcher never invoked the handler; the class is now threaded from the webhook URL path (<endpoint>/<trigger>/<className>) into thePayload. This is also a correctness fix: an unroutedafterFindreturned{"success": true}instead of an objects array, which Parse Server rejects — so a registeredafterFindpreviously broke every matching query with a connection error. The path segment is charset-validated before use as a routing key. - FIXED:
:vectorcolumns are now stripped fromafterFindwebhook payloadobjects(the route-derived class is the only way to resolve the model, since the find payload carries no className;vector_visibility :publicclasses 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/afterCreateare no longer presented as registerable webhook triggers (Parse Server has no such type); they remain ActiveModel callbacks that run inside thebeforeSave/afterSavehandler. 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, andlocal_only_callbacks. Returns a Hash, or a human-readable summary withpretty: true;network: falseaudits 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 viainstance_exec, so a barereturnraisedLocalJumpErrorwhen the handler was defined inside a method (initializer, class body, config block); they now run as a method on the payload, givingreturnordinary semantics. The legacy idioms (last expression,next,break) still set the result, andselfis still the payload. - NEW:
payload.after_response { … }(aliasdefer) runs work after the webhook response is sent, off the client's critical path (search indexing, cache warming, fan-out). Usesrack.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 oncreate_object/update_object,call_function/call_function_with_session, andParse.call_function— serialized toX-Parse-Cloud-Contextand exposed to Cloud Code triggers;Webhooks::Payload#contextreads it on the receive side. - NEW:
Parse::User#verify_password(password)/Parse::API::Users#verify_password(username, password)validate credentials viaPOST /verifyPassword(credentials in the body, mirroringlogin, so the plaintext password stays out of URLs and logs) without minting a session — a step-up / re-auth primitive. - NEW:
Parse::Error::EmailNotVerifiedErrorfromParse::User.login!distinguishes "verify your email" (preventLoginWithUnverifiedEmail, code 205) from bad credentials. It subclassesParse::Error::AuthenticationError, so existingrescue AuthenticationErrorhandlers keep catching it (non-breaking). - NEW:
Query#exclude_keys(*fields)(excludeKeys), LiveQuerysubscribe(watch: [...])(update events only when named fields change, 7.0+),Query#aggregate(pipeline, raw_values:, raw_field_names:)(9.9.0rawValues/rawFieldNames),Query#hint(index_name)(REST + mongo-direct), and the:field.contained_by => [...]($containedBy) constraint. - IMPROVED:
Query#exclude_keysnow also takes effect on the mongo-direct read path (results_direct,first_direct, and aggregations that auto-promote to direct MongoDB). Because MongoDB's$projectis 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_keysremains a result-shaping convenience, not an ACL/CLP boundary — usekeysorprotectedFieldsto 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$vectorSearchbranch via reciprocal-rank fusion (RRF). Each branch enforces ACL / CLP /protectedFieldsindependently 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$rankFusiondetection via a cached behavioural probe, not version-string parsing). - NEW:
Parse::Retrieval::Rerankercross-encoder protocol with a deterministicReranker::Fixtureand aReranker::Cohereadapter (/v2/rerank);Parse::Retrieval.retrievenow acceptshybrid:andrerank:(previously reserved, raisingNotImplementedError), withtenant_scope:enforced authoritatively in both branches. - NEW:
Parse::Embeddings::SpendCap— opt-in per-tenant cumulative embedding-token cap with hard-refuse, charged at thesemantic_searchagent-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:
PipelineSecurityadmits$rankFusion(read-only, stage-0 Atlas operator) for the opt-in native path.
Retrieval (RAG): completeness
- NEW:
Class.embed_pending!backfills null:vectorfields 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 | :publiccontrols whether a class's:vectorproperties appear inas_jsonby default (:owner_onlyis the safe default; an explicitinclude_vectors:always wins). - IMPROVED: webhook trigger payloads now strip declared
:vectorcolumns fromobject/original/update/objectsby default (a:publicclass keeps them).
MCP: Streamable HTTP transport and disconnection hardening
- NEW:
Parse::Agent::MCPRackApp.new(transport: :streamable_http)(andParse::Agent.rack_app(transport:)) enables the full MCP 2025-06-18 Streamable HTTP transport in one switch — POST→SSE streaming plus the server→clientGET /notification stream — equivalent tostreaming: 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 explicitstreaming:/notifications:, or an unknown value, raisesArgumentError. - 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 a503JSON-RPC-32000instead of spawning unbounded orphan-prone threads. Pass an explicit integer to resize, ornilto knowingly run uncapped (logs a one-time warning); a non-positive / non-integer value raisesArgumentError. - NEW: disconnect observability —
MCPRackApp.abandoned_dispatcher_count(process-wide counter) plus aparse.agent.mcp_dispatcher_abandonedActiveSupport::Notificationsevent on every premature close. On disconnect the dispatcher's cancellation token is tripped and the orphan is bounded by the per-toolTimeoutand clean I/O deadlines; it is intentionally not force-killed (aThread#killwould risk returning a half-used pooled connection). - CHANGED: custom tools registered via
Parse::Agent::Tools.registernow have their declaredtimeout:(default 30s) actually enforced —Tools.invokewraps the handler inTimeout.timeout, raisingToolTimeoutError(previously the custom-handler path ran unbounded).registerrejects a non-positivetimeout:. Migration: a custom tool that legitimately runs longer than 30s must now declare an explicittimeout:.
Auth and accounts
- FIXED:
Parse::UserMFA 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_statusreport correctly after an ordinary fetch (a leak-safe{status: "enabled"}projection is preserved while the TOTP secret and recovery codes are stripped). Self-servicedisable_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:consoleprompts for a TOTP / recovery code (or readsPARSE_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, mirroringrequest_password_reset. - FIXED:
Parse::Audience#queryis stored as a JSON string on the wire to match Parse Server's_Audience.querycolumn type, so saving a hash query no longer fails the server schema check. Public API unchanged (assign / read aHash).
Performance and tooling
- CHANGED:
Parse::AtlasSearchrole_cache_ttlnow defaults to 30s (was 120) so role grants / revokes reflect in$searchACL 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_privatepolicy, its private fallback, and how to override it viaset_default_acl/acl_policy. Added a Cloud Code Webhooks guide, a runnableexamples/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
$rankFusionpath 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_rpermand fail closed. - LiveQuery webhook triggers are delivered over HTTP only in a co-located single-process setup;
beforeConnectis 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