MCP setup
Postfleet ships a Model Context Protocol (MCP) server so an agent can send and read mail as
tools. It is thin-over-REST: every tool calls the same /api/v1/ endpoints as your key, so
auth, scoping, and quotas are enforced exactly once.
Configure the server
The server speaks streamable HTTP at /api/mcp and authenticates with a bearer pf_ key. Pass
the key in the Authorization header:
{
"mcpServers": {
"postfleet": {
"url": "https://api.postfleet.ai/api/mcp",
"headers": { "Authorization": "Bearer pf_..." }
}
}
}
The Cursor marketplace plugin in the postfleet-mcp
repo declares headers.Authorization = Bearer ${POSTFLEET_API_KEY} so the key is attached at
install time. Local stdio is npx -y @postfleet/mcp with POSTFLEET_API_KEY set.
A request without a Bearer token — including initialize and tools/list — is rejected 401,
with a WWW-Authenticate: Bearer header that also points at RFC 9728 protected-resource metadata:
{ "error": "missing bearer token",
"hint": "Configure this MCP server with an Authorization: Bearer pf_... header." }
WWW-Authenticate includes resource_metadata so a Cursor/Grok connect card can finish: it
discovers /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server,
then the authorize page attaches an existing pf_ key as the access token. On hosted Postfleet
every protected-resource document (and the 401 resource_metadata URL) advertises the same
resource: https://api.postfleet.ai/api/mcp. Adding the hosted URL with no header will not
list the server as connected.
The key's scope and can_read/can_send/mailbox binding apply to tool calls exactly as they
do to the REST API — see Authentication. Note that wait_for_email can hold a request
open up to ~2 minutes, so a deployment must allow a long request duration.
Tools
Every tool is a thin wrapper over the same /api/v1/ endpoints documented in the
API reference — same validation, same error codes, same scoping.
Listed in the order the server registers them, which is the order an agent scanning the roster reads top-down: discovery first, so an agent finds an existing mailbox before creating a second one.
| Tool | Arguments | Purpose |
|---|---|---|
list_mailboxes |
(none) | List the mailboxes you can use, newest first, with id and address. Usually the first call in a workflow — every other tool needs an id reached through it. |
create_mailbox |
slug?, display_name?, extraction_schema_id?, domain_id? |
Create a mailbox this agent owns. Pass a verified domain_id from list_domains to host it on a custom domain instead of the shared one. |
list_domains |
(none) | The account's custom sending domains with id, name, and verification status. Only verified domains can host mailboxes. |
create_domain |
name |
Register a new custom sending domain. Returns id, status, and DNS records. Paid plan required. |
verify_domain |
id |
Re-check DNS and refresh verification status after publishing records. |
send_email |
mailbox_id, to, subject, text, client_id |
Send a new email. |
reply_email |
mailbox_id, reply_to_message_id, text, client_id |
Reply to a received message (threads automatically). |
list_inbox |
mailbox_id, limit? |
List recent messages, newest first, with comprehension status per message. |
read_email |
message_id |
Read one message in full (cleaned body, sanitization report, classification, extraction). |
wait_for_email |
mailbox_id, from_contains?, subject_contains?, timeout_seconds? |
Block until a matching email arrives, or time out ({timed_out:true}). |
create_draft |
mailbox_id, to?, subject?, text, reply_to_message_id? |
Stage a draft without sending. Returns {id, status:"draft"}. |
list_drafts |
mailbox_id |
List open drafts (status draft, pending_approval, sending). |
get_draft |
id |
Read one draft in full: recipient, subject, body text, and current status. |
update_draft |
id, to?, subject?, text? |
Edit an unsent draft; omitted fields keep their current value. The reply target can't be changed, and a draft already sending or sent can't be edited. |
send_draft |
id |
Send a staged draft by id. |
delete_draft |
id |
Discard a draft so it will never be sent. Already-sending or sent drafts can't be deleted. |
client_id is required on send_email and reply_email — unlike the REST API, where it stays
optional for compatibility with direct callers. Pass any unique string you make up, and reuse the
same one if you retry after an error: that is what makes the send at-most-once.
Sending & idempotency has the full replay table for what a repeated
client_id returns in each state. An optional
guard is precisely the one an agent omits, so the MCP schema requires it. The draft: prefix is
reserved for the approval flow's own keys and is rejected.
read_email never returns the pre-sanitization raw body — only the cleaned, screened content
reaches the agent.
The 202 pending-approval outcome
If a mailbox requires human approval, send_email, reply_email, and send_draft do not
send immediately. They return the queued-draft shape instead of a message id:
{ "draft_id": "d_123...", "status": "pending_approval" }
This is a success, not an error, and it must not be retried. The email is delivered once a
human approves the draft in the dashboard. To follow up, call list_drafts — a pending_approval
entry is still waiting on a human; once approved it is sent and tombstoned (drops off the list).
Distinguish the two success shapes:
{ "id": "...", "thread_id": "..." }→ sent.{ "draft_id": "...", "status": "pending_approval" }→ queued for human approval.
See Drafts & human-in-the-loop for the full approval model.
Errors
Tool errors come back as a short message plus a recovery hint (never a stack trace), for
example an invalid key (401), a scope/capability rejection (403), quota exhaustion (402),
a not-found id (404), a domain already registered (409), a suppressed recipient (422),
the email-provider domain cap (403 provider_domain_limit — not the Postfleet $20 plan;
do not retry until an operator raises provider capacity), a provider rejection (502 —
permanent for verify_domain and most create_domain failures; send tools may retry with
the same client_id), or a missing provider configuration (503). A 503 means the hosted
API has no provider key; retrying or using the dashboard will not help until an operator
configures it. The underlying status and shapes match the REST API documented throughout
these pages.