Email tools

MCP Server for Email Warmup With Claude

Build an MCP server for email warmup: Claude reads warmup state, then enables or pauses mailboxes only after you approve a plan. Tested Python code.

Max Korolev
Max KorolevFounder & CEO, Outreach2dayUpdated 8 min read
On this page

An MCP server for email warmup lets an assistant such as Claude answer "which mailboxes are not warming yet?" and then start warmup for them. Reading changes nothing. Starting and pausing warmup changes how your mailboxes behave, so the assistant should propose a change, you approve it, and a retry must not start a second operation.

This post builds that server in Python over the Outreach2day email warmup API. It has four tools, two of which only read. The default is the hosted Outreach2day MCP server (Outreach2day MCP): it covers warmup too; add https://public.outreach2day.com/mcp to your AI client and sign in with your Outreach2day account. The server here is optional: it runs locally with a key from $OUTREACH2DAY_API_KEY.

What an Assistant Should Control in Warmup

Warmup sends a small, growing number of emails between a new mailbox and other mailboxes that open and reply to them, so providers see a normal sending history before any cold email goes out (the email warmup service article explains the mechanism). Plan for at least two weeks before a new mailbox sends cold email, and keep warmup running while it sends (warmup timing).

That gives an assistant three useful jobs:

  • Report. List mailboxes by warmup state and how long they have warmed.

  • Start. Enable warmup for mailboxes that are set up but not_started.

  • Pause. Stop warmup on a mailbox while you look into a problem, then enable it again.

disable and remove also exist in the API. They are left out of the server: turning warmup off on a sending mailbox is a decision you make on purpose, in the app or with a curl call.

The Tools

Tool

Endpoint

Changes anything?

warmup_status

GET /warmup/mailboxes

No

plan_warmup

GET /warmup/mailboxes

No (stores a plan in the server's memory)

apply_warmup_plan

POST /warmup/operations

Yes: enable or pause

warmup_operation

GET /warmup/operations/{operation_id}

No

Plan, Approve, Apply

The change is split into two tools. plan_warmup takes an action and a list of addresses, checks them against the workspace and returns a plan: its ID, each mailbox with its warmup state and mailbox status, and the addresses that are not in the workspace. Addresses are compared in lower case, as the API does. Only mailboxes with status active or warmup_* are accepted, so a mailbox that is still being set up shows up in the plan before it is rejected. It changes nothing. apply_warmup_plan takes only the plan ID, so the model cannot change the mailbox list between what you saw and what runs.

The client's permission prompt is not enough on its own. The MCP specification asks clients to let a person deny tool calls and to show tool inputs, but how that looks is up to each client, and once a tool is allowed permanently (Claude Code offers "don't ask again"), no prompt appears. With a plan step, the approval happens in the conversation, in words: "Start warmup for these 12 mailboxes?" The docstring of plan_warmup tells the model to show the plan and wait.

The tool annotations say the same thing in machine-readable form: plan_warmup is readOnlyHint, and apply_warmup_plan is a write tool marked idempotentHint. The specification calls annotations hints, and clients do not have to act on them, so the plan step is what enforces the flow.

The Email Warmup MCP Server

Save this as o2d_warmup_mcp.py and install the same packages as in the cold email MCP server post (mcp 2.x and requests):

import os
import uuid
from typing import Literal

import requests
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

BASE = "https://public.outreach2day.com"
KEY = os.environ["OUTREACH2DAY_API_KEY"]
WS = int(os.environ["O2D_WORKSPACE_ID"])
MAX_BATCH = 100
PLANS = {}

mcp = MCPServer("outreach2day-warmup")


def call(method, path, **kwargs):
    try:
        r = requests.request(
            method, BASE + path, timeout=30,
            headers={"Authorization": f"Bearer {KEY}"},
            params={"workspaceId": WS}, **kwargs,
        )
    except requests.RequestException as e:
        raise ToolError(
            f"Network error ({type(e).__name__}); the "
            "request may have arrived. Check state "
            "before retrying.") from e
    if r.status_code >= 400:
        raise ToolError(
            f"API {r.status_code}: {r.text[:300]}")
    return r.json()


def states():
    rows = call("GET", "/warmup/mailboxes")
    return {m["mailbox"].lower(): m for m in rows}


@mcp.tool(annotations=ToolAnnotations(
    read_only_hint=True))
def warmup_status(state: str | None = None) -> list:
    """Warmup state per mailbox. Optional filter:
    not_started, warming, paused, error, disabled."""
    keep = ("mailbox", "mailboxStatus", "warmupState",
            "warmupStartedAt", "warmupPausedAt")
    return [{k: m.get(k) for k in keep}
            for m in states().values()
            if state is None or m["warmupState"] == state]


@mcp.tool(annotations=ToolAnnotations(
    read_only_hint=True))
def plan_warmup(action: Literal["enable", "pause"],
                mailboxes: list[str]) -> dict:
    """Prepare a warmup change. Changes nothing.
    Returns [warmupState, mailboxStatus] per
    mailbox; only active or warmup_* statuses are
    accepted. Show the plan to the user; call
    apply_warmup_plan only after they approve."""
    if not 0 < len(mailboxes) <= MAX_BATCH:
        raise ToolError(f"Send 1-{MAX_BATCH} mailboxes")
    asked = {m.strip().lower() for m in mailboxes}
    now = states()
    found = sorted(asked & now.keys())
    missing = sorted(asked - now.keys())
    if not found:
        raise ToolError("No mailbox found in workspace")
    plan_id = uuid.uuid4().hex
    PLANS[plan_id] = {"action": action,
                      "mailboxes": found}
    return {
        "plan_id": plan_id,
        "action": action,
        "mailboxes": {m: [now[m]["warmupState"],
                          now[m]["mailboxStatus"]]
                      for m in found},
        "not_in_workspace": missing,
    }


@mcp.tool(annotations=ToolAnnotations(
    read_only_hint=False, destructive_hint=False,
    idempotent_hint=True))
def apply_warmup_plan(plan_id: str) -> dict:
    """Run a plan from plan_warmup after the user
    approved it. Safe to retry: the same plan_id
    returns the same operation."""
    plan = PLANS.get(plan_id)
    if plan is None:
        raise ToolError("Unknown plan_id; call "
                        "plan_warmup first")
    op = call("POST", "/warmup/operations", json={
        "action": plan["action"],
        "mailboxes": plan["mailboxes"],
        "idempotencyKey": plan_id,
    })
    return {k: op.get(k) for k in (
        "operationId", "status", "acceptedCount",
        "rejectedCount", "rejections")}


@mcp.tool(annotations=ToolAnnotations(
    read_only_hint=True))
def warmup_operation(operation_id: str) -> dict:
    """Status of a warmup operation. Final when
    status is not requested or processing."""
    op = call("GET", f"/warmup/operations/{operation_id}")
    return {k: op.get(k) for k in (
        "operationId", "status", "action",
        "acceptedCount", "rejectedCount",
        "successCount", "failureCount", "rejections")}


if __name__ == "__main__":
    mcp.run()

Idempotency: Why a Retry Does Not Start a Second Operation

POST /warmup/operations requires an idempotencyKey (8 to 128 characters). A request with a key that already exists in the workspace returns the existing operation, whatever the body says (details), so a key must stand for exactly one batch.

The server uses the plan ID (32 hex characters) as the key. That gives three properties:

  • A timeout or a model that calls apply_warmup_plan twice returns the same operation.

  • A new plan gets a new key, so enabling, pausing and enabling again the same mailboxes on the same day runs three operations.

  • The plan lives in the server process. After a restart, apply_warmup_plan answers "Unknown plan_id" and the model has to make a new plan, which you approve again.

Do not let the model invent keys. A new random key per attempt turns every retry into a new operation.

Reading the Result

Warmup changes run in the background. apply_warmup_plan returns the operation right away, usually with status: requested. The model then calls warmup_operation until the status is neither requested nor processing. For enable, the normal final status is accepted, and successCount stays 0. The statuses and rejection codes are the same as in the REST call; the full table is in the email warmup API post. The operation says the request went through; warmup_status shows whether a mailbox is actually warming.

Test It

Start the server with an invalid key and call a read tool, as shown in the cold email MCP server post. With the key ot2d_invalid, warmup_status returns an error result with API 401: {"detail":"Invalid API key"}, and apply_warmup_plan with a made-up ID returns "Unknown plan_id; call plan_warmup first" without calling the API.

Then connect it the same way as the first server:

claude mcp add outreach2day-warmup \
  -e OUTREACH2DAY_API_KEY="$OUTREACH2DAY_API_KEY" \
  -e O2D_WORKSPACE_ID="$O2D_WORKSPACE_ID" \
  -- /path/to/.venv/bin/python /path/to/o2d_warmup_mcp.py

Free guide19 min read

Send 100,000 cold emails a month

Domains and mailboxes you need, DNS, warmup, lists, copy and follow-ups, with a launch checklist, sent to your inbox.

Unsubscribe in one click.

A Session With Claude

A typical exchange:

  1. You: "Which mailboxes on acmemail.com have not started warmup?" Claude calls warmup_status with state: not_started and lists them.

  2. You: "Start warmup for those." Claude calls plan_warmup and shows the plan: 6 mailboxes, all not_started, none missing.

  3. You: "Go ahead." Claude calls apply_warmup_plan, then warmup_operation until it reads accepted.

  4. Two days later: "Anything warming with an error?" Claude calls warmup_status with state: error.

How Other Warmup MCP Servers Compare

Other vendors expose warmup through hosted MCP servers:

  • Instantly's MCP server has sending-account tools next to campaigns, leads, email and analytics; its help center gives "Enable warmup for my new account" as an example prompt (Instantly help center).

  • Warmforge is one of the products behind the Salesforge Forge MCP server; its README lists 15 Warmforge tools (forge-mcp).

  • SimplyWarmup runs a hosted server for warmup and inbox health, listed as official on PulseMCP.

Hosted servers need no code. A local server gives you control over which actions exist and how they are confirmed.

FAQ

Can the assistant set the daily warmup volume?

No. The API turns warmup on, off or into pause; daily volume is managed by Outreach2day and is not a request field. Each mailbox sends 25+ warmup emails a day to Google recipients, 25 to Outlook and 25 to other providers, so 75+ warmup emails a day in total.

Does the server return a reputation score?

No. It returns warmup state and dates. Placement and reputation checks are covered in the spam test score post.

Why a limit of 100 mailboxes per plan?

A plan you approve should be short enough to read. Raise MAX_BATCH if you need larger batches, or split them into several plans.

Can I combine this with the read-only server?

Yes. Run both, or merge the tools into one file. Keep the plan and apply tools together so the plan store is shared.

Is warmup included in the price?

Yes, it is part of the per-mailbox price on every plan; see pricing.

Connect the Hosted Outreach2day Server

Add https://public.outreach2day.com/mcp to your AI client and sign in with your Outreach2day account when it asks: in claude.ai and Claude Desktop as a custom connector, in ChatGPT as a developer-mode app, in Cursor and VS Code with the one-click links on the MCP page. To set up every MCP client on your computer at once, run the installer:

curl -fsSL https://outreach2day.com/install | sh

On Windows, in PowerShell: irm https://outreach2day.com/install.ps1 | iex. The installer finds Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Gemini CLI and Codex on your computer, backs up each config file and adds an outreach2day server by URL next to the servers you already have (for Claude Desktop, which takes remote servers as connectors, it prints the steps). It asks for no API key: each client opens an Outreach2day sign-in page the first time it connects. For CI or a machine without a browser, run the installer with --use-key: it asks for the key once and the client config reads it from $OUTREACH2DAY_API_KEY. --client cursor limits it to one client, --dry-run shows the changes without writing them, --uninstall takes them out again.

Then ask your agent: "Check my Outreach2day account status". Steps for each client are on the MCP page.

The hosted server's warmup tools, a step-by-step session and prompts for each assistant are on email warmup over MCP. To go from new mailboxes to a launched campaign in one chat, see outbound end to end over MCP.

Get Started

Create an account and order mailboxes in the app, then add the MCP server to your AI client and sign in. For the local server above, create a key on the API keys page.

Free guide

Send 100,000 cold emails a month

How to send 100,000 cold emails a month while keeping quality, engagement and conversion rates high: domains, mailboxes, warmup, leads, copy and scaling

Step-by-step playbook · 19 min read
  • Email tools6 min read

    Email Warmup API: Start, Pause and Monitor

  • Email tools6 min read

    How to Connect Claude to Cold Email

  • Email tools6 min read

    How to Connect Claude Code to Cold Email

  • Agent skill

    Warmup metrics reader

Deliverability call

Talk to our deliverability team

Book a call with the people who run our mailbox infrastructure. We look at your current setup and tell you what to change.

  • Review your domains, DNS records and current inbox placement
  • Size the setup: domains, mailboxes per domain and daily volume per mailbox
  • Plan warmup and the move from your current provider or sequencer

Free call

See open times in your time zone and book on this page.

Or open the booking page