Email MCP Server: Give Any MCP Agent an Inbox

9 min read

How to build an email MCP server: the tool surface for sending, replying, threading and receiving mail, with working TypeScript and API examples.

John Joubert

John Joubert

Founder, Robotomail

Email MCP Server: Give Any MCP Agent an Inbox
Table of contents

An email MCP server is a Model Context Protocol server that exposes mailbox operations as tools, so any MCP-compatible agent can send, reply to, and read email without you writing a custom integration per client. The tool surface is small: create a mailbox, send a message, reply in thread, list recent messages, fetch a thread, fetch an attachment. Under the hood those tools call an email API that supports both directions, which is what we built Robotomail for, and you can wrap it in about a hundred lines of TypeScript.

This post shows the tool schema we recommend, working handler code, and how to solve the part MCP does not solve for you: inbound mail arriving while the agent is not looking.

What an email MCP server actually is

MCP is a transport and discovery protocol. It lets a host application (Claude Desktop, an IDE agent, your own orchestrator) list tools from a server, call them with JSON arguments, and get structured results back. It does not know anything about email.

So an email MCP server is a thin adapter with three jobs:

  1. Declare tools with schemas the model can reason about.
  2. Translate tool calls into HTTP calls against a real mail backend.
  3. Return results that are small enough to not blow up the context window.

The mail backend is where the actual difficulty lives: MX records, DKIM signing, bounce handling, threading headers, spam filtering, attachment storage. An MCP server that speaks SMTP directly can send, badly, and cannot receive at all. You want a mailbox API behind it.

The MCP tool surface for email

Keep the surface narrow. Every extra tool costs tokens in every request and gives the model another way to guess wrong. This is the set we would ship:

Tool Arguments Returns
create_mailbox address mailbox id, full address
send_email mailbox_id, to[], subject, body_text message id, thread id
reply_to_email mailbox_id, in_reply_to, body_text message id, thread id
list_new_messages mailbox_id, limit message ids, from, subject, snippet
get_message message_id full body, headers, attachment list
get_thread thread_id ordered message summaries
download_attachment attachment_id file reference or base64

Two design notes that matter more than they look.

Separate reply_to_email from send_email. Models are bad at remembering to pass an in_reply_to value to a generic send tool. If replying is its own tool with a required inbound message id, the correct threading behavior becomes the default rather than something the model has to remember. The mail API sets In-Reply-To and References from that id, which is what keeps the conversation in one thread in Gmail and Outlook. More on why that matters in email threading.

Never return full message bodies from a list tool. Return ids and a 200 character snippet. Let the model call get_message on the one it cares about. A list tool that dumps ten HTML emails into context will eat your whole budget and produce worse answers.

Tool descriptions are prompt engineering

The description string is the only instruction the model gets. Write it like an operator, not a doc generator:

send_email: Send a new email from the agent's own mailbox. Use only to
start a NEW conversation. To answer an email the agent received, use
reply_to_email instead so the message stays in the same thread.
Do not use for bulk or unsolicited messages.

That last line does real work. Constraints in tool descriptions reduce the number of times an agent decides to email fifty people.

Building the server on Robotomail

Provision a mailbox first, then wire the tools.

1. Create the mailbox. From the CLI:

npx @robotomail/cli mailbox create shopping-agent

Or over HTTP:

curl -X POST https://api.robotomail.com/v1/mailboxes \
  -H "Authorization: Bearer rm_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"address": "shopping-agent"}'

You get a real, deliverable address on our platform domain. Point the mailbox at your own domain later if you want the agent to look like part of your product.

2. Define the tools. Using the official TypeScript SDK:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "email", version: "1.0.0" });
const API_KEY = process.env.ROBOTOMAIL_API_KEY!;

server.tool(
  "send_email",
  "Send a new email from the agent's mailbox. Use only to start a new " +
    "conversation; use reply_to_email to answer an existing one.",
  {
    mailbox_id: z.string(),
    to: z.array(z.string().email()).max(5),
    subject: z.string(),
    body_text: z.string(),
  },
  async ({ mailbox_id, to, subject, body_text }) => {
    const res = await fetch(
      `https://api.robotomail.com/v1/mailboxes/${mailbox_id}/messages`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ to, subject, bodyText: body_text }),
      }
    );

    if (!res.ok) {
      return {
        isError: true,
        content: [{ type: "text", text: `Send failed: ${res.status} ${await res.text()}` }],
      };
    }

    const msg = await res.json();
    return { content: [{ type: "text", text: JSON.stringify(msg) }] };
  }
);

3. Add the reply tool. Same call, plus the inbound message id:

server.tool(
  "reply_to_email",
  "Reply to an email the agent received. Keeps the message in the same thread.",
  {
    mailbox_id: z.string(),
    in_reply_to: z.string().describe("message_id from the received email"),
    body_text: z.string(),
  },
  async ({ mailbox_id, in_reply_to, body_text }) => {
    const res = await fetch(
      `https://api.robotomail.com/v1/mailboxes/${mailbox_id}/messages`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ inReplyTo: in_reply_to, bodyText: body_text }),
      }
    );
    return { content: [{ type: "text", text: await res.text() }] };
  }
);

await server.connect(new StdioServerTransport());

4. Scope the credentials. One API key per agent, not one shared key across your whole fleet. When an agent misbehaves you revoke its key and nothing else breaks. Key handling is covered in authentication.

Receiving email: MCP pulls, mail pushes

This is the part most email MCP servers get wrong. MCP is request/response. The host calls a tool, your server answers. There is no reliable way for an MCP server to wake up an idle agent because a vendor replied twenty minutes later.

Email is push. Inbound mail arrives at a webhook whenever it arrives:

{
  "event": "message.received",
  "data": {
    "message_id": "...", "mailbox_id": "...", "mailbox_address": "shopping-agent@robotomail.co",
    "from": "vendor@example.com", "subject": "Re: Order #A-4521",
    "body_text": "...", "thread_id": "...", "received_at": "2026-04-17T10:00:00.000Z"
  }
}

Two architectures work, and they are not exclusive:

Store and expose. Your webhook handler writes inbound messages to a table with a handled flag. The list_new_messages tool reads unhandled rows for that mailbox, and the agent marks them handled after replying. This keeps everything inside MCP and works with any host, including ones you do not control. The cost is that mail is only processed when someone runs the agent.

Wake the agent. Your webhook handler starts an agent run and injects the inbound message as the initial input. The agent then uses the MCP tools for everything else: fetching the thread, downloading the attachment, sending the reply. This is the right pattern for anything time-sensitive like support intake or order updates.

We usually do both. The webhook triggers a run, and the pull tool exists as a recovery path for messages that arrived while the worker was down.

A note on idempotency: webhook deliveries can repeat. Deduplicate on message_id before you start an agent run, or you will send the same vendor two nearly identical replies.

MCP server vs direct API vs a skill

Approach Best when
MCP server You want one email integration shared across multiple hosts and agent frameworks, including third-party clients
Direct HTTP calls You control the agent loop and want tight control over prompting, retries, and message trimming
Packaged skill You are on Claude Code or a similar host and want mail working in minutes

MCP is the right choice when the consumer is not you, or not only you. If a single Python agent needs an inbox, calling the API directly is less machinery for the same result. If you are on Claude Code specifically, our agent skill already ships the send, reply, and thread-reading behavior with prompt guidance included, and there is no server process to keep alive.

Operational details that bite

  • Rate limits. Give the MCP server its own limiter. A model in a retry loop will happily call send_email a dozen times in five seconds. Return a clear error and let the model back off rather than passing every attempt through. See limits.
  • Loop protection. If your agent replies to an autoresponder that replies to your agent, you have built a mail loop. Drop messages with Auto-Submitted headers, and cap replies per thread per hour.
  • Attachments. Do not return base64 blobs through MCP. Return metadata and a reference, download server side, and only surface extracted text to the model.
  • Error shape. Return isError: true with the real status code and body. Models recover well from "422: recipient address is invalid" and badly from "something went wrong".
  • Recipient caps. Cap to[] at a small number in the schema. Schema-level limits are cheaper than trusting the description.

FAQ

Does an email MCP server let agents receive email?

Not by itself. MCP tools are pull based, so the agent only sees mail when it calls a tool. Combine the MCP server with an inbound webhook so mail is captured the moment it arrives, then either wake an agent run or let a list_new_messages tool serve it up on the next turn.

Can I use SMTP behind the MCP server instead of an API?

You can send that way, though you will handle DKIM, bounces, and reputation yourself, and you still cannot receive. Receiving over SMTP means running an MX endpoint and parsing MIME. An email API with inbound support removes both problems.

How many tools should an email MCP server expose?

Five to seven. Send, reply, list, get message, get thread, and optionally create mailbox and download attachment. Every additional tool consumes context in every request and increases the chance the model picks the wrong one.

Do replies stay in the same conversation thread?

Yes, if your reply tool passes the inbound message_id. We set the In-Reply-To and References headers from it so Gmail, Outlook, and Apple Mail group the messages correctly. Omit it and every reply looks like a brand new email.

Ready to build it? Create a mailbox and send your first message in a few minutes with the quickstart, or start at robotomail.com.

Give your AI agent a real email address

One API call creates a mailbox with full send and receive. Webhooks for inbound, automatic threading, deliverability handled. 30-day money-back guarantee.

Related posts