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 wrong | What comes back | Readable by the model |
|---|---|---|
Tool filtered out of tools/list | HTTP 403, no JSON-RPC envelope | No |
| A scope check inside the service | isError: true, “This credential cannot perform that action.” | Yes |
Missing or oversized idempotency_key | isError: true, generic text | Yes |
| Arguments fail the input schema | isError: true, generic text | Yes |
| Inbox policy blocked the send | isError: true, the policy message | Yes |
| Result serialises over 131,072 chars | isError: true, pointing at the REST API | Yes |
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.
| Tool | Required scope | What it costs you if granted |
|---|---|---|
list_inboxes | inboxes:read | Names of authorised inboxes only. |
list_threads | threads:read | Subjects, senders, previews, and a cursor. |
list_messages | messages:read | IDs, recipients, attachment metadata, previews. |
get_message | messages:read | One bounded body of untrusted text. |
create_draft | drafts:write | A proposal that a human must act on. |
send_message | messages:send | Outbound 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:
- The authorising human’s scopes right now. Their role’s scopes, themselves filtered down by every project and inbox assignment that human holds.
- The connection’s preset.
email.readmaps toread,email.drafttoread_draft,email.sendtoread_draft_send.readcarriesinboxes:read,threads:read,messages:read,attachments:readandevents:read;read_draftaddsdrafts:read,drafts:writeanddrafts:submit;read_draft_sendaddsmessages:sendand nothing else. - 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 job | Grant | Why, and when to change it |
|---|---|---|
| Classify, summarise, or route inbound mail | email.read | Four 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 approve | email.draft | The 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 audience | email.send | Needs 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 send | two grants | One 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.
- Connect with
email.draftand calltools/list. Five entries, andsend_messageis not one of them. If your client displays six, it is showing a cached manifest from an earlier grant; reconnect before believing it. - Call
send_messagedirectly 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. - Call
create_draftand readresult. It must beapproval_required, with adraft_idand anapproval_id. A"result": "queued"means the wrong preset, or a reused idempotency key from a real send. Do not assert onreason_codescontainingdraft_only_principal: a policy rule fires first and replaces it, so that assertion passes on a bare inbox and fails on a configured one. - 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
Scoped Credentials for Agent Email: Read, Draft, Send
Give an agent the smallest useful email permission: project and inbox scoping, one-time secrets, draft-only grants, rotation, and revocation you can verify.
Prompt Injection by Email: What Actually Helps
Any stranger can email your agent. What an email-borne injection looks like, why message text must never grant permission, and the controls that limit damage.
MCP Email Server: Give Your Agent Client a Real Inbox
How a remote MCP email server works, what the consent screen must show, how to scope a grant to one inbox, and how to verify it before allowing sends.