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.
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? |
|---|---|---|
|
| No |
|
| No (stores a plan in the server's memory) |
|
| Yes: enable or pause |
|
| 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_plantwice 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_plananswers "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:
You: "Which mailboxes on acmemail.com have not started warm-up?" Claude calls
warmup_statuswithstate: not_startedand lists them.You: "Start warm-up for those." Claude calls
plan_warmupand shows the plan: 6 mailboxes, allnot_started, none missing.You: "Go ahead." Claude calls
apply_warmup_plan, thenwarmup_operationuntil it readsaccepted.Two days later: "Anything warming with an error?" Claude calls
warmup_statuswithstate: 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.
Keep reading
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 readEmail tools6 min read
Email Warmup API: Start, Pause and Monitor
Email tools8 min read
Cold Email API for Domains, Warm-Up and Replies
Email tools8 min read
MCP for Cold Email Infrastructure: Domains and Mailboxes

