Skip to content

Give an agent an inbox, safely

Nine MCP tools, two of which will not act without a confirmation token — and the structural reason an agent cannot approve its own send.

PlatformAdvanced13 min readUpdated

01The nine tools an agent gets

Nine tools is a decision, not an accident. An MCP server that mirrors every REST endpoint hands a model a hundred near-identical choices and it picks wrong; these are the nine things an assistant actually needs to do with a mail account, and each one is a whole job rather than a step in one.

TL;DR

Six read, three write, and only the two that put mail in front of a stranger need a person to say yes.

ToolModeConfirmationWhat it does
send_emailWriteRequiredComposes and sends a new message. Never sends on its first call.
reply_to_threadWriteRequiredReplies inside an existing inbound thread. Also never sends on its first call.
list_emailsReadSent messages, with their status. The outbound side of the account.
get_emailReadOne message in full, including its delivery and engagement events.
search_threadsReadFinds inbound threads. The tool an agent reaches for first.
get_threadReadOne inbound thread with its messages, in order.
list_domainsReadWhich sending domains exist and whether they are verified.
get_analyticsReadAggregate sending numbers — delivery, bounces, engagement.
create_contactWriteAdds a contact to an audience. Writes to your database, but nothing leaves the building.
A write that stays inside
  • create_contact changes your database and nothing else.
  • Nothing it does is visible outside your account, and it is reversible with a delete.
A write that leaves the building
  • Sending and replying cause an irreversible action in the outside world.
  • You cannot un-send an email, un-annoy the person who received it, or un-damage a sending reputation.

That asymmetry is what the gate exists for — not a general nervousness about agents. “Write” is doing two different jobs in that column, and only one of them is worth interrupting a person for.

AnnotationWhat a client can decide from it
readOnlyHintTrue on all six read tools, false on the three writers. The first thing an auto-approval policy should look at.
destructiveHintTrue only on send_email and reply_to_thread. False on create_contact, which is the distinction above, stated in machine terms.
idempotentHintTrue everywhere except the two senders — calling a read twice costs nothing.
openWorldHintTrue only on the two senders. They are the tools that touch something outside your account.
x-mailysend-confirmationSet to required on the two senders, so a client knows about the gate before it calls anything.
mailysend/self_approvalCarried in _meta as impossible — which the next two sections spend their time earning.

A client can decide what to auto-approve without parsing English out of a description field, which matters because a description is written for a model and a policy needs to be enforced by code.

02The two tools that need a human

Call send_email and nothing is sent. What comes back is not an error, not a refusal, and not a permission problem — it is a confirmation_required result, which is a perfectly normal outcome of a successful tool call.

TL;DR

Two calls, with a person in between. The first mints a token bound to that exact message; the second spends it.

CALL 1
send_email
nothing is sent
RESULT
confirmation_required
token + summary
HUMAN
Approve or reject
CALL 2
send_email + token
the send happens

That the first call is a success is load-bearing. If it returned an error, a capable agent would do what capable agents do with errors: adjust something and retry, possibly in a loop, possibly with increasingly creative arguments. Returning a success whose content is “here is the confirmation you now need” tells the agent the call worked and the next step belongs to somebody else.

{
  "status": "confirmation_required",
  "confirmation": {
    "token": "cnf_…",                    // single-use, bound to this exact call
    "approved": false,
    "expires_at": "2026-09-11T14:22:31Z",   // ten minutes by default
    "approval_channel": "https://…/app/approvals",
    "summary": { /* from, to, cc, subject, preview, attachments */ }
  }
}

The text half of the result is written for the model to relay, and it says plainly: this call did not send anything, a person must approve it, you cannot approve it yourself, and repeating this call will not send it. The structured half carries the full summary — sender, recipients, subject, body preview, attachment count, schedule — so the person deciding sees the actual message rather than a tool name and a shrug.

RejectionWhat the API saysWhat it means
not_approvedA person has not approved this send yet.Nobody has decided. Wait — do not retry in a loop.
payload_changedThe message changed since it was confirmed.Not the message that was approved, even by a character. Approval is of a message, not of an intention.
already_usedThat confirmation has already been spent.The message went out. Do not send it again.
wrong_toolThat confirmation was issued for a different tool.A reply token cannot be spent on a send, or the reverse.
wrong_workspaceThat confirmation belongs to a different workspace.Tokens do not travel between accounts.
expiredThe confirmation expired. Call the tool again to request a fresh one.Past its ten minutes.
invalid_tokenMalformed, or not issued by this server.The signature over the claims did not check out. A token cannot be forged into existence.

Redeeming checks, in order: signature, claims, tool, workspace, expiry, payload digest, approval, then consumption.

The ten-minute expiry is deliberately short, and the API says why when you try to use a stale one: a stale approval is an approval for a message nobody remembers. Approving something at nine in the morning and having it go out at four in the afternoon is not consent, it is a delayed surprise. The token's nonce is also the idempotency key for the send itself, so a spent token cannot become a second message.

03Why an agent cannot approve its own send

Here is the claim worth being precise about. An agent cannot approve its own send. Not because it lacks a permission, and not because a check rejects it — because the operation is not in the protocol it is speaking.

TL;DR

The server answers six JSON-RPC methods. None of them is approve, and nothing an agent can send adds one.

MethodWhat it is for
initializeVersion handshake and capability exchange.
notifications/initializedThe client saying it is ready.
notifications/cancelledThe client abandoning an in-flight request.
pingLiveness.
tools/listThe nine tools, always all nine.
tools/callInvoke one of those nine.

That is the exhaustive set. There is no approve, and no alias for one — anything else answers “Unknown method”, code −32601. Not forbidden. Unknown.

And tools/call is not a way in, because the nine tools are the nine listed above and none of them decides a confirmation. The object that can mark a confirmation approved is reachable only by the process that constructed the server: the dashboard route, behind a session, behind a role check. Nothing on the JSON-RPC surface can address it.

A permission the agent lacks
  • A condition evaluated at runtime against some state.
  • And every such condition is a thing that can be confused: by a role broader than you thought, by a code path that forgot to check, by a token that means something other than expected, by a bug.
An operation the protocol does not offer
  • The set of methods a server answers is not a runtime condition.
  • There is no argument, no header and no sequence of legal calls that adds a method to that set.

This is the only useful way to think about prompt injection: not as something to be filtered out of the input, which is an arms race against natural language, but as something that must be unable to cause the action you care about even when it works. Persuading the model is the easy part. Persuading a protocol to grow a method is not a thing that persuasion does.

The system prompt the server hands every client says the same thing in the model's own register: the two sending tools never send on their first call, there is no tool, method or argument that approves a confirmation, so do not look for one and do not retry in a loop — report the pending confirmation and wait. That instruction exists to save the agent from wasting its turn, not to keep it honest. The keeping-honest is done by the method table.

04Connecting an agent

Your instance exposes MCP at /mcp. The credential is an ordinary API key in an Authorization header, which means everything from the keys guide applies unchanged — including which permission you hand over.

TL;DR

A URL and a bearer token. The only real decision is what that token is scoped to, and it is the one decision you cannot fix afterwards with care.

{
  "mcpServers": {
    "mailysend": {
      "url": "https://your-instance.example.com/mcp",
      "headers": { "Authorization": "Bearer ms_live_…" }
    }
  }
}
1 · Decide what the credential may reach

A sending_access key can call the two sending tools and nothing else — every read tool and create_contact require full_access. Note the shape of the check: all nine tools are always listed, and the permission is enforced when a tool is actually called, with an error naming the permission it would need. An agent therefore knows what exists and discovers what it may do, which produces better behaviour than a truncated list that leaves it guessing why an obvious capability is missing.

2 · Point it at a mailbox, not at everything

The read tools see what the credential is scoped to. This is the single highest leverage decision in the whole setup, and it gets its own section below.

3 · Send a test through the gate

Ask the agent to send something harmless, then go and approve it. Watching one message stop, wait for you, and then go is worth more than any amount of reading about it — and it verifies the approval channel is somewhere you will actually see.

Protocol detailWhat the server does
Revisions answered2025-06-18 is current; 2025-03-26 and 2024-11-05 are also honoured, and the client's requested version is echoed back when it is one of them. A client pinned to an older revision is a client that will not be upgraded on our schedule.
JSON-RPC batchingRejected outright — “JSON-RPC batching is not supported.” It was removed from the specification, and accepting it anyway would leave two framings to reason about in every future change, the second of which is the one with the bug in it.
TransportPOST only; anything else is a 405, and an Accept header the server cannot satisfy is a 406.
NotificationsAnswered with 202 and no body, because a notification has no reply by definition.

05Running an agent inbox in production

The technical half of this is done. What is left is the operational half, and it is where agent inboxes actually go wrong — not with a dramatic breach, but with a queue nobody reads and a habit of clicking approve.

TL;DR

Give the agent its own mailbox. Everything else in this section is easier because of that one decision, and nothing recovers from skipping it.

Its own address
  • [email protected], created for this purpose, with the credential scoped to it.
  • The blast radius is a sentence: mail that arrived at one address you created deliberately.
  • The audit trail is readable, because every action in that mailbox is the agent’s — you are scanning a list, not separating two actors out of one stream.
Your support inbox
  • Password reset links, invoices, legal correspondence, and whatever a customer decided to attach.
  • Pointing a language model at all of it is a decision most people would not make if it were phrased that way out loud.
THE REVIEW HABIT
A confirmation gate is only as good as the attention paid to it. Three things keep it real: read the summary, not the tool name — the recipients and the body preview are the whole point, and they are right there. Treat “why is it sending this?” as a stop, not as a curiosity to resolve by approving and seeing. And keep the queue short enough to actually read, which is a statement about how much work you hand the agent, not about how fast you click.

The failure mode to name out loud is the rubber stamp. Twenty confirmations a day, all of them fine, and by the second week approving is muscle memory. The twenty-first is the one that matters and it looks exactly like the others in the list — which is why it matters that the summary shows the recipients and the body rather than “send_email (1)”. If your queue has become too long to read properly, the honest fix is to give the agent less to do, or to move a genuinely routine category behind a template it cannot vary, rather than to keep approving faster.

Operational factThe number, and where it bites
Every decision is recordedApproving is a person authorising a machine to send mail in their name, so it is written to the audit log with the deciding actor, the source address, the tool and the summary — the same treatment claiming the instance gets. That is what answers “who approved this” later, which is impossible to reconstruct if nobody wrote it down.
Rate limits apply as normalMCP calls go through the same API surface, so the same fixed-window limits apply per workspace: 600 requests a minute on the sending bucket, 1,000 a minute otherwise. An agent in a retry loop reaches that well before it does anything expensive — worth knowing, because a model that has decided to keep trying will keep trying for a while.
Confirmations expire in ten minutesWhich also bounds how long an approval queue can usefully be. A queue you read twice a day is a queue full of expired tokens and an agent asking for them again.

Finally, the thing to review monthly rather than never: whether the agent still needs full_access. Most agents are given it during setup because the read tools need it, and most never have their scope narrowed afterwards. If yours has settled into only replying to threads, a sending_access key does that job. The keys guide has the rotation order for swapping one credential for the other without a gap.

What just happened

Your agent has nine tools, six of which only read. The two that put mail in front of a human being cannot do it alone: they return a confirmation request carrying a single-use token bound to that exact message, and there is no method in the protocol an agent could call to approve one. The failure mode left to you is a human one — an approval queue nobody reads becomes a button somebody clicks, and at that point the gate is decoration.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.