Serve row-scoped tools to consumers over MCP
Give a signed-in consumer’s own AI agent a tool that returns only that consumer’s rows. The row scope is the same server-bound ownership boundary the dashboard already uses; MCP does not add a second authorization path. This recipe binds an existing catalog tool to the existing ownership saved query so tools/call is scoped to the calling consumer.
Preconditions: an ownership saved query with a current_user_id-bound inner join (row-level-security), a catalog tool for it (mcp-tool-catalog), and a served, enabled endpoint (mcp-endpoint). The consumer query guard itself permits any join shape and ON predicate as long as every referenced model and field is public, and identity is available only through the server-bound current_user_id parameter — this recipe’s row scope depends on the inner ownership join specifically, so keep it.
This recipe’s require_query action executes the ownership query with public_saved_query=False (rule_saved_query_executor.py), not the public HTTP route’s public_saved_query=True. Under this execution context the query is checked against builder shape and builder field visibility rather than the public-field walk, and current_user_id is server-bound to the event actor — the consumer the rule is acting on behalf of, not a caller-suppliable value. The ownership join compiles under this flag exactly as it does under the public flag, so the same saved query serves both this recipe and a page-render binding. See docs/adr/2026-09-21-saved-query-full-expressiveness.md, ruling 8.
1. Bind the tool to the ownership query
Section titled “1. Bind the tool to the ownership query”Create two application-scoped rules (model_id null) triggered by tool_called, each scoped to the one tool by name so unrelated tools do not fire them:
{"==": [{"var": "event.tool_name"}, "list_my_contacts"]}Query rule, trigger tool_called, phase post_save. It runs the ownership saved query and stops the tool if the caller has no rows:
{ "type": "require_query", "params": { "saved_query_id": "<ownership-saved-query-id>", "message": "No visible contacts for this consumer", "template": {} }}Leave template empty. current_user_id is server-bound and cannot be supplied; naming it in the template is rejected. The runtime binds it from the calling consumer’s identity, exactly as the dashboard’s query execution does.
Response rule, trigger tool_called, phase response. It shapes the tool’s result body from the rows the query returned:
{ "type": "patch_response", "patches": [ {"op": "replace", "path": "/body", "value": {"contacts": {"var": "query_rows"}}} ]}query_rows is the output of the query rule. The two rules cooperate: the post_save rule produces the rows, the response rule renders them into the tool result.
2. Where the identity comes from
Section titled “2. Where the identity comes from”The calling consumer authenticates the MCP request with the consumer OAuth bearer their MCP client obtained through the application host’s consent page: Authorization: Bearer <consumer OAuth access token>. The client adds https://<app host>/mcp as a remote connector, the consumer signs in as an application user in the browser tab it opens, reviews the client and scopes on https://<app host>/oauth/authorize, and approves; the client exchanges the code at https://<app host>/token. The grant’s subject is the consumer’s app_user record, and the runtime binds it as current_user_id for the ownership join. The consumer never supplies an identity, and a tool argument can never set it.
The bearer is issued for the endpoint’s one audience, the inbound URL of the service in mcp_settings.endpoint_service_id, so it is accepted on both the /mcp route and the inbound route. A consumer session token sent as a bearer, the consumer session cookie and the endpoint service’s API key are not MCP credentials: each gets the 401 that starts the client’s sign-in (mcp-endpoint, section 4). See the customer guide Connect an agent to your application.
Two guarantees follow, both fail closed:
- Unauthenticated call. No consumer bearer, so the call is refused with
401before any tool runs; with anonymous access opted in (mcp-endpoint, section 5), an anonymous call of a non-anonymous tool gets the same401, and an anonymous tool runs as the service actor, so the ownership join binds nothing and the tool returns its miss message rather than another consumer’s rows. - Cross-application bearer. A bearer issued for a different application has a different audience and is refused; it never scopes this application’s rows.
3. Prove it with three consumers
Section titled “3. Prove it with three consumers”Serve the tool and connect three MCP clients, one per consumer. For each, run the browser consent journey on the application host as that consumer: open https://<app host>/oauth/authorize with the client’s registered redirect_uri and an S256 code_challenge, sign in on the application’s sign-in page when it redirects with returnTo, approve on the consent page, and exchange the returned code with its code_verifier at https://<app host>/token. That yields three consumer bearers, each bound to its own app_user record.
Call the tool with each bearer. Each receives only their granted rows, for example four, one, and zero, and none sees another’s. Run initialize then tools/list then tools/call per bearer; do not reuse one consumer’s Mcp-Session-Id for another. A fourth call with no bearer gets 401 and no rows.
