No AI surface in Leed has permissions of its own. Every one of them borrows yours, and then takes things away. That is the single most useful sentence about Leed’s AI security posture, and everything below is the mechanics of it.
There are five layers. They fire in order, outermost first, and when a call is refused it is almost always worth knowing which one refused it — because the fix is completely different at each. This page is that answer key.
| Layer | Applies to | What it stops | What you see when it fires |
|---|---|---|---|
| 1. The connected identity | Operator MCP, assistant | A banned user, a non-member, a forged workspace claim, and anything your role does not permit | 403 with a reason at connect time, or a tool error carrying the route’s own refusal |
| 2. Tool visibility | Operator MCP, assistant | A whole class of tools from ever being offered to the model | unknown tool: <name> — the model cannot call what it was never shown |
| 3. Risk tier | Assistant only | A destructive call from running before a person approves it | A pending-action card in the chat, with Approve and Skip |
| 4. Two-phase confirmation | Operator MCP only | A bulk or wholesale-replace call from running on the first request | A non-mutating preview of exactly what would change, and an instruction to ask you |
| 5. The audit trail | Every surface | Nothing — it is the record, not a gate | Nothing, in the moment. It is what you read afterwards |
flowchart TD
A["Tool call arrives"] --> B{"Is the user banned?"}
B -->|"yes"| B1["403 user_banned"]
B -->|"no"| C{"Is the user a member<br/>of this workspace?"}
C -->|"no"| C1["403 no_cms_membership"]
C -->|"yes"| D{"Is this tool visible<br/>to this surface?"}
D -->|"no"| D1["unknown tool"]
D -->|"yes"| E{"Risk tier or<br/>confirmation gate?"}
E -->|"high, assistant"| E1["Parked — awaiting_user_approval"]
E -->|"unconfirmed, MCP"| E2["Preview returned — nothing changed"]
E -->|"clear"| F{"Does your role permit<br/>this route?"}
F -->|"no"| F1["403 from the route,<br/>returned as a tool error"]
F -->|"yes"| G["Execute, then write an audit row"]
Layer 1 — the connected identity
An Operator MCP tool call does not take a private path into your data. It re-dispatches in process to the very same RBAC-guarded /api/* route the CMS itself calls, carrying a short-lived session minted for that one request and revoked the moment it finishes. There is no AI bypass, no service account and no elevated identity anywhere in the chain.
Three checks run before the route ever sees the request:
- The token must have been minted for this resource.
/mcpaccepts only an audience-bound token and rejects opaque ones outright. A client that never asked for a token bound tohttps://app.leed.ai/mcpgets a401and a pointer back to discovery — the symptom, and the fix, are in connecting to the Operator MCP. - An active ban is
403 user_banned. Issued tokens survive a ban for as long as they would otherwise live, so this is re-checked on every single request rather than at issue time. A temporary ban whose expiry has passed does not block. - No membership in the workspace is
403 no_cms_membership. Theorg_idclaim baked into your token at consent is treated as a preference, never as authorization: Leed re-resolves the workspace and re-checks your membership on every request. A stale claim naming a workspace you have since left resolves to no role, and the request is refused.
The tier check that follows admits every valid plan. Only an unset or unrecognized tier is refused, as 403 mcp_ineligible_tier.
Layer 2 — tool visibility
Every tool in Leed’s shared registry declares which surfaces may see it: the in-CMS assistant, the Operator MCP, both, or — for a few — one and not the other. That flag decides what appears in the tool list a model is handed.
This is a hard boundary, not a soft one. A tool an MCP client was never shown cannot be invoked by guessing its name: the call is answered unknown tool: <name> before any argument is parsed and before any route is contacted. It is not “restricted but reachable”; it is absent.
The largest block hidden this way is the destructive one. Every publish, every delete and the two approval tools are invisible over MCP, which is why “no MCP write can go live” is a structural property of the surface rather than a promise. They remain visible to the assistant, where layer 3 handles them instead. Which tools each surface can see is enumerated in the Operator MCP tool index, and the exclusion list with its rationale is in what the Operator MCP can and cannot do.
Layer 3 — risk tier
The assistant, and only the assistant, sorts every tool into low (run it) and high (park it and wait for a person). A parked call is dispatched to nothing until you press Approve; an unrecognized tool name is treated as high risk on the conservative side. The mechanics — the card, the argument preview, the 60-minute approval window — are in Leed Assistant.
Risk tier has no effect on MCP at all. It is not consulted there, and a high-risk tool that happens to be MCP-visible does not park. That is not an oversight: MCP clients have no uniform way to interrupt a model and ask a human, so approval had to be expressed differently. That is layer 4.
Layer 4 — two-phase confirmation
Because there is no elicitation to rely on, an MCP tool that needs your approval asks for it through its own contract. The first call — with no confirm flag, or confirm: false — mutates nothing and returns a preview of exactly what would change, plus an instruction telling the model to show you that plan and wait. Only a second call carrying the identical arguments plus confirm: true executes.
{
"preview": true,
"action": "set_campaign_audience",
"summary": "Replace campaign 42's audience targeting wholesale with 3 persona(s) and 12 account(s).",
"effects": [
"Removes every persona and account currently targeted by campaign 42",
"Targets personas 7, 9, 11",
"Targets accounts 101, 104, 118, …"
],
"instruction": "This was a preview — nothing was changed. Present this plan to the user and ask for approval; only call set_campaign_audience again with the same arguments plus confirm: true after the user explicitly approves."
}There are two enforcement modes, and the difference matters when you are reading a preview:
route— the backing route implements the dry run itself, so the preview is validated against real pages and real permissions server-side.schedule_pagesworks this way, which is why its preview can tell you that page four of eleven is not in a schedulable state.local— the MCP layer refuses to dispatch at all withoutconfirm: true, and builds the preview from the validated arguments.set_campaign_audienceworks this way.
Two tools carry the gate today. It is not an ad-hoc pair: the recorded convention in the codebase is that any new destructive, bulk, multi-item or publish-adjacent MCP tool must be registered through the confirmation wrapper, or carry a written reason why it is exempt — so the set grows with the surface rather than lagging behind it. The two current tools appear with their full parameter tables in MCP tools: publishing and users and MCP tools: campaigns and personas.
Worth being clear about the failure mode this does not cover: a model that ignores the instruction and immediately re-calls with confirm: true has your client’s cooperation, not Leed’s. The gate guarantees that the first call changed nothing, and that the second call is a deliberate, separately-logged act. It cannot guarantee that a human read the preview.
Layer 5 — the audit trail
Every tool call every AI surface makes is recorded. There are two stores, split by who was authenticating:
ai_actions— the in-CMS assistant and the Operator MCP. Calls made by your team, under a Leed identity.mcp_request_events— the Docs MCP, the public site agent and reader search on your published site. Calls made by your readers, or by nobody in particular. Rows carry the identified visitor where there is one, the record count, and adeniedflag with the attempted email and domain for a refused sign-in — which doubles as a demand signal for who is trying to reach your docs. Those are described in the Docs MCP tool reference.
What an ai_actions row holds
| Field | Meaning | Example |
|---|---|---|
toolName | The tool that was called | update_page_draft |
inputsJson | The full arguments, as the model sent them | {"pageId":"dev-garden-post","summary":"…"} |
resultJson | The result, wrapped as { result, _meta } | {"result":{"ok":true},"_meta":{"clientId":"…","recordCount":1}} |
recordCount | Size of the result set, where one can be derived | 12 for a twelve-hit search; null for a scalar |
status | How it ended | auto_run, approved, pending, skipped, errored |
riskTier | low or high | low |
source | Which surface made the call | assistant or mcp |
sessionId | Groups one conversation, or one authorization | mcp:9f2c… |
userId / companyId | Who, and in which workspace | — |
createdAt / approvedAt | Millisecond timestamps | approvedAt is null unless a person approved it |
An Operator MCP call that succeeds is recorded as auto_run; one that fails — for any reason, including an RBAC refusal from the route — is recorded as errored with the error in place of the result. Both carry _meta.clientId, the OAuth client that made the call, so a workspace connected from three different tools can be told apart afterwards.
{
"toolName": "schedule_pages",
"source": "mcp",
"status": "auto_run",
"riskTier": "high",
"sessionId": "mcp:9f2c41ab7e0d5c3348b1f6ea92d70c15",
"inputsJson": "{\"pageIds\":[\"dev-garden-post\"],\"publishedAt\":\"2026-09-14T09:00:00.000Z\",\"confirm\":true}",
"resultJson": "{\"result\":{\"scheduled\":1,\"failed\":0},\"_meta\":{\"clientId\":\"…\",\"recordCount\":1}}",
"recordCount": 1
}The MCP session id
An Operator MCP session id is the literal string mcp: followed by the first 32 hexadecimal characters of the SHA-256 hash of the access token that authorized the call.
That derivation does one useful thing and deliberately avoids another. It groups every call made under a single authorization — one connection, from one client, until the token is refreshed — so a burst of activity can be traced to one authorization rather than scattered across unrelated rows. And it does so without ever storing the token: the hash is one-way, and nothing in the log can be turned back into a credential.
What is never recorded
The audit trail records tool calls, not prompts. For a connected MCP client that distinction is absolute: Leed never sees your conversation at all. Your client decides which tools to call and with what arguments, and only those decisions arrive here. What you typed, what the model reasoned, and everything it chose not to do stay in your client.
The assistant is the one exception worth stating plainly, because it is a chat inside Leed: its turns are stored, which is what makes a conversation resumable and what fills the recent-chats list on the Chat tab. That is separate from the audit trail, and it is your own workspace’s data either way.
What no layer protects you from
Four gates and a log are a real boundary, and it is worth being honest about where that boundary stops.
A low-risk write is a real write. create_page_draft and update_page_draft do not park, do not prompt and do not preview. They create and change actual drafts in your workspace. What they cannot do is make anything live — which is a very good property and not the same as “nothing happened”. Body edits go further still: they arrive as tracked suggestions rather than overwriting your text, which authoring pages over MCP covers in full.
A draft nobody reads can still be published by a person. Leed’s guarantee is that no AI surface can publish. It is not a guarantee that unreviewed AI output stays out of your live site — someone on your team can still hit publish on it. The review step is the point of publishing changes, and it is the step these layers are designed to preserve rather than replace.
Your role is the ceiling and the floor. Connecting a client cannot grant it more than you have — but it also does not grant it less. An Administrator’s connected client is an Administrator’s connected client. If that is more reach than you want a given tool to have, the lever is the role of the account you connect with, not a setting inside MCP.
Tool results are untrusted content. The assistant is instructed to treat page bodies, form contents and every other tool result as untrusted, and never to follow instructions embedded in them. That instruction is a mitigation, not a proof. Content written by someone outside your team is content an AI reading your workspace will encounter.