mcp server
mailtea-mcp
Mailtea MCP server — let AI agents send, schedule, and manage email over Model Context Protocol.
Set up
Runs on your computer. Pick your agent and operating system.
Before you start
- Node.js 20 or newer
{
"mcpServers": {
"mailtea-app-mailtea-mcp": {
"command": "npx",
"args": [
"-y",
"mailtea-mcp"
]
}
}
}same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12
Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs
README
synced Oct 14, 2026mailtea-mcp
Mailtea MCP server — let AI agents (Claude Code, Cursor, Codex, …) send, schedule, and manage email over the Model Context Protocol. MIT-licensed.
Quick start
Connect Claude Code in one command:
claude mcp add mailtea \
-e MAILTEA_API_TOKEN=mt_pat_xxxxxxxx \
-- npx -y mailtea-mcp
Create the token in Settings → API keys, then ask your agent to send an email. It calls email.send and the message goes out through Mailtea.
A live key is prefixed mt_pat_ or mt_svc_. A test key is prefixed mt_test_: the agent's sends are validated, recorded and emit webhooks, but nothing is delivered. See Test mode.
The server defaults to the Mailtea cloud API. Self-hosting or running locally? Add -e MAILTEA_API_BASE_URL=http://localhost:7787 (and optional -e MAILTEA_PUBLICATION_ID=pub_demo).
Which publication a tool acts on
publicationId (spelled publication_id on the automation and event tools) is optional everywhere. Left out, it resolves to the publication the connection is already for: the one chosen on the consent screen after a browser sign-in, the one a publication-scoped API key was minted for, or MAILTEA_PUBLICATION_ID over stdio. A team-scoped personal API key (the default key Studio creates) uses the one publication its owner belongs to in the key's team, when there is exactly one; a service key takes no such default. Pass it explicitly when your credential reaches more than one publication; the error then lists the ids it can take. An explicit id is never widened: a credential scoped to one publication is still refused for any other.
Tool families
email.*—email.send,email.batch,email.get,email.list,email.analytics,email.reschedule,email.cancel,email.resend(transactional, one-shot to specific recipients;resendretries a failed/bounced email).email.listtakesmode—liveortest, with no mixed viewemail.inbound_*: received email:inbound_list,inbound_get,inbound_list_attachments,inbound_get_attachment,inbound_reply(auto-threaded; all butinbound_listresolve the publication from the email id, so they take nopublicationIdat all);inbound_replytakesattachments(6 MiB together; the hosted connector takes requests up to 1 MB),to(the original's From or Reply-To) and the automatic-mailheadersauth.*issue.*: newsletter drafts and sends, pluspublish_to_web/unpublish_from_web. A post goes to all active contacts unlessissue.create_draft/issue.update_draftsetsegmentId(fromsegment.list, in the post's publication);nullonupdate_draftclears it. The segment picks the recipients when the post is senttemplate.*: reusable email templates:create,list,get,update,publish,unpublish,duplicate,delete, plusversions/restore_version. Editing or restoring a published template no longer returns it to draft: the change, From and Reply-To included, is saved as a working copy (has_unpublished_versions: true) and the published version keeps sending untilpublishis called again. Inversions,is_currentmarks the entry matching the working copy andis_publishedthe one that is sending, and each entry carries itsfrom,reply_toandsender_recorded: a sender-only change records a version (or folds into the open one, like any edit), andrestore_versionbrings the sender back with the design, except for an entry withsender_recorded: false, which predates sender history and keeps the current sender.unpublishis now the only way to stop a published template sending, short of deleting itpublication.*domain.*— sending domains: add, read DNS records, verify, then send from it;claim/claim_get/claim_verify/claim_canceltake over a domain another publication holds by proving DNS controlcontact.*— incl.get,delete,get_properties,set_properties.import_csvtakesenrollInAutomationsto put the imported contacts through matchingcontact.created/contact.subscribedautomations — how an imported list starts a welcome series. It defaults to false, the same default as Studio's import checkbox. Above 500 rows it also needsconfirmLargeEnrollment: true— the same acknowledgement a Studio operator gives on the confirm screen — or the import is refused withenrollment_too_largeand stores nothingcontact_property.*— custom contact fieldssegment.*: saved audience segments. Filters arestatus_filter,query_filterandinactive_days(no open or click in the last N days, 1 to 3650; a contact who never engaged counts as inactive, so it finds the silent cohort, not engaged readers). Engagement tracking is not backfilled, so contacts with no recorded engagement count as inactive. Onsegment.update,nullclears a filter.segment.deleterefuses a segment that a draft, scheduled or sending post targets (segment_in_use, and the error names the posts)tag.*— tag definitionswebhook.*— outbound event subscriptionsapi_key.*: manage API keys (requiressettings:write).api_key.createtakesmode: "test"to mint a test key.api_key.currentdescribes the key in use (team, publication, receiving domain, scopes, plan limits).analytics.*section.*automation.*: multi-step contact journeys:create,list,get,update,enable,disable,archive,delete,validate,metrics. An automation is a versioned graph ofsteps+connections, so an agent can author one as data.connectionsis optional (steps link in array order) and becomes required only when the graph branches;validate_only: trueoncreate/updateis a dry run returning the same codedissues[]a real failure returns, so an agent can self-correct before committing. On an active automation,updateis refused only when it adds an error the live version does not already have (active_graph_invalid), and changing its trigger needs a pause first (trigger_locked_while_active). These tools take snake_case arguments, unlike the older camelCase tools, because the graph payload is snake_case throughout and mixing the two inside one payload is a trapautomation_run.*—list,get,cancel. Run detail returns the graph the run is pinned to, not the live oneevent.*/event_definition.*—event.sendfor custom event ingest (opt-increate_contact,idempotency_key, fan-out counts in the reply), plusevent_definition.list / get / create / update
Current resources:
publication://current/brand-guidelinesmailtea://capabilitiesanalytics://current/latest-summarymailtea://automations/step-types— the machine-readable automation catalog: trigger types, step config shapes, branch labels, limits, condition operators and validation codes. Fetch it once instead of carrying the model in every tool schemamailtea://automations/condition-fields— the rule DSL forconditionsteps and filters: operators, addressable field namespaces,{"var": "…"}value references, and how an unresolved path evaluates
Current prompts:
newsletter.draft_from_brief: argumentsbrief(required),audience,tone,call_to_actionnewsletter.subject_line_pack: argumentstopic(required),count(1 to 30, default 10),audience
No tool writes copy: no AI model runs on Mailtea's side. Write the email yourself and save it with issue.create_draft.
Every tool in tools/list carries a title and the four MCP tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), the same on stdio and on the hosted server. Every send (including issue.schedule, event.send and automation.enable), every delete, and every consent or suppression change is marked destructive; anything that emails recipients, changes the public website, looks up public DNS or points a webhook at an outside URL is marked open world. The full table and the rules behind it are in the MCP server docs.
A tool that receives an argument it does not declare still runs, and its result adds a Warning: Ignored unknown argument item naming what it accepts.
Build
pnpm --filter mailtea-mcp build
Run locally over stdio
export MAILTEA_API_BASE_URL=http://localhost:7787
export MAILTEA_API_TOKEN=<BETTER_AUTH_SESSION_OR_PAT_TOKEN>
export MAILTEA_PUBLICATION_ID=pub_demo
node packages/mcp/dist/stdio.js
Required env:
MAILTEA_API_BASE_URLMAILTEA_API_TOKEN
Optional env:
MAILTEA_PUBLICATION_ID
Copy-paste stdio config pattern
If your MCP client accepts a stdio server definition, this is the minimal pattern:
{
"mcpServers": {
"mailtea": {
"command": "node",
"args": ["/absolute/path/to/mailtea/packages/mcp/dist/stdio.js"],
"env": {
"MAILTEA_API_BASE_URL": "http://localhost:7787",
"MAILTEA_API_TOKEN": "<BETTER_AUTH_SESSION_OR_PAT_TOKEN>",
"MAILTEA_PUBLICATION_ID": "pub_demo"
}
}
}
}
Use this as the starting point for:
- Codex-style MCP clients
- Claude Code-style MCP clients
- Cursor/OpenCode-style MCP clients
- internal agent launchers
The exact config file location varies by client, but the process contract above stays the same.
Copy-paste remote MCP pattern
If your client supports remote MCP over HTTP, point it at Mailtea API:
{
"mcpServers": {
"mailtea": {
"url": "http://localhost:7787/mcp",
"headers": {
"Authorization": "Bearer <BETTER_AUTH_SESSION_OR_PAT_TOKEN>"
}
}
}
}
Remote MCP is useful when:
- the agent runtime cannot launch a local stdio process
- you want one shared Mailtea control plane for multiple clients
- you are testing integrations from another machine or container
Safe remote smoke:
MAILTEA_API_BASE_URL=http://localhost:7787 \
MAILTEA_API_TOKEN=<BETTER_AUTH_SESSION_OR_PAT_TOKEN> \
pnpm deploy:smoke:mcp
First useful calls
Start with this sequence:
email.sendpublication.listissue.create_draftissue.update_draftcontact.listanalytics.latest_summary
That verifies the full loop:
- discover workspace
- draft content
- inspect audience
- inspect outcomes
Test mode
A test key (mt_test_…) sends nothing. Every message the agent creates is
validated, recorded and emits webhooks, but is never handed to a provider — so
an agent can exercise the whole send path, and a CI run can point at production
Mailtea, without a single message reaching an inbox.
Mint one with api_key.create ({ "name": "CI", "mode": "test" }), or in
Settings → API keys, and connect the server with it in place of the live
token.
Reserved recipients on test.mailtea.email force the outcome. The first to
recipient decides:
| Recipient | Result |
|---|---|
delivered@test.mailtea.email | sent, then delivered |
bounced@test.mailtea.email | sent, then bounced |
complained@test.mailtea.email | sent, then a spam complaint |
delayed@test.mailtea.email | sent, delayed, then delivered |
failed@test.mailtea.email | fails outright, never sent |
| anything else | sent, then delivered |
email.list takes mode to read test mail back, and every row it returns
carries its own mode — a test row is marked [test] in the summary. A test key
reads only test mail and a live key only live mail; there is no mixed view.
A test key is not a data sandbox. It reads and writes the real contacts, templates, senders and webhooks. Only delivery is simulated. Newsletter sends, "send me a copy" and automation enrollment are refused with a test key.
When to use MCP instead of direct API calls
Use mailtea-mcp when you want:
- a compact tool catalog
- prompt and resource support
- less custom tool-wrapping work
- better interoperability across coding agents
Use the API directly when you need:
- a browser action layer
- a non-MCP builder
- full transport control
- custom HTTP orchestration