Skip to main content

Write gating

Write tools never execute on scope alone. Every write call passes three gates before the dispatcher reaches the tool body. Any one of them missing closes the call with a structured error.

The model is least-privilege by construction: a token with mcp:write does not imply that any write tool is callable, and a granted tool does not imply that any deal accepts writes from it. Each layer is its own opt-in.

The three gates

  1. Scope. The access token carries mcp:write. Granted at OAuth consent, fixed for the life of the token, and never auto-upgraded. See Authentication.

  2. Per-tool grant. The authenticated user has explicitly enabled this specific tool. Toggled at Settings → Connected MCP clients in the Evenhand web app. The default state is off for every write tool, including newly registered tools — granting mcp:write once does not auto-enable future tools.

  3. Per-deal opt-in. For any tool whose input names a deal_id (most write tools), the authenticated user has explicitly enabled MCP writes for that deal. Toggled on the deal page in the web app. The default state is off for every deal.

The gates compose in that order. The dispatcher checks scope first, then the per-tool grant, then — if the tool is deal-scoped — the per-deal opt-in. Read tools skip all three: they are bounded purely by row-level security, and mcp:read is the default scope.

Why three gates

The MCP server's read surface is bounded by Postgres RLS. The same user calling the same query against the web app sees the same rows; visibility cannot widen through MCP. Writes are different — a write reaches the same data the user can already mutate from the web app, but it does so through an AI client that synthesizes calls from natural language. Three opt-ins matter:

  • Scope (mcp:write) decides whether AI-mediated writes are on at all for this client. It is a deliberate, one-time consent during OAuth.
  • Per-tool grants decide which kinds of writes are on. A user who is happy for Claude to submit Q&A questions may not want Claude touching QoE normalization adjustments.
  • Per-deal opt-ins decide where writes are on. Most users care about MCP writes on a specific in-flight deal, not on every deal they have ever touched.

Granted scopes don't imply granted tools. Granted tools don't imply granted deals. The defaults are off and stay off until a human flips them.

Permission-denied response

A write call that fails any gate returns a structured error, not a generic 403. The shape is:

{
"error": "permission_denied",
"reason": "missing_per_deal_optin",
"tool_name": "evenhand_questions_create",
"deal_id": "8e0f...e4a1",
"remediation": "Open this deal in the Evenhand web app and enable MCP writes for it under the deal-level settings.",
"settings_url": "https://app.evenhandhq.com/buyer/settings/mcp-clients"
}

reason is stable and machine-readable. Switch on it; the remediation string may be edited between releases.

reasonCause
missing_scopeAccess token lacks mcp:write. Re-run the OAuth flow with mcp:write in scope.
missing_per_tool_grantThe authenticated user has not enabled this specific write tool.
missing_per_deal_optinThe authenticated user has not enabled MCP writes for the named deal.
tool_not_foundThe named tool does not exist or has been retired.

deal_id is present only when the failing gate is per-deal. tool_name is always present.

User-side controls

A user manages every gate from one page: Settings → Connected MCP clients (path: /buyer/settings/mcp-clients). The page surfaces:

  • Connected clients. Each registered client app, its scopes, the last-seen access token, and a revoke control. Revoking a client invalidates its tokens immediately.
  • Per-tool grants. A row per write tool, with a toggle for the user's connected clients. Toggling off invalidates future write calls for that tool — in-flight calls still complete.
  • Per-deal opt-ins. The list of deals the user is on, with a toggle for MCP writes on each. Also reachable from the deal page itself.

The page is the same authoritative surface the dispatcher reads. There is no admin override and no "grant-all" affordance.

A worked example

A user with a mcp:read mcp:write token asks Claude to submit a Q&A question on deal 8e0f...e4a1. The flow:

  1. Claude calls tools/call with evenhand_questions_create. The dispatcher receives the call.
  2. Scope check. Token carries mcp:write. Pass.
  3. Per-tool grant check. The user has enabled evenhand_questions_create on this client. Pass.
  4. Per-deal opt-in check. The user has enabled MCP writes on deal 8e0f...e4a1. Pass.
  5. The tool runs inside the user's withSession transaction. RLS enforces participant membership; the question is inserted with status = pending_broker_review per the standard Q&A flow.
  6. An audit_log row is written. See Audit & revocation.

If any of steps 2–4 fail, the call returns the permission_denied envelope above and no database write occurs.