All Posts
MCP

BUILDING A GMAIL MCP SERVER FROM SCRATCH

How I exposed Gmail as AI-native tooling over raw IMAP/SMTP — no OAuth dance, no browser automation — so any MCP-compatible agent can read and send email through standardised tool calls.

Apr 2025 8 min read MCP · Python · FastAPI
View on GitHub →

The Model Context Protocol (MCP) is Anthropic's open standard for giving language models structured, type-safe access to external systems. Think of it as USB-C for AI tools — one consistent interface regardless of which agent, host, or service is on either end. When I started this project I wanted to answer a simple question: what does it actually take to wrap a messy real-world protocol like IMAP/SMTP into something an LLM can call cleanly?

Why Gmail?

Email is one of those surfaces that every knowledge-worker drowns in. If an AI agent can reliably search threads, extract context, and compose replies — without ever opening a browser or scraping HTML — it unlocks a whole category of automations that are currently friction-heavy. Gmail's REST API is the obvious answer but it requires an OAuth 2 consent flow that breaks down in headless agent environments. App Passwords over raw IMAP/SMTP let us bypass that entirely.

The Architecture

Gmail MCP Server architecture diagram Gmail MCP Server — architecture overview from the repo

The server ships with two modes behind the same codebase. A FastAPI web UI on port 8000 for humans who want direct inbox management, and a headless MCP server that speaks the protocol's JSON-RPC transport over stdio. Both share the same IMAP/SMTP connection layer.

project layout
gmail-mcp-server/
├── server.py         # MCP tool definitions + stdio transport
├── app.py            # FastAPI web UI (port 8000)
├── gmail_client.py   # IMAP/SMTP abstraction layer
├── Dockerfile
├── docker-compose.yml
└── .env.example      # GMAIL_ADDRESS + GMAIL_APP_PASSWORD

The 8 MCP Tools

Every tool is declared with a JSON Schema so the agent knows exactly what arguments to supply and what shape the response takes. Here's the full surface area:

Tool name What it does
list_emailsReturns the N most recent messages (subject, sender, date, snippet)
search_emailsFull-text search across subject and body
search_by_senderFilters messages from a specific address
search_by_subjectFilters by subject line keyword
get_unreadReturns all unread messages
get_email_detailsFetches the full body + headers of one message by UID
send_emailComposes and sends via SMTP
list_foldersEnumerates all IMAP mailboxes/labels

Defining a Tool in Code

The MCP Python SDK makes this refreshingly clean. You decorate a function with @mcp.tool() and return a Pydantic model. The SDK handles schema generation, JSON-RPC dispatch, and error serialisation automatically.

server.py
from mcp.server.fastmcp import FastMCP
from gmail_client import GmailClient

mcp = FastMCP("gmail")
client = GmailClient()

@mcp.tool()
async def search_emails(query: str, limit: int = 10) -> list[dict]:
    """Search emails by keyword across subject and body."""
    return await client.search(query, limit)

The IMAP Connection Layer

IMAP is stateful and connection-hungry, which is exactly the wrong shape for an async server handling concurrent agent requests. The fix is a small connection pool wrapped in an asynccontextmanager — each operation borrows a connection, runs its fetch, and returns it immediately.

gmail_client.py (simplified)
import imaplib, asyncio
from contextlib import asynccontextmanager

class GmailClient:
    def __init__(self):
        self._pool: list[imaplib.IMAP4_SSL] = []

    @asynccontextmanager
    async def _conn(self):
        conn = await asyncio.to_thread(self._acquire)
        try:
            yield conn
        finally:
            self._pool.append(conn)

    async def search(self, query: str, limit: int) -> list:
        async with self._conn() as conn:
            return await asyncio.to_thread(self._do_search, conn, query, limit)

Auth: App Passwords Beat OAuth Here

Google's App Passwords are 16-character tokens scoped to a single app. They live in .env as GMAIL_APP_PASSWORD and require 2-Step Verification to be enabled on the Google account. No redirect URIs, no token refresh, no consent screen — the agent just works.

Docker Setup

Both modes are selectable via a single environment variable so the same image works in both contexts:

docker-compose.yml
services:
  gmail-web:
    build: .
    environment:
      - MODE=web
      - GMAIL_ADDRESS=${GMAIL_ADDRESS}
      - GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD}
    ports: ["8000:8000"]

  gmail-mcp:
    build: .
    environment:
      - MODE=mcp
      - GMAIL_ADDRESS=${GMAIL_ADDRESS}
      - GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD}

Connecting to Claude

In Claude Desktop's config, point the MCP server entry at the Docker container's stdio transport. Claude will auto-discover all 8 tools, their schemas, and their descriptions — and start using them within seconds of the first prompt.

claude_desktop_config.json
{
  "mcpServers": {
    "gmail": {
      "command": "docker",
      "args": ["run", "--rm", "-i",
               "--env-file", ".env",
               "gmail-mcp-server"]
    }
  }
}

What I'd Do Differently

Python MCP Protocol FastAPI Docker IMAP / SMTP Anthropic API