· mcp · typescript · email · ai-agents

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.

Đọc bản tiếng Việt →

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_messages for reading
  • mark_message, move_message, delete_message for triage
  • create_draft, create_reply_draft for writing
  • list_templates, render_template for 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_message goes 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.ts imported ./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.