Controls · September 21, 2026

The MCP Tool Surface Is the Permission Boundary

How the MCP tool surface permission boundary is computed per request, what a filtered tools/call returns, and three ways the tool list shrinks under you.

An agent that reads email reads whatever a stranger decided to write to it. The usual defence runs late: the model reads the mail, decides to call a send tool, and a policy layer refuses the call. Everything before that refusal already happened, including the part where a paragraph of hostile text talked the model into sending.

The MCP tool surface permission boundary puts the line earlier. This is what it does on https://mcp.emailforagents.ai/mcp: how the visible tool set is recomputed on every request, what comes back when a model calls a tool that was filtered out, three ways the list shrinks with nobody touching the agent, and the attacks none of it touches. For the credential model underneath, read scoped credentials for agent email.

An absent tool and a denied call are not the same control

The grant decides the contents of tools/list, and tools/list is the only thing the model ever learns about. A capability the grant does not carry is not described, not named, and not addressable.

A denied call is a conversation. An absent tool is not. A denial is a message the model receives, interprets and plans around: it can rephrase, retry with a different recipient, or ask a human for more access. Refusals are an input, and every input on an agent’s path is something an attacker gets to influence. Removing the tool removes the input.

Here is what an email.draft connection is handed. Descriptions are trimmed for width; the schemas are verbatim in shape.

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      { "name": "list_inboxes", "inputSchema": { "type": "object", "properties": {} } },
      { "name": "list_threads", "inputSchema": { "required": ["inbox_id"] } },
      { "name": "list_messages", "inputSchema": { "required": ["inbox_id"] } },
      { "name": "get_message", "inputSchema": { "required": ["inbox_id", "message_id"] } },
      {
        "name": "create_draft",
        "description": "Create a new email or threaded reply awaiting human review. This cannot transmit mail.",
        "inputSchema": { "required": ["inbox_id", "idempotency_key", "to", "subject"] }
      }
    ]
  }
}

An email.send connection gets that array plus one entry, send_message, with the same four required arguments. One element of one array is the entire difference between an agent that can put mail on the wire and one that cannot.

Ask for the hidden tool on the first connection and the answer is not a tool result:

curl -sS -i -X POST https://mcp.emailforagents.ai/mcp \
  -H "Authorization: Bearer $EFA_CONNECTION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"send_message","arguments":{"inbox_id":"ibx_..."}}}'
HTTP/1.1 403 Forbidden
{"error":{"code":"permission_denied",
          "message":"This connection cannot use that tool.",
          "request_id":"req_...","retryable":false,"details":{}}}

Look at what that body does not contain. There is no jsonrpc field and no id. The request carried "id": 2 and nothing came back to match it to. This is the plain HTTP error envelope the REST API uses, served at the JSON-RPC transport layer, and it is the mechanical reason the model never sees a tool error. There is no JSON-RPC result for the client to hand back to it.

Every other way a call can fail on this server comes back as a readable tool result. That is the distinction to hold on to, and it is checkable a row at a time.

What went wrongWhat comes backReadable by the model
Tool filtered out of tools/listHTTP 403, no JSON-RPC envelopeNo
A scope check inside the serviceisError: true, “This credential cannot perform that action.”Yes
Missing or oversized idempotency_keyisError: true, generic textYes
Arguments fail the input schemaisError: true, generic textYes
Inbox policy blocked the sendisError: true, the policy messageYes
Result serialises over 131,072 charsisError: true, pointing at the REST APIYes

Rows one and two are both permission_denied and they are not the same event. The first is the visibility filter refusing a name before any argument is parsed. The second is a scope check inside the send service, wrapped into a normal tool result the model reads and can work around. Same code, two observable shapes, and only the first removes a capability.

“Generic text” in rows three and four is literal. A missing idempotency_key, a key over 200 characters, a malformed recipient and an unknown property all collapse into Invalid tool arguments or unavailable content., because only errors raised by the send service carry their own message through. Validate arguments client-side or you will be guessing which one the server disliked.

Row one costs you something. A strict JSON-RPC client that correlates responses by id has nothing here to correlate, so it surfaces a transport error, a parse failure, or a promise that never settles rather than a clean “not permitted”. Handle a non-200 with a bare {"error":{...}} body as its own case before parsing it as JSON-RPC. The readable rows are lossy in the other direction: a policy_denied carries details.reason_codes such as send_disabled, blocked_recipient or recipient_not_allowed over REST, and over MCP only the message string survives, so the structured reason reaches neither the model nor your logs.

How the MCP tool surface permission boundary is computed

A tool appears when the grant holds every scope that tool requires. The server exposes exactly six, with no dynamic registration and no administrative tool.

ToolRequired scopeWhat it costs you if granted
list_inboxesinboxes:readNames of authorised inboxes only.
list_threadsthreads:readSubjects, senders, previews, and a cursor.
list_messagesmessages:readIDs, recipients, attachment metadata, previews.
get_messagemessages:readOne bounded body of untrusted text.
create_draftdrafts:writeA proposal that a human must act on.
send_messagemessages:sendOutbound mail, subject to inbox policy.

The scopes that get compared against that column are not the ones printed on the consent screen. They are an intersection of three sets, recomputed from the database on every single request:

  1. The authorising human’s scopes right now. Their role’s scopes, themselves filtered down by every project and inbox assignment that human holds.
  2. The connection’s preset. email.read maps to read, email.draft to read_draft, email.send to read_draft_send. read carries inboxes:read, threads:read, messages:read, attachments:read and events:read; read_draft adds drafts:read, drafts:write and drafts:submit; read_draft_send adds messages:send and nothing else.
  3. The scopes stamped into the access token at exchange time, which are the connection’s scopes filtered by the preset named in the requested scope string.

The token is a lookup handle, not a claim set, so there is no cached-token window in which a revoked capability is still callable. The same three-way intersection is computed again inside the transaction that claims the idempotency key, so a grant revoked between the HTTP request and the database write fails the write.

The useful consequence is that the tool list can shrink under a running agent without anyone editing the agent, the connection, or the inbox. Three ways, all worth knowing before you debug one at three in the morning:

Demote the authorising human. A connection authorised by an operator on the email.send preset shows six tools. Change that person to viewer and the next tools/list returns four. viewer holds neither drafts:write nor messages:send, so create_draft and send_message both vanish. The connection record still says read_draft_send.

Give the authorising human a narrow assignment somewhere unrelated. Role scopes are intersected with every assignment preset that person holds, across all projects and inboxes, not just the one the agent uses. A developer who picks up a single read-only inbox assignment elsewhere loses messages:send everywhere, and the agent’s send_message goes with it. The intersection is deliberately conservative, because a principal carries one scope set: a broader assignment on one inbox must not escalate a narrower assignment on a sibling.

Refresh a token without carrying the scope forward. The exchange computes a ceiling from the requested scope string, and that string is mapped by looking for email.send, then email.draft, then falling through to read. A scope string naming neither, including an empty one, yields the read ceiling and a four-tool session against a connection nobody changed.

The list cannot move the other way. Consent cannot grant a preset above the one the client requested, and a connection whose scopes exceed the authoriser’s current scopes fails at token exchange with “The authorizer no longer has these permissions”. No preset contains keys:manage, members:manage, billing:manage, policies:write, inboxes:delete, messages:delete, messages:raw or exports:write, so a connected agent cannot mint a credential, change an inbox policy, or delete the record of what it did. The audit trail is not within reach of the thing being audited.

Two scopes sit in all three presets and still do not behave like the rest. No tool consumes events:read, so a connected agent cannot poll the event cursor over MCP however you scope it, and the inbound half of the loop stays on the REST path in the agent email API walkthrough. attachments:read is consumed conditionally, required at send time only when the payload carries attachment_ids, the same way messages:read is additionally required when it carries reply_to_message_id.

Why create_draft cannot transmit, on any grant

The guarantee is not that drafting is unlikely to send. It is that the send path is unavailable to it.

Before the draft tool runs, the server builds the acting principal by filtering messages:send out of the caller’s scope list, then calls the same send service send_message uses. On a read_draft_send connection, which does hold messages:send, the tool that could transmit is not the tool that was invoked, and the invoked one executes with that scope removed. No argument, prompt, or model behaviour restores it.

What the service does next is decided by scopes, not by a flag on the request. A principal holding drafts:submit and not holding messages:send is a draft-only principal, and every send it attempts becomes a pending approval:

{
  "result": "approval_required",
  "draft_id": "drf_...",
  "draft_version": 1,
  "approval_id": "apr_...",
  "reason_codes": ["draft_only_principal"],
  "request_id": "req_..."
}

draft_only_principal is a fallback, not a label, and that trips up anyone writing an assertion against it. The server fills reason_codes with whatever the inbox policy produced and substitutes draft_only_principal only when that list came back empty. On an inbox with an approve-always rule, the same draft-only principal gets reason_codes: ["always"] and the string you were looking for is absent. Assert on result being approval_required and treat reason_codes as diagnostic colour.

Visibility and execution key on different scopes here, which matters if you ever build a grant by hand rather than from a preset. create_draft becomes visible on drafts:write, while the service behind it requires drafts:submit for a draft-only principal and messages:send for anyone else. A grant holding drafts:write alone sees the tool, calls it, and gets This credential cannot perform that action. with no indication of which scope was missing. Appearing in tools/list is necessary to call a tool and not sufficient to succeed with it.

Keep the two authorities apart, because people conflate them. drafts:submit is permission to put a finished message in front of a reviewer. messages:send is permission to put it on the wire. A grant can hold drafts:submit forever and never move a byte toward a recipient. What happens to the approval next, including why editing a draft invalidates the approval bound to it, is in human approval before an AI agent sends email.

Two drafting failures that look like something else

Both come from the draft path and the send path being the same code, and neither is obvious from the tool descriptions.

create_draft and send_message share one idempotency namespace. The key is hashed as send:<your key> in the send operation family for both tools, and the stored payload hash covers the canonical message, not the tool name. Call create_draft with key k1, then call send_message with the same key and the same recipients, subject and body, and the second call replays the first call’s stored response. You get "result": "approval_required" back from send_message and no mail is sent. Reverse the order and create_draft returns "result": "queued" with a message_id for a message that is already on its way. The key identifies the send, not the tool that asked for it. Generate a fresh key when you mean a fresh action, and expect idempotency_conflict when you reuse one with different content.

A send-disabled inbox blocks drafting too. Policy is evaluated before the draft-only branch, so a deny throws for both tools. On an inbox with send_enabled off, a blocked recipient, or a recipient outside the allow list, an email.draft agent that only wanted to write a draft gets an isError result reading Workspace policy blocked this send. It never asked to send. A disabled inbox is therefore not a safe place to let an agent practise composing; use a test inbox. The reason_codes telling you which rule fired are the ones MCP drops, so read them from the console or tune the rule in sending controls.

Which preset to grant, and when that answer flips

Default to email.draft. It is the only preset where the reduced surface does real work: the agent reads a whole inbox, composes a complete reply, and the tool that transmits is not in the list. email.send gives up the property this article is about.

The agent’s jobGrantWhy, and when to change it
Classify, summarise, or route inbound mailemail.readFour tools, no write path. Flip up only when someone starts pasting the agent’s summaries into replies by hand.
Draft replies for a person to approveemail.draftThe default. Flip up only if the recipients are a fixed list you control, and then only on that one inbox.
Reply autonomously inside a known audienceemail.sendNeeds an inbox policy doing the work the tool list was doing. Flip back down the day the recipient set stops being a list.
Handle mail from strangers and also sendtwo grantsOne preset cannot be both. Run email.draft on the inbox that receives cold mail and email.send on the one that does not.

The last row is the one people resist and the one that matters. A connection produces one tool list for its whole session, so a grant that can send is a grant that can send while reading a message from someone you have never heard of. Splitting by inbox costs one extra authorisation flow and turns a policy question back into a capability question.

The flip condition behind all four rows is the same. Move up when a human is being used as a copy-paste mechanism, move down when the agent is sending to recipients nobody enumerated in advance. Approval-queue volume is not a reason to widen a grant: if review is the bottleneck, narrow the inbox policy instead.

An API key is rejected at the MCP endpoint by design

You cannot skip the OAuth flow by pasting a key. Sending no Authorization: Bearer header, or a human console session token, returns HTTP 401 unauthenticated with WWW-Authenticate: Bearer realm="mcp", resource="...". An efa_live_ or efa_test_ API key authenticates successfully and then fails authorisation with HTTP 403: only connection grants may invoke MCP tools. The key is valid. It is valid somewhere else.

A key has no consent record behind it, no per-inbox selection made by a named human in a browser, and no expiry unless someone set one. Letting one in would derive the tool surface from an environment variable rather than from a grant a person agreed to, which makes everything above an honour system. The trade in the other direction is real, and MCP or API key for agent email sets out when the REST path is the right call.

Two ordering details, because guessing wrong sends you debugging the wrong layer. Authentication runs before the HTTP method is looked at, so GET /mcp without a token returns 401 rather than 405, and 405 with Allow: POST once the token is good. Origin is checked last, after the JSON-RPC envelope is validated, and that rejection skips the standard envelope: it is the bare object {"error":"forbidden_origin"}, with error as a string rather than an object, so handler code reaching for error.code reads undefined.

What everyone else does, and where it stops short

Filtering tools by authorisation is not an idea this site invented. The ecosystem treats it as tidiness rather than as a control, and the gap is easy to verify.

Scope-filtered tool discovery exists as SEP-1881 in the Model Context Protocol repository, which argues that unfiltered discovery means “privileged or sensitive operations would be discoverable by unauthorized users”. Its companion SEP-1880 would let a tool declare the scopes it requires. Both are drafts, SEP-1881 without a sponsor as of this writing, and SEP-1881 is explicit that it “does not require servers to filter tools”. Read that carefully, because it settles the interoperability question. Filtering is a choice each server makes and never behaviour a client may assume, so an empty slot in a tool list tells you nothing until you have called into it.

GitHub’s MCP server does filter, and its own documentation gives the motive as to “reduce clutter and prevent errors from attempting operations your token can’t perform”. The implementation tells you what kind of mechanism that is. Filtering applies to classic personal access tokens, whose scopes the server discovers with a HEAD request reading the X-OAuth-Scopes response header at startup. Fine-grained tokens and GitHub App installation tokens do not return that header, so filtering is skipped and every tool is shown, with the API enforcing permissions at call time. When the lookup fails it logs failed to fetch token scopes, continuing without scope filtering and carries on unfiltered.

None of that is a criticism. It is a tidiness feature, built correctly as one, and the giveaway is the direction it fails in. A security control fails closed. This one fails open, deliberately, because usability is its job.

The rest of the field lands on denial rather than removal. Gateway vendors put a policy engine between client and server, which is useful for rate limits, redaction and logging and is still an after-the-decision check. OWASP’s agentic guidance goes further than most, recommending a per-agent allowlist and a runtime that refuses any call not matching it, including calls to tools the agent was never registered against. That is a correct control, and it is still a refusing runtime, which means the model sees the refusal.

So the question to ask any MCP vendor is narrow, and one curl answers it rather than a questionnaire. Call a tool that was filtered out of tools/list. If a well-formed JSON-RPC result comes back with isError set and a sentence in it, the list was a hint, because the model can read that sentence and work around it. If nothing comes back that the client can hand to the model, the list was a boundary. Then ask what happens when the scope lookup itself fails. A server that filters perfectly and shows everything on a timeout has the profile of one that never filtered.

Verifying the boundary yourself, in four calls

Do this in a test project, where inboxes get non-routable .mail.invalid addresses and a simulated transport, so a mistake cannot reach a person. The rest of that sandbox, including how to push mail into an inbox without a sender, is in testing an agent’s email without sending real mail.

  1. Connect with email.draft and call tools/list. Five entries, and send_message is not one of them. If your client displays six, it is showing a cached manifest from an earlier grant; reconnect before believing it.
  2. Call send_message directly over HTTP, as in the curl above, bypassing your client’s own tool gating. You want 403 from the server, not a refusal generated by your client. A client-side block proves nothing about the server.
  3. Call create_draft and read result. It must be approval_required, with a draft_id and an approval_id. A "result": "queued" means the wrong preset, or a reused idempotency key from a real send. Do not assert on reason_codes containing draft_only_principal: a policy rule fires first and replaces it, so that assertion passes on a bare inbox and fails on a configured one.
  4. Send a hostile test message into the inbox through the test inbound endpoint, with body text telling the agent to email the thread elsewhere. Then ask the agent to handle its inbox. The interesting output is the transcript: a well-behaved model declines, a badly behaved one tries and finds no tool. Only the second outcome tells you anything about the server.

Repeat step four whenever you change presets. It is the step that shows why the boundary matters more than the prompt: the tool descriptions do carry an untrusted-input warning, and a warning in a description is a hint to a model, not a control.

What a reduced tool surface does not defend against

Being precise about the residual risk is the difference between a control and a claim. Four things survive it.

Content injected into a draft that a human then approves. This is the big one. A read-and-draft agent that has read a hostile message can compose a draft reflecting it: a plausible reply carrying an attacker-supplied link, a changed bank detail, a recipient named in the inbound text. The tool surface did its job, the agent transmitted nothing, and the reviewer still becomes the transmission mechanism if they approve on the strength of a subject line. Approvals bind to an exact draft version and payload hash, so what the reviewer read is what leaves. Binding does not read the content for them.

Reading is itself an action. A read grant pulls untrusted text into the model’s context, and that context is shared with every other tool the client has connected. If the same session can write to a repository or call a webhook, the email content has an exit that never touches this server. That class of attack is set out in prompt injection by email.

The surface is fixed per connection, not per message. Nothing drops send_message for five minutes because the agent just read mail from an unknown sender. A hard split means two connections, two presets, two sessions.

A human can always widen it. Anyone who can approve a new authorisation moves the agent from email.draft to email.send in one flow. That is the correct design, and the thing to monitor on a schedule rather than after an incident.

None of the four is solved by a longer tool list or a better prompt. Start by listing every connection on your workspace and the preset it holds, from the agent controls overview. If one holds read_draft_send on an inbox that receives mail from strangers, move that one down first.


Keep reading

All guides · Documentation · Controls