---
title: MCP setup
description: Configure the Postfleet MCP server with a bearer key, the full tool list, and how to handle the 202 pending-approval outcome.
updatedAt: "2026-08-24"
---

# 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:

```json
{
  "mcpServers": {
    "postfleet": {
      "url": "https://api.postfleet.ai/api/mcp",
      "headers": { "Authorization": "Bearer pf_..." }
    }
  }
}
```

The Cursor marketplace plugin in the [postfleet-mcp](https://github.com/Thestral12/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:

```json
{ "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](/docs/auth). 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](/docs/api) — 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](/docs/sending) 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:

```json
{ "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](/docs/drafts) 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.
