Sign in Create account
Guides

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 example validation_error: … or sending_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 Authorization header.
  • 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.

AssistantSign-inHow to connect
ClaudeOAuthIn 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.
ChatGPTOAuthIn the apps and connectors settings (some plans need developer mode), create a connector with the URL and OAuth authentication.
GeminiOAuth or keyIn 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.
CursorOAuth or keyIn 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 CodeOAuth or keyRun 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 CodeOAuth or keyAdd 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.

Configuration with an API key

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.

OAuthAPI key
Best forThe 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 inThe 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.
PermissionsThe scopes the app asked for and the person accepted.Full access (full_access) becomes mcp:full; sending only (sending_access) becomes mcp:send.
RevokingIn 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.

ScopeName on the consent screenWhat it allowsTools
mcp:readRead your dataSee emails, domains, contacts, segments, topics, templates, broadcasts, automations, forms, inboxes, metrics and usage.22 read tools
mcp:sendSend emails and eventsSend and schedule emails from your verified domains and record events for your automations.send_email, track_event
mcp:fullManage everythingCreate, 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 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…" and the JSON-RPC body insufficient_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_key tool error. Note: in MCP, get_email and get_usage need mcp: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

ToolScopeWhat it doesAPI endpoint
send_emailmcp:sendSends or schedules a transactional email (up to 50 recipients; HTML, text or a published template).POST /emails
list_emailsmcp:readLists emails, with filters by text, status, domain and dates.GET /emails
get_emailmcp:readOne email with its content, status and event timeline.GET /emails/{id}
cancel_scheduled_emailmcp:fullCancels an email that is still scheduled.POST /emails/{id}/cancel

Domains

ToolScopeWhat it doesAPI endpoint
list_domainsmcp:readSending domains, with verification status and region.GET /domains
get_domainmcp:readOne domain with its DNS records and what is wrong with each.GET /domains/{id}

Contacts and audience

ToolScopeWhat it doesAPI endpoint
list_contactsmcp:readContacts, with filters by text, segment, topic and unsubscribed status.GET /contacts
get_contactmcp:readOne contact by ID or email.GET /contacts/{id}
create_contactmcp:fullCreates a contact, with segments and topics. Counts toward the plan's contact limit.POST /contacts
update_contactmcp:fullChanges only the fields sent for a contact.PATCH /contacts/{id}
delete_contactmcp:fullDeletes a contact and its memberships, for good.DELETE /contacts/{id}
list_segmentsmcp:readStatic and dynamic segments, with their contact counts.GET /segments
list_topicsmcp:readSubscription topics.GET /topics
list_contact_propertiesmcp:readCustom contact properties (key, type and fallback value).GET /contact-properties

Templates

ToolScopeWhat it doesAPI endpoint
list_templatesmcp:readTemplates, published or draft.GET /templates
get_templatemcp:readOne template by ID or alias.GET /templates/{id}
create_templatemcp:fullCreates a template; with publish: true it also publishes it.POST /templates

Broadcasts

ToolScopeWhat it doesAPI endpoint
list_broadcastsmcp:readBroadcasts, with status and stats.GET /broadcasts
get_broadcastmcp:readOne broadcast with its audience, content and delivery stats.GET /broadcasts/{id}
create_broadcastmcp:fullCreates a broadcast as a draft. Nothing is sent.POST /broadcasts
send_broadcastmcp:fullSends or schedules a broadcast. Requires confirm: true.POST /broadcasts/{id}/send

Automations and events

ToolScopeWhat it doesAPI endpoint
list_automationsmcp:readAutomations, with status and run counts.GET /automations
get_automationmcp:readOne automation with its trigger and steps.GET /automations/{id}
track_eventmcp:sendRecords an event for a contact, which can start automations (and send emails).POST /events

Metrics and usage

ToolScopeWhat it doesAPI endpoint
get_metricsmcp:readSends, deliveries, opens, clicks, bounces and complaints for a period.GET /emails/metrics
get_usagemcp:readPlan, usage and limits, rate limit and whether sending is paused.GET /usage

Forms

ToolScopeWhat it doesAPI endpoint
list_formsmcp:readSignup forms, with submission counts.GET /forms
list_form_submissionsmcp:readSubmissions of a form: answers, contact and double opt-in.GET /forms/{id}/submissions

Inboxes

ToolScopeWhat it doesAPI endpoint
list_inboxesmcp:readThe team's inboxes and the AI agent settings.GET /inboxes
list_inbox_threadsmcp:readConversations of an inbox, with filters by folder, label, unread, assignee and text.GET /inboxes/{id}/threads
get_inbox_threadmcp:readOne conversation with all its messages and drafts (including the otp_token of received messages).GET /inboxes/{id}/threads/{thread_id}
update_inbox_threadmcp:fullMoves, marks as read, assigns and labels a conversation.PATCH /inboxes/{id}/threads/{thread_id}
reply_to_inbox_threadmcp:fullReplies to a conversation from the inbox address. Sends a real email.POST /inboxes/{id}/threads/{thread_id}/reply
create_inbox_draftmcp:fullSaves 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:

TypeNameWhat it is
Resourcepostal://domainsThe team's sending domains and the verification of each, as JSON (the first 100). Needs mcp:read.
Resourcepostal://docs/quickstartA short Markdown guide to how domains, sending, templates, contacts, broadcasts, automations, inboxes and errors fit together.
Promptwrite_and_send_campaignGuides the assistant from the goal to a sent or scheduled broadcast, with your approval at each step. Arguments: goal (required), segment and send_at.
Promptdiagnose_deliveryInvestigates 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

POST /mcp (tools/list)

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);
200Response (abridged)
{
  "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.

POST /mcp (tools/call)

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);
200Response
{
  "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:

200Response
{
  "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) and code_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_id an 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.

POST /oauth/register

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);
201Response
{
  "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.

Authorization URL (example)
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.

Exchange the code
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"
200Response
{
  "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 the team:<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.
Refresh
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

WhatLimit
Planmcp_access feature; without it, 403 plan_limit_reached
RequestsThe 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
TransportPOST to /mcp only; GET and DELETE answer 405 (Allow: POST)
Listslimit from 1 to 100 (default 20), cursor pagination
Recipients and schedulingThose of the API: up to 50 recipients per email and scheduling up to 30 days ahead
OAuth access token1 hour
OAuth refresh token30 days, replaced on every use
OAuth client registration30 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.

WhereNameWhen it happens
401unauthorizedThe token is missing. The response carries WWW-Authenticate with the resource metadata.
401invalid_tokenThe 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.
401invalid_api_keyThe ps_… key does not exist or was deleted.
403plan_limit_reachedThe team's plan does not include the MCP server.
403insufficient_scopeThe OAuth connection lacks the scope of the tool called (JSON-RPC error.code -32001, with WWW-Authenticate and the missing scope).
429rate_limit_exceededThe team went over the requests-per-second limit. Wait for Retry-After.
toolrestricted_api_keyA sending-only key called a tool that is not for sending.
toolvalidation_errorInvalid data, such as a sender outside a verified domain.
toolsending_pausedThe team's sending is paused (too many bounces or complaints).
tooldaily_quota_exceededThe daily sending quota is used up.
toolconfirmation_requiredsend_broadcast without confirm: true.
toolnot_foundThe 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