View as markdown ↓

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.