Email tools

MCP Server for Email Warmup With Claude

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

Outreach2dayPublished 7 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 warm-up for them. Reading changes nothing. Starting and pausing warm-up 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. A hosted Outreach2day MCP server is in development (Get early access on the home page); the server here runs locally with your API key.

What an Assistant Should Control in Warm-Up

Warm-up 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 warm-up service article explains the mechanism). Plan for at least two weeks before a new mailbox sends cold email, and keep warm-up running while it sends (warm-up timing).

That gives an assistant three useful jobs:

  • Report. List mailboxes by warm-up state and how long they have warmed.

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

  • Pause. Stop warm-up 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 warm-up 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 warm-up 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 warm-up 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 not read-only but 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["O2D_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:
    """Warm-up 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 warm-up 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 warm-up 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

Warm-up 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 O2D_API_KEY="$O2D_API_KEY" \
  -e O2D_WORKSPACE_ID="$O2D_WORKSPACE_ID" \
  -- /path/to/.venv/bin/python /path/to/o2d_warmup_mcp.py

A Session With Claude

A typical exchange:

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

  2. You: "Start warm-up 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 Warm-Up MCP Servers Compare

Other vendors expose warm-up 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 warm-up 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 warm-up volume?

No. The API turns warm-up on, off or into pause; daily volume is managed by Outreach2day and is not a request field.

Does the server return a reputation score?

No. It returns warm-up state and dates, not a score. 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 warm-up included in the price?

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

Get Started

Create an account, order mailboxes in the app, create a key on the API keys page and run the server above.

Get an API key

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, warm-up, leads, copy and scaling.

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

    Email Warmup API: Start, Pause and Monitor

  • Email tools8 min read

    Cold Email API for Domains, Warm-Up and Replies

See deliverability issues before they kill performance

Monitor mailbox health in real time, spot degradation early, and keep warmup, protection, and sending in one place.