Skip to content

MCP User Filtering: Verified and Onboarded as Separate Query Filters

An operator investigating "April 25 Orange Alert" (569 recipients) asked the AI agent a natural follow-up: "how many of those are app users?" The agent answered 0 — wrong, and wrong in a way that revealed a real gap rather than a model hallucination: GetAlertUsersTool had no way to filter on users.verified or users.onboarded, and no other MCP tool existed that could query users by those states at all, so the agent had no correct answer available to it [@design-doc]. The fix extended GetAlertUsersTool with both filters, added a new cross-cutting QueryUsersTool, and made pagination signals explicit enough that an agent can't silently conflate "the page I got back" with "everything that matched."

Context

"App user" is not one flag in this system. users.verified means the user has confirmed their location; users.onboarded means the user has accepted the terms and conditions — two independent product states, covered in more depth in Verified vs. Onboarded user states. Because GetAlertUsersTool filtered only on delivery status, an agent asked about onboarding or verification numbers had nothing to query against and, in this incident, silently conflated "the users the tool returned" with "the users that actually match," reporting a flat zero instead of admitting it could not answer [@design-doc].

The team considered and rejected folding verified and onboarded into one combined app_user filter. Collapsing them would have been more convenient for a single query, but it would blur a distinction the product deliberately keeps separate — a decision this page treats as settled rather than reopening it.

Decision

GetAlertUsersTool gained two optional boolean parameters, verified and onboarded, which AND together with each other and with the tool's existing delivery_status filter [@get-alert-users-tool]. A caller can now ask "of the recipients on this alert, how many have verified = true" directly, instead of the agent guessing.

A new QueryUsersTool, backed by a new App\Services\UserQueryService, is the cross-cutting counterpart: it queries users by any combination of alert_id, alert_severity, verified, onboarded, and fsp_id, requiring at least one filter to avoid becoming a dump-all-users endpoint [@query-users-tool]. UserQueryService::query() builds the filtered User query, counts total_matching before applying limit/offset, and only joins through userAlerts when an alert-related filter is actually present, keeping the common case (a bare verified/onboarded/fsp_id query) a simple WHERE rather than a join [@user-query-service]. The two boolean filters are applied as independent where() clauses in baseQuery() rather than merged into a single derived column, which is the mechanism that keeps them separate at the SQL level, not just at the API surface [@user-query-service].

Both tools also gained explicit pagination signaling designed for an LLM caller rather than a human developer: has_more, next_offset, truncated (redundant with has_more on purpose, since different agents notice different response keys), and a _note string present only when the page is truncated, telling the caller to re-call with offset = next_offset [@query-users-tool]. Both tools' descriptions were rewritten to explicitly instruct the calling agent to check total_matching against count before reporting a total to a user [@get-alert-users-tool] [@query-users-tool] — a direct response to the incident, where the agent had a correct count available but reported it as a total.

This work is scoped to InternalYayaServer; the Dify-facing YayaServer tools were not touched. See MCP servers and the Dify integration for how the two servers differ, and the MCP tools reference for the full parameter list of both tools.

Status

Approved and implemented.

Consequences

Operators can now get a correct answer to "how many of these are onboarded/verified" without a code change, and a badly-scoped query (zero filters on QueryUsersTool) fails loudly with Response::error('At least one filter is required.') instead of silently returning everything. The explicit pagination fields are advisory, not enforced — a poorly prompted agent can still ignore has_more and under-report a total, so the tool description carries the real defense and the response fields are a backstop, not a guarantee.

The team deliberately left the "verified OR onboarded" convenience filter unimplemented. A caller who wants that union must call the tool twice and combine results itself; this is intentional friction that keeps the underlying distinction from being silently erased inside a single "app user" concept in future queries.