MCP server: connect AI assistants to Postal
Connect Claude, ChatGPT, Gemini, Cursor, VS Code and other AI assistants to your Postal account: 34 tools, OAuth or API key, permissions by scope and the same rules as the API.
#What it is
Postal's MCP (Model Context Protocol) server lets AI assistants, such as Claude, ChatGPT, Gemini, Cursor, VS Code and Claude Code, use your account: send emails, check a delivery, adjust contacts, write and send campaigns, read the inbox. The assistant calls tools (34 today), and each one runs the same endpoint of the REST API.
Address: https://api.postal.so/mcp. It is the MCP Streamable HTTP transport, with no session: every call is a POST carrying one JSON-RPC message and its own credential. Every call acts on one team: the one you picked when connecting the app, or the team of the API key.
- The same rules as the API. Validation, plan limits, sending quotas, sending pause, the suppression list and the team's rate limit apply as in any call. Errors come as
name: message(for examplevalidation_error: …orsending_paused: …), the same error names as the API. - Two ways in. OAuth, where the person signs in to Postal, picks the team and can revoke later; or an API key in the
Authorizationheader. - Permissions by scope. A connected app can only read, only send or do everything, and the assistant only sees the tools it may use.
- Irreversible actions ask for confirmation. Sending a broadcast requires
confirm: true, and the server's instructions tell the assistant to confirm recipients and content with you before sending emails.
Depends on your plan
The MCP server is a plan feature (mcp_access). Without it, the endpoint answers 403 plan_limit_reached. See Limits.
#Connect an assistant
In the dashboard, open Settings › AI connections: the screen shows the server URL and the step-by-step instructions for each assistant. Chat apps use OAuth; developer tools accept OAuth or an API key.
| Assistant | Sign-in | How to connect |
|---|---|---|
| Claude | OAuth | In the connector settings (web or desktop), add a custom connector, paste the URL and connect: you sign in to Postal, pick the team and allow access. |
| ChatGPT | OAuth | In the apps and connectors settings (some plans need developer mode), create a connector with the URL and OAuth authentication. |
| Gemini | OAuth or key | In Gemini CLI, add the server to ~/.gemini/settings.json (or use the URL in Gemini apps that accept MCP servers). Without the header, Gemini opens the Postal sign-in on first use. |
| Cursor | OAuth or key | In the MCP settings (or in ~/.cursor/mcp.json), add a server. With just the URL, Cursor opens the Postal sign-in so you can pick the team. |
| VS Code | OAuth or key | Run MCP: Add Server, choose HTTP and paste the URL; on the first call VS Code asks you to sign in. With a key, use the .vscode/mcp.json below. |
| Claude Code | OAuth or key | Add the server with the command below. Without the header, run /mcp inside Claude Code to sign in to Postal. |
To sign in with an API key, use the examples below and replace ps_... with your key. The screens of each assistant change often; when in doubt, follow its own documentation.
Claude Code
claude mcp add --transport http postal https://api.postal.so/mcp \
--header "Authorization: Bearer ps_..."
Cursor
// ~/.cursor/mcp.json
{
"mcpServers": {
"postal": {
"url": "https://api.postal.so/mcp",
"headers": { "Authorization": "Bearer ps_..." }
}
}
}
VS Code
// .vscode/mcp.json
{
"servers": {
"postal": {
"type": "http",
"url": "https://api.postal.so/mcp",
"headers": { "Authorization": "Bearer ${input:postal-api-key}" }
}
},
"inputs": [
{ "id": "postal-api-key", "type": "promptString", "description": "Postal API key", "password": true }
]
}
Gemini CLI
// ~/.gemini/settings.json
{
"mcpServers": {
"postal": {
"httpUrl": "https://api.postal.so/mcp",
"headers": { "Authorization": "Bearer ps_..." }
}
}
}
Other MCP clients that read a file with mcpServers and accept headers use the same block as Cursor.
#Authentication
The server accepts two credentials, always as Authorization: Bearer. No other is accepted, and Postal does not pass the token on to anyone.
| OAuth | API key | |
|---|---|---|
| Best for | The Claude, ChatGPT and Gemini connectors and any app that can open a sign-in window. | Developer tools that accept headers (Claude Code, Cursor, VS Code) and scripts. |
| How it signs in | The person signs in to Postal, picks a team and allows access. The app gets an access token (1 hour) and a refresh token (30 days). | A key starting with ps_, created under API keys. |
| Permissions | The scopes the app asked for and the person accepted. | Full access (full_access) becomes mcp:full; sending only (sending_access) becomes mcp:send. |
| Revoking | In AI connections, at any time. | Deleting the key, under API keys. |
An API key restricted to one domain only sends from that domain, as in the API. An OAuth token stops working right away when the connection is revoked, when the person leaves the team or when the account is suspended.
Prefer OAuth when the app offers it: you pick the team, see what the app did and revoke without changing any key. If you use a key, prefer a sending-only one when the assistant only needs to send emails and events.
Without a credential, /mcp answers 401 with a WWW-Authenticate header pointing at the resource metadata (/.well-known/oauth-protected-resource/mcp): that is how an MCP client finds out how to start OAuth.
#Scopes and permissions
Each tool asks for a scope. mcp:full covers all of them; mcp:send does not include reading (an app that only sends cannot see emails, contacts or usage). In tools/list, the assistant only sees the tools the token or key can call.
| Scope | Name on the consent screen | What it allows | Tools |
|---|---|---|---|
mcp:read | Read your data | See emails, domains, contacts, segments, topics, templates, broadcasts, automations, forms, inboxes, metrics and usage. | 22 read tools |
mcp:send | Send emails and events | Send and schedule emails from your verified domains and record events for your automations. | send_email, track_event |
mcp:full | Manage everything | Create, change and delete contacts, templates and broadcasts, send broadcasts, cancel scheduled emails and work on inbox conversations (organize, reply, save drafts). Includes the other two scopes. | All 34 tools |
If the app asks for no scopes, the screen offers all three. The person allows or denies the request as a whole.
When permission is missing
- OAuth connection: calling a tool beyond the scopes gives
403withWWW-Authenticate: Bearer error="insufficient_scope", scope="…"and the JSON-RPC bodyinsufficient_scope: …. The client can ask the user to connect again with the missing scope. - API key: a sending-only key that calls a read or management tool gets a
restricted_api_keytool error. Note: in MCP,get_emailandget_usageneedmcp:read, even though the REST API lets a sending-only key read those two endpoints.
#Tools
Each tool's arguments follow the fields of the matching endpoint, and the client reads them from the inputSchema in tools/list. Lists accept limit (1 to 100, default 20) and the after and before cursors: pass the last id in after while has_more is true. The tools also declare whether they only read, whether they are destructive and whether they are idempotent (annotations), so the assistant can decide when to ask for confirmation.
Emails
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
send_email | mcp:send | Sends or schedules a transactional email (up to 50 recipients; HTML, text or a published template). | POST /emails |
list_emails | mcp:read | Lists emails, with filters by text, status, domain and dates. | GET /emails |
get_email | mcp:read | One email with its content, status and event timeline. | GET /emails/{id} |
cancel_scheduled_email | mcp:full | Cancels an email that is still scheduled. | POST /emails/{id}/cancel |
Domains
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
list_domains | mcp:read | Sending domains, with verification status and region. | GET /domains |
get_domain | mcp:read | One domain with its DNS records and what is wrong with each. | GET /domains/{id} |
Contacts and audience
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
list_contacts | mcp:read | Contacts, with filters by text, segment, topic and unsubscribed status. | GET /contacts |
get_contact | mcp:read | One contact by ID or email. | GET /contacts/{id} |
create_contact | mcp:full | Creates a contact, with segments and topics. Counts toward the plan's contact limit. | POST /contacts |
update_contact | mcp:full | Changes only the fields sent for a contact. | PATCH /contacts/{id} |
delete_contact | mcp:full | Deletes a contact and its memberships, for good. | DELETE /contacts/{id} |
list_segments | mcp:read | Static and dynamic segments, with their contact counts. | GET /segments |
list_topics | mcp:read | Subscription topics. | GET /topics |
list_contact_properties | mcp:read | Custom contact properties (key, type and fallback value). | GET /contact-properties |
Templates
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
list_templates | mcp:read | Templates, published or draft. | GET /templates |
get_template | mcp:read | One template by ID or alias. | GET /templates/{id} |
create_template | mcp:full | Creates a template; with publish: true it also publishes it. | POST /templates |
Broadcasts
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
list_broadcasts | mcp:read | Broadcasts, with status and stats. | GET /broadcasts |
get_broadcast | mcp:read | One broadcast with its audience, content and delivery stats. | GET /broadcasts/{id} |
create_broadcast | mcp:full | Creates a broadcast as a draft. Nothing is sent. | POST /broadcasts |
send_broadcast | mcp:full | Sends or schedules a broadcast. Requires confirm: true. | POST /broadcasts/{id}/send |
Automations and events
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
list_automations | mcp:read | Automations, with status and run counts. | GET /automations |
get_automation | mcp:read | One automation with its trigger and steps. | GET /automations/{id} |
track_event | mcp:send | Records an event for a contact, which can start automations (and send emails). | POST /events |
Metrics and usage
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
get_metrics | mcp:read | Sends, deliveries, opens, clicks, bounces and complaints for a period. | GET /emails/metrics |
get_usage | mcp:read | Plan, usage and limits, rate limit and whether sending is paused. | GET /usage |
Forms
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
list_forms | mcp:read | Signup forms, with submission counts. | GET /forms |
list_form_submissions | mcp:read | Submissions of a form: answers, contact and double opt-in. | GET /forms/{id}/submissions |
Inboxes
| Tool | Scope | What it does | API endpoint |
|---|---|---|---|
list_inboxes | mcp:read | The team's inboxes and the AI agent settings. | GET /inboxes |
list_inbox_threads | mcp:read | Conversations of an inbox, with filters by folder, label, unread, assignee and text. | GET /inboxes/{id}/threads |
get_inbox_thread | mcp:read | One conversation with all its messages and drafts (including the otp_token of received messages). | GET /inboxes/{id}/threads/{thread_id} |
update_inbox_thread | mcp:full | Moves, marks as read, assigns and labels a conversation. | PATCH /inboxes/{id}/threads/{thread_id} |
reply_to_inbox_thread | mcp:full | Replies to a conversation from the inbox address. Sends a real email. | POST /inboxes/{id}/threads/{thread_id}/reply |
create_inbox_draft | mcp:full | Saves a draft for a person to review and send in the dashboard. Does not send. | POST /inboxes/{id}/drafts |
What the assistant should not do on its own
send_email, send_broadcast and reply_to_inbox_thread reach real people and cannot be undone. The server's instructions tell the assistant to confirm recipients, sender, subject and content with you first. send_broadcast refuses the call with confirmation_required unless confirm is true. For a reply a person should review, use create_inbox_draft, which only saves the draft. To test without really sending, send to the test addresses ([email protected]…): nothing is delivered and no quota is used, but the events and webhooks are real.
#Resources and prompts
Besides the tools, the server offers ready-made context and recipes:
| Type | Name | What it is |
|---|---|---|
| Resource | postal://domains | The team's sending domains and the verification of each, as JSON (the first 100). Needs mcp:read. |
| Resource | postal://docs/quickstart | A short Markdown guide to how domains, sending, templates, contacts, broadcasts, automations, inboxes and errors fit together. |
| Prompt | write_and_send_campaign | Guides the assistant from the goal to a sent or scheduled broadcast, with your approval at each step. Arguments: goal (required), segment and send_at. |
| Prompt | diagnose_delivery | Investigates why an email did not arrive or went to spam: events, DNS records, metrics and limits, without sending anything. Arguments: email_id, recipient and domain. |
The prompts are titled “Escrever e enviar campanha” and “Diagnosticar entrega” (in Portuguese) in the client.
#Examples
An assistant makes these calls for you, but they are just JSON-RPC over HTTP, which helps to test the key and understand the responses. The Accept and MCP-Protocol-Version headers go with every call; the response is JSON. There is no need to open a session (initialize) first: each POST stands on its own.
List the tools
cURL
curl -X POST https://api.postal.so/mcp \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-11-25" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}'
Node.js
const response = await fetch('https://api.postal.so/mcp', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': '2025-11-25',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/mcp",
headers={
"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-11-25",
},
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('mcp', [
'headers' => [
'Authorization' => 'Bearer '.getenv('POSTAL_API_KEY'),
'Accept' => 'application/json, text/event-stream',
'MCP-Protocol-Version' => '2025-11-25',
],
'json' => [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/list',
],
]);
$data = json_decode($response->getBody(), true);
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "send_email",
"title": "Send email",
"description": "Sends (or schedules) a transactional email …",
"inputSchema": { "type": "object", "properties": { … }, "required": ["from", "to"] },
"annotations": { "title": "Send email", "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true }
},
…
]
}
}
With a sending-only key, the list has only send_email and track_event.
Send an email
The send_email tool accepts the fields of POST /emails, and idempotency_key plays the role of the Idempotency-Key header. The example sends to a test address, so nothing is delivered.
cURL
curl -X POST https://api.postal.so/mcp \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-11-25" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "send_email",
"arguments": {
"from": "Acme Store <[email protected]>",
"to": ["[email protected]"],
"subject": "Order shipped",
"html": "<p>Your order is on its way.</p>",
"idempotency_key": "order-1042-shipped"
}
}
}'
Node.js
const response = await fetch('https://api.postal.so/mcp', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': '2025-11-25',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'send_email',
arguments: {
from: 'Acme Store <[email protected]>',
to: ['[email protected]'],
subject: 'Order shipped',
html: '<p>Your order is on its way.</p>',
idempotency_key: 'order-1042-shipped',
},
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/mcp",
headers={
"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2025-11-25",
},
json={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "send_email",
"arguments": {
"from": "Acme Store <[email protected]>",
"to": ["[email protected]"],
"subject": "Order shipped",
"html": "<p>Your order is on its way.</p>",
"idempotency_key": "order-1042-shipped",
},
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('mcp', [
'headers' => [
'Authorization' => 'Bearer '.getenv('POSTAL_API_KEY'),
'Accept' => 'application/json, text/event-stream',
'MCP-Protocol-Version' => '2025-11-25',
],
'json' => [
'jsonrpc' => '2.0',
'id' => 2,
'method' => 'tools/call',
'params' => [
'name' => 'send_email',
'arguments' => [
'from' => 'Acme Store <[email protected]>',
'to' => ['[email protected]'],
'subject' => 'Order shipped',
'html' => '<p>Your order is on its way.</p>',
'idempotency_key' => 'order-1042-shipped',
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{\"id\":\"019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b\"}"
}
],
"structuredContent": {
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
},
"isError": false
}
}
The result comes in structuredContent (and also as text in content). Follow the email with get_email.
When the API refuses
An API error becomes a result with isError: true and the text name: message, with the JSON-RPC HTTP status 200. Here, the sender is not on a verified domain:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "validation_error: The outro.com domain is not verified. Please, add and verify your domain."
}
],
"isError": true
}
}
Credential and permission failures, on the other hand, are HTTP responses (see Errors).
#OAuth for MCP client developers
If you use a ready-made assistant you do not need this part. It is for those building an MCP client that wants to connect to Postal. The authorization server is the API itself (https://api.postal.so) and follows OAuth 2.1: the authorization code flow with PKCE, no other flows.
1. Discovery
GET /.well-known/oauth-protected-resource/mcp(RFC 9728) tells which authorization server to use and the resource's scopes. It also exists at the root.GET /.well-known/oauth-authorization-server(RFC 8414) lists the endpoints (authorization_endpoint,token_endpoint,registration_endpoint,revocation_endpoint), the scopes (mcp:read,mcp:send,mcp:full) andcode_challenge_methods_supported: ["S256"]. Both responses are cached for 1 hour.
2. Client registration
There are two ways, and neither needs manual sign-up:
- Dynamic registration (RFC 7591):
POST /oauth/register. Clients are limited per IP (30 per minute and 300 per hour), and the ones that never complete an authorization are deleted after 24 hours. - Client ID Metadata Document: use as
client_idan HTTPS URL that returns the client's JSON. Postal fetches the document without following redirects, with a 5-second timeout and up to 5 KB, never from a private network, and caches it for 1 hour.
The redirect_uris (up to 10) must be https, loopback (http://localhost, 127.0.0.1, [::1]) or a private-use scheme of a known desktop app (cursor, vscode, vscode-insiders, windsurf, zed), with no credentials or fragment. The authorization request must use one of them, exactly as registered. The default authentication method is client_secret_basic (the response includes a client_secret, once); with none the client is public and relies on PKCE alone.
cURL
curl -X POST https://api.postal.so/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My assistant",
"redirect_uris": ["https://assistant.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"scope": "mcp:read mcp:send"
}'
Node.js
const response = await fetch('https://api.postal.so/oauth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_name: 'My assistant',
redirect_uris: ['https://assistant.example.com/oauth/callback'],
token_endpoint_auth_method: 'none',
scope: 'mcp:read mcp:send',
}),
});
const data = await response.json();
Python
import requests
response = requests.post(
"https://api.postal.so/oauth/register",
json={
"client_name": "My assistant",
"redirect_uris": ["https://assistant.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"scope": "mcp:read mcp:send",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('oauth/register', [
'json' => [
'client_name' => 'My assistant',
'redirect_uris' => ['https://assistant.example.com/oauth/callback'],
'token_endpoint_auth_method' => 'none',
'scope' => 'mcp:read mcp:send',
],
]);
$data = json_decode($response->getBody(), true);
{
"client_id": "0198f3c2-7a41-7d0e-9b3a-5e6f7a8b9c0d",
"client_id_issued_at": 1793000000,
"client_name": "My assistant",
"redirect_uris": ["https://assistant.example.com/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
3. Authorization
Open the person's browser at GET /oauth/authorize. response_type=code, client_id, redirect_uri and PKCE with code_challenge_method=S256 are required. Also send resource (the MCP address, https://api.postal.so/mcp, or the origin https://api.postal.so), scope and state. Someone who is not signed in goes to the dashboard login and comes back; the consent screen shows the app, the return address and the permissions, and the person picks the team. The app does not pick the team: a team:… scope in the request is refused with invalid_scope. With prompt=none the server answers login_required or consent_required instead of showing screens.
https://api.postal.so/oauth/authorize?response_type=code&client_id=0198f3c2-7a41-7d0e-9b3a-5e6f7a8b9c0d&redirect_uri=https%3A%2F%2Fassistant.example.com%2Foauth%2Fcallback&scope=mcp%3Aread%20mcp%3Asend&state=<state>&code_challenge=<code_challenge>&code_challenge_method=S256&resource=https%3A%2F%2Fapi.postal.so%2Fmcp
Once the person allows, they return to the redirect_uri with code, state and iss (the issuer, RFC 9207: check it). If they refuse, they return with error=access_denied.
4. Tokens
Exchange the code at POST /oauth/token (body application/x-www-form-urlencoded), with the PKCE code_verifier and the same resource. Only the authorization_code and refresh_token grants exist.
curl -X POST https://api.postal.so/oauth/token \
-d grant_type=authorization_code \
-d client_id=0198f3c2-7a41-7d0e-9b3a-5e6f7a8b9c0d \
-d "redirect_uri=https://assistant.example.com/oauth/callback" \
-d code=<code> \
-d code_verifier=<code_verifier> \
-d "resource=https://api.postal.so/mcp"
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "<JWT>",
"refresh_token": "<refresh_token>"
}
- The access token is a 1-hour JWT, with the MCP audience (
https://api.postal.so/mcp) and theteam:<id>scope of the chosen team. A token that does not name this server as its audience is refused (401 invalid_token). - The refresh token lasts 30 days and is replaced on every use: always keep the new one, because the old one stops working.
- OAuth errors come in the RFC 6749 format (
{"error": "invalid_grant", "error_description": "…"}), not in the API error format.
curl -X POST https://api.postal.so/oauth/token \
-d grant_type=refresh_token \
-d client_id=0198f3c2-7a41-7d0e-9b3a-5e6f7a8b9c0d \
-d refresh_token=<refresh_token>
To disconnect, the app calls POST /oauth/revoke (RFC 7009) with the token (access or refresh) and the client credentials. This ends the app's whole connection to that team, as if the person revoked it in the dashboard, and answers 200 even for an unknown token.
The registration, token, revocation and metadata endpoints and /mcp accept calls from browsers (open CORS, no cookies). Limits: token, 60 per minute per client and IP; authorization, 30 per minute.
#Manage connections
Under Settings › AI connections you find the apps connected to the team, with who connected them, when, and the last use, plus the team's recent history of connections, denials and revocations. Revoking cuts the access right away; to use the app again, it has to be connected again.
- Each person revokes their own connections. Team owners and admins revoke anyone's.
- Each connection belongs to one person in one team: if the person leaves the team or the account is suspended, the tokens stop working.
- API keys do not appear there; they are managed under API keys.
#Limits
| What | Limit |
|---|---|
| Plan | mcp_access feature; without it, 403 plan_limit_reached |
| Requests | The same per-second limit as the team (Limits), counted separately from REST calls; each tool still counts as one API request. Above it, 429 rate_limit_exceeded |
| Transport | POST to /mcp only; GET and DELETE answer 405 (Allow: POST) |
| Lists | limit from 1 to 100 (default 20), cursor pagination |
| Recipients and scheduling | Those of the API: up to 50 recipients per email and scheduling up to 30 days ahead |
| OAuth access token | 1 hour |
| OAuth refresh token | 30 days, replaced on every use |
| OAuth client registration | 30 per minute and 300 per hour, per IP |
#Errors
Credential, plan and scope problems are HTTP responses; problems with a tool call (validation, quotas, sending pause…) come as a result with isError: true.
| Where | Name | When it happens |
|---|---|---|
401 | unauthorized | The token is missing. The response carries WWW-Authenticate with the resource metadata. |
401 | invalid_token | The OAuth token is invalid, expired, revoked or not for this server (audience), the person left the team or the account was suspended. Connect the app again. |
401 | invalid_api_key | The ps_… key does not exist or was deleted. |
403 | plan_limit_reached | The team's plan does not include the MCP server. |
403 | insufficient_scope | The OAuth connection lacks the scope of the tool called (JSON-RPC error.code -32001, with WWW-Authenticate and the missing scope). |
429 | rate_limit_exceeded | The team went over the requests-per-second limit. Wait for Retry-After. |
tool | restricted_api_key | A sending-only key called a tool that is not for sending. |
tool | validation_error | Invalid data, such as a sender outside a verified domain. |
tool | sending_paused | The team's sending is paused (too many bounces or complaints). |
tool | daily_quota_exceeded | The daily sending quota is used up. |
tool | confirmation_required | send_broadcast without confirm: true. |
tool | not_found | The ID does not exist in this team. |
The “tool” rows are an error inside a tools/call result (HTTP 200). The other names are those of the API; read the message and fix the cause instead of repeating the call blindly.
#See also
- Authentication and the API key types.
- SDKs and the CLI, to do from code or the terminal what the assistant does in conversation.
- The API's limits and errors, which also apply to MCP.
- Integrate your app and CRM connections, to bring your product and CRM data into Postal.