Building an MCP server so an LLM agent can read my inbox
How I wrapped IMAP in a small TypeScript MCP server: the tool design, where the safety line sits (drafts, never send), and what I would change.
I wanted an agent to answer one kind of question: “what came in overnight, and what needs a reply?” Pasting emails into a chat window does not scale, so I wrote mcp-imap-server, a small TypeScript server that gives Claude controlled access to a mailbox. It is about 800 lines and four commits, so treat this as notes from a small project, not a production system.
What MCP is
The Model Context Protocol is a standard way for an agent host (Claude Desktop, Claude Code) to talk to a local or remote process that exposes tools. The server declares each tool with a name, a description and an input schema. The model reads those, decides when to call one, and the host runs it and passes the result back. For me that meant one stdio process, started by the host, with the mailbox credentials passed in as environment variables.
Designing the tools
The agent should never see raw IMAP. Folders, UIDs and flags are the mailbox’s vocabulary, so I kept the tools close to what a person does:
list_folders,list_messages,get_message,search_messagesfor readingmark_message,move_message,delete_messagefor triagecreate_draft,create_reply_draftfor writinglist_templates,render_templatefor reusable reply text
Registration is a name, a description and a zod schema. The description is what the model actually reads, so I wrote it like a docstring for a colleague:
server.tool(
"list_messages",
"List recent messages in a folder with subject, from, date. Default folder is INBOX.",
{
folder: z.string().default("INBOX").describe("Mailbox folder path"),
limit: z.number().min(1).max(100).default(20).describe("Max messages to return"),
},
async ({ folder, limit }) => {
const client = await getClient();
const lock = await client.getMailboxLock(folder);
try {
// fetch envelope + flags for the last `limit` messages
} finally {
lock.release();
}
}
);
Three small decisions paid off. list_messages returns only the envelope and the seen flag, so a 20-message listing stays cheap in tokens, and the agent calls get_message only for the ones that matter. The limit is capped at 100 in the schema, so the model cannot ask for the whole mailbox. And every handler takes a mailbox lock and releases it in finally, so one failed call does not leave the connection stuck.
Where the safety line is
The server is not read-only. It can flag, move and delete messages, and delete_message has a permanent option that sets \Deleted and expunges. The default moves to Trash. That option is the riskiest thing in the project, and I added it without thinking hard.
The write side is safer. There is no SMTP code anywhere. create_draft and create_reply_draft append a message to the Drafts folder and stop. A reply draft sets In-Reply-To and References so it threads correctly. A human opens the draft, reads it and presses send. If the model gets confused or a message contains a prompt injection, the worst outcome of the writing tools is a bad draft sitting in a folder.
Credentials come from IMAP_HOST, IMAP_USER, IMAP_PASSWORD, IMAP_PORT and IMAP_TLS, read once in getConfigFromEnv(), which throws if one is missing. .env is in .gitignore and the repo ships only an .env.example with placeholders like imap.example.com. The credentials never reach the model, because tools return mail content and not configuration. They do sit in your MCP config file, so that file deserves the same care as the mailbox.
What I would change
- Split read and write. Two servers, or one flag at startup, would do it, so a “summarise my inbox” session cannot delete anything. I would also drop permanent delete or put it behind a separate opt-in.
- Treat mail as untrusted input. Anything in
get_messagegoes straight into the model’s context. A hostile email can carry instructions. The no-send rule is what limits the damage, and I would not remove it. - Check the repo builds from a clean clone.
tools.tsimported./signature.js, a file I never committed, so a fresh clone would not compile. It worked on my machine, which is how this kind of bug survives. I have since committed the file and a clean clone builds. - Add tests. There are none. The IMAP calls are thin, but the draft headers deserve at least one check.
Takeaway
The tool list sets what the agent can do, whatever the prompt says. Letting it write drafts but never send took one design choice and no extra code. Permanent delete is the tool I should have questioned earlier.