MCP
Postwind is an MCP server. It serves Streamable HTTP at /mcp on the API listener, with the same Authorization: Bearer pw_… keys and scopes as the API. There are no sessions: every request is served on its own, so any api+mcp node answers any request. Each tool wraps exactly one API operation and is named after its operationId.
Connecting
URL: http://<api-host>:8080/mcp (the path is mcp.path, default /mcp). Header: Authorization: Bearer pw_…. stdio is not supported: the server is remote and keys are mandatory. Both client lifecycles are supported: initialize (protocol versions up to 2025-11-25) and server/discover (2026-07-28).
- The API listener speaks plain HTTP and expects a TLS terminator in front of it. Behind one, the URL is
https://<host>/mcp; the examples below usehttps://mail.example.com/mcp. - The server issues no
Mcp-Session-Id. Every POST answersapplication/json, so a load balancer needs no sticky sessions. - The key is checked on every HTTP request to the path, before anything else. A bad key gets the API’s
401problem+json, byte for byte. - Then the
Hostheader is checked againstmcp.allowed_hosts. The default is["localhost", "127.0.0.1", "::1"], and a host outside the list gets403. A remote client works only after the operator adds the node’s public host name. An entry without a port allows any port;[]turns the check off. - With
mcp.enabled = falsethe path is not mounted and answers404.
Client configuration
Use a project key with the scopes the agent needs, not an admin key, unless the agent has to manage projects. A key string in a config file is readable by anyone who can read the file.
Claude Code
claude mcp add --transport http postwind https://mail.example.com/mcp \
--header "Authorization: Bearer pw_prj_…"
Add --scope user to use the server in every project, or --scope project to write it to the project’s .mcp.json. Run /mcp in Claude Code to check the connection.
Cursor
In .cursor/mcp.json of the project, or ~/.cursor/mcp.json for every project:
{
"mcpServers": {
"postwind": {
"url": "https://mail.example.com/mcp",
"headers": {
"Authorization": "Bearer ${env:POSTWIND_KEY}"
}
}
}
}
Cursor reads ${env:POSTWIND_KEY} from the environment, so the key string stays out of the file.
Claude Desktop
Custom connectors take a remote server by URL. A static Authorization header is set in the Request headers section of the Add custom connector dialog, with No sign-in as the authentication and Bearer pw_… as the value. That section is in beta and only some organizations have it. Claude then connects to the node itself, so the node’s MCP URL must be reachable by Claude, not only by your computer.
Without that section, use the mcp-remote bridge in claude_desktop_config.json. mcp-remote is a community bridge: a local stdio process, run with npx, that forwards to a remote server. It is not part of Postwind or of Claude Desktop.
{
"mcpServers": {
"postwind": {
"command": "npx",
"args": [
"-y", "mcp-remote", "https://mail.example.com/mcp",
"--transport", "http-only",
"--header", "Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer pw_prj_…"
}
}
}
}
The header value is in env because some clients do not escape spaces inside args. --transport http-only skips the SSE fallback, which Postwind does not serve.
Other clients
Any MCP client that speaks Streamable HTTP and can send a static header works: give it the URL and Authorization: Bearer pw_….
First call
Call whoami with no arguments. Your client sends initialize or server/discover first, then:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "whoami", "arguments": {} }
}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "<the same JSON as structuredContent>" }],
"structuredContent": {
"key_id": "01900000-0000-7000-8000-000000000001",
"name": "ops",
"kind": "project",
"project_id": "<project_id>",
"scopes": ["read", "write"],
"expires_at": null
},
"isError": false
}
}
The answer tells the key’s kind, its project, its scopes and its expiry: the same body as GET /api/v1/whoami. Then get_server_info returns the node’s version, enabled subservices and transports.
Keys and project context
A project key works in its own project; the project_id argument is ignored. An admin key must pass project_id to project tools.
- Project tools are the tools of operations under
/api/v1/projects/{project_id}/…. Theirproject_idargument is optional in the schema. - With a project key the argument is ignored entirely, even when it is not a UUID.
- With an admin key and no argument the call fails with
validation_failed, fieldproject_id, coderequired. A value that is not a UUID isnot_found. get_project,update_project_settingsanddelete_projectrequireproject_idfrom every key and check it as the API checks its path: another project isnot_found.
After that the checks are those of the API; see Authentication and scopes.
Tool rules
Every description answers three questions in order: Call it … (when), Needs … (key, scope, state), Returns … (what comes back). Arguments are strict: an unknown argument is validation_failed. Output is structuredContent, field for field the body of the API operation, plus a text block with the same JSON.
The full rules, with the error shape and the limits, are on Tool rules.
Confirm and dry run
launch_campaign, cancel_campaign, revoke_api_key, revoke_admin_key, delete_project, delete_list, delete_sending_domain, erase_subscriber, add_suppression and remove_suppression need confirm: true. Without it the tool checks everything the real call checks, says what would happen (outcome, effect) and writes nothing.
confirm defaults to false, so a call that leaves it out is always a dry run. Run the dry run first, show its effect to a person when it matters, then call again with confirm: true.
Errors
isError: true with code and detail from the same table as the API, plus errors for validation_failed and precondition_failed.
The codes are on the API errors page. The rate limit (429) and the body limit (413) are answered at the HTTP level, as in the API; they are not tool errors.
Limits
Lists return up to 100 items (limit 1–100, default 50) with the same cursor as the API. import_subscribers takes up to 1 000 records per call; larger imports go through the API. There is no Idempotency-Key: a tool that got no answer is checked with the matching list_* tool before it is called again.
Requests share the key’s rate limit with the API, and every JSON-RPC message counts. See Rate limits.
Resources
postwind://docs/templates— template syntax and variablespostwind://docs/deliverability— the deliverability checklistpostwind://docs/errors— the error codes
The resources are built into the binary.
Guides
Tools
57 tools, one per API operation; names equal operationId.
Server The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
get_server_info | — | Describes the Postwind node that answered. | |
whoami | — | Tells which API key this connection uses. |
Projects The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_projects | — | Lists projects, oldest first, at most 100 per call. | |
create_project | — | Creates a project, the unit of isolation: its own keys, domains, lists, templates and campaigns. | |
get_project | required | Reads one project with all its settings. | |
update_project_settings | required | Renames a project and changes its settings: sender, transport, tracking, footer, branding of the public pages, quotas. | |
delete_project | confirm | required | Deletes a project: it refuses every change from then on, admin keys can still read it, and its slug becomes free. |
Keys The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_admin_keys | — | Lists the admin keys in id order, at most 100 per call, the bootstrap key and revoked and expired ones included. | |
create_admin_key | — | Creates an admin key, which may do everything: projects, their keys, admin keys. | |
revoke_admin_key | confirm | — | Revokes an admin key for good, this connection’s own included. |
list_api_keys | optional | Lists the API keys of a project in id order, at most 100 per call, revoked and expired ones included. | |
create_api_key | optional | Creates an API key of a project with the scopes it needs: read, write, send, keys. | |
revoke_api_key | confirm | optional | Revokes an API key of a project for good: every request with it is refused from then on. |
Lists The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_lists | optional | Lists the mailing lists of a project, oldest first, at most 100 per call. | |
create_list | optional | Creates a mailing list in a project, the audience campaigns go to. | |
get_list | optional | Reads one mailing list: its name, double opt-in, confirmation template and sender. | |
update_list | optional | Renames a mailing list and changes its options: double opt-in, the confirmation template, the sender of its own letters. | |
delete_list | confirm | optional | Deletes a mailing list: it stays readable, takes no change from then on and gets no new subscribers; its subscribers are kept. |
Subscribers The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_subscribers | optional | Lists a list’s subscribers, ordered by id, at most 100 per call, with an optional status filter. | |
add_subscriber | optional | Adds a subscriber to a list, or merges attributes into one already on it. | |
find_subscriber | optional | Finds a subscriber by its email on a list. | |
import_subscribers | optional | Adds a batch of up to 1 000 subscribers to a list in one call and reports record by record. | |
get_subscriber | optional | Reads one subscriber by its id, whatever list it is on: its email, attributes and status. | |
update_subscriber_attributes | optional | Changes a subscriber’s attributes, merging the ones you give into the ones it has (new values win, null removes a key); the status is never changed. | |
erase_subscriber | confirm | optional | Erases a subscriber for good, on the person’s request to delete their data: its row (address, attributes, status) is deleted, and in its events and the… |
get_subscriber_events | optional | Lists a subscriber’s history: the events about it (subscriber.created, subscriber.unsubscribed, subscriber.resubscribed, …) since it was added, oldest… | |
reactivate_subscriber | optional | Takes a bounced or complained subscriber back to active. | |
resubscribe_subscriber | optional | Subscribes an unsubscribed subscriber again, with fresh consent: active, or pending on a list with double opt-in; the suppression its unsubscribe left is… | |
unsubscribe_subscriber | optional | Unsubscribes a subscriber: from its list, or (scope: "project") the address from every list of the project. |
Suppressions The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_suppressions | optional | Lists the project’s suppressions by address, then scope, at most 100 per call, or those of one address (email). | |
add_suppression | confirm | optional | Suppresses an address by hand (reason manual) for the project or one of its lists: it gets no mail of that scope; subscriber statuses are not changed. |
remove_suppression | confirm | optional | Removes an address’s suppressions: every one, or only that of scope. |
Domains The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_sending_domains | optional | Lists the sending domains of a project in creation order, at most 100 per call. | |
add_sending_domain | optional | Adds a sending domain to a project and generates its DKIM key pair (RSA 2048; the private key is stored sealed and no call returns it). | |
get_sending_domain | optional | Reads one sending domain of a project. | |
update_sending_domain | optional | Changes a sending domain’s ip_pool, tracking_host or dns_provider. | |
delete_sending_domain | confirm | optional | Deletes a sending domain for good: no read finds it again, its DKIM key is destroyed, and its name is free to add again (with a new key); its records stay in… |
apply_dns_records | optional | Applies a sending domain’s required DNS records through its provider. | |
get_required_dns_records | optional | Lists the DNS records a sending domain needs, for entry by hand at any registrar. | |
verify_sending_domain | optional | Checks a sending domain’s DNS records through a resolver now and records the outcome. |
Templates The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_templates | optional | Lists the templates of a project in id order, each by its latest version and without its sources, at most 100 per call. | |
create_template | optional | Creates a template, its version 1: a message (kind message, the default: a subject, an HTML body and an optional text body) or a partial (kind partial: a… | |
get_template | optional | Reads the latest version of a template, with its sources. | |
update_template | optional | Saves a new version of a template: its latest version’s number plus one, with the sources given; the name and kind stay. | |
preview_template | optional | Renders a message template as a subscriber would get it, before anything is sent: the subject, the HTML and the text, with warnings. | |
send_test_email | optional | Sends a message template as a test to a few addresses through the project’s transport: rendered as preview_template renders it with sample_attributes… | |
get_template_version | optional | Reads one version of a template, with its sources. |
Campaigns The same group in the HTTP API
| Tool | Confirm | Project | What it does |
|---|---|---|---|
list_campaigns | optional | Lists the campaigns of a project, oldest first, at most 100 per call, optionally of one status. | |
create_campaign | optional | Creates a campaign in a project, a draft: one message template sent to one or more of its lists. | |
get_campaign | optional | Reads one campaign: its status, lists, template, sender and the other fields, and what its launch fixed. | |
update_campaign | optional | Changes a campaign’s fields: name, lists, template, sender, reply-to, subject, transport, tracking, throttle. | |
cancel_campaign | confirm | optional | Cancels a scheduled, sending or paused campaign for good: its letters not sent yet are cancelled; letters already sent stay sent. |
launch_campaign | confirm | optional | Launches a draft campaign: its recipients’ messages are queued and sending starts. |
pause_campaign | optional | Pauses a sending campaign: no new letter of it goes out until resume_campaign; the letters under way finish. | |
preflight_campaign | optional | Shows what launching a draft campaign would do now: its recipients after deduplication and suppression, its sender’s sending domain with its warnings, its… | |
resume_campaign | optional | Resumes a paused campaign: it is sending again and its remaining letters go out. | |
get_campaign_stats | optional | Reads where a campaign’s sending is: its status, how many recipients its launch wrote, and its deliveries by status (queued, deferred, sent, bounced… |