MCP server

AI agents use the platform through its Model Context Protocol server. The tools call the same services as the REST API and run as the caller's API key or personal token, so an agent has exactly the key's permissions and domain/stream/mailbox restrictions, or the token's user's mailboxes within its scopes — nothing more.

AI agents use the platform through its Model Context Protocol server. The tools call the same services as the REST API and run as the caller's API key or personal token, so an agent has exactly the key's permissions and domain/stream/mailbox restrictions, or the token's user's mailboxes within its scopes — nothing more.

Endpoint, transport and authentication

Endpoint https://<host>/mcp (locally http://localhost:8080/mcp)
Transport Streamable HTTP, stateless: JSON-RPC over POST, no session id, no server-initiated messages. Send Accept: application/json, text/event-stream.
Authentication Authorization: Bearer mp_live_… — an API key of an app, for an agent that works for the organization (Integrations → Connect an AI agent → For the organization) — or Authorization: Bearer mp_pat_… — a user's personal token, for an agent acting as that person (Settings → My agents; see REST) — or Authorization: Bearer mp_oat_… — an OAuth access token an agent platform got when the person connected it (Connecting with OAuth).
Server info name mailroomly, version 1.0.0; capabilities: tools, prompts
  • Without a key or token, every request (the handshake included) is rejected with HTTP 401 and WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource/mcp", which is how an agent platform finds out how to connect (Connecting with OAuth). A present but invalid, revoked or expired key or token gets the same 401, with error="invalid_token".
  • Session cookies are never accepted (/mcp is exempt from CSRF, so a cookie carries no authority there); the UI does not use MCP.
  • The per-key (per-token) limit of 100 requests per second applies.
  • A request of 32 KB or more takes its turn for room in memory, up to 10 seconds, and otherwise is refused with HTTP 429 and Retry-After, before any tool runs (conventions). The refusal is in the request log, under the call, not a tool.
  • tools/list shows each caller only the tools its permissions (or its token's scopes) allow.

Create a dedicated key per agent with only the permissions it needs, and restrict it to the relevant domains, stream types or mailboxes. For a person's own agent, the person creates a personal token instead: it reaches only their mailboxes.

Keep the key out of configuration files by reading it from an environment variable. The web app's samples use MAILROOMLY_API_KEY:

  • Claude Code: claude mcp add --transport http mailroomly https://<host>/mcp --header "Authorization: Bearer $MAILROOMLY_API_KEY" (the shell expands it when the server is added).
  • Cursor (.cursor/mcp.json): "Authorization": "Bearer ${env:MAILROOMLY_API_KEY}".
  • Claude Code's .mcp.json and other clients that expand ${VAR} in their configuration: "Authorization": "Bearer ${MAILROOMLY_API_KEY}". A client that does not expand variables sends the text as it is and gets 401: put the key itself in its configuration instead.

Connecting with OAuth

Agent platforms that connect remote MCP servers by signing the person in (Claude.ai and Claude Desktop custom connectors, ChatGPT connectors, Grok custom connectors, MCP Inspector, OpenCode, and any client of the MCP authorization spec) need only the server URL, https://<host>/mcp. Mailroomly is the OAuth 2.1 authorization server for it:

  1. The client calls /mcp without a token and gets 401 with the protected resource metadata's address.
  2. It reads /.well-known/oauth-protected-resource/mcp (RFC 9728) and /.well-known/oauth-authorization-server (RFC 8414; /.well-known/openid-configuration answers the same).
  3. It registers at /oauth/register (RFC 7591), unless its client_id is the https address of its client metadata document, which Mailroomly fetches instead.
  4. It sends the person's browser to /oauth/authorize with PKCE (S256). The person signs in to Mailroomly and, on Connect an agent, chooses what it may do and which addresses it handles, in the organization they work in.
  5. It exchanges the code at /oauth/token and calls /mcp with Authorization: Bearer mp_oat_….

The connection acts exactly as a personal token of that person with the scopes and addresses they allowed: same tools, limits, request log and audit. It is listed in Settings › My agents › Connected apps (kind: "OAUTH" in GET /api/v1/me/tokens), where the person disconnects it; it stops when the person is disabled, changes role, resets their password or leaves the organization, or when the organization turns personal tokens off, like a token. An administrator may allow the administration scopes (every admin gets ADMIN_TOKEN_CREATED), and the person is emailed whenever an agent connects.

Endpoints /oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke (RFC 7009)
Clients Public (token_endpoint_auth_method: none, the default) or confidential (client_secret_post, client_secret_basic); redirect URIs https, http://localhost / 127.0.0.1 on any port, or the app's own scheme
Grants authorization_code with PKCE S256 (required), refresh_token
Scopes The personal-token scopes (scopes_supported); unknown ones are ignored, and the person chooses on consent, so the token's scope says what was allowed
Resource https://<host>/mcp (RFC 8707 resource optional; another value is invalid_target)
Access token mp_oat_…, 1 hour, valid at /mcp only
Refresh token mp_ort_…, 30 days, rotated on every use. Using a rotated one again revokes the connection, except a single retry within 30 seconds while its replacement is unused
Codes One use, 60 seconds; a second use revokes the connection

Errors follow OAuth ({"error": "invalid_grant", "error_description": "…"}). Tokens and codes are stored as keyed hashes only. An OAuth access token is not accepted by the REST API.

Tools

Tool Purpose Required permission
send_message Send an email from a verified domain SEND
get_message A message with its event timeline MESSAGES_READ
search_messages Search the message log MESSAGES_READ
list_mailboxes Mailboxes the key can use MAILBOXES_READ
list_threads Conversations of a mailbox INBOX_READ
get_thread A conversation with every message's text INBOX_READ
update_threads Archive, report spam (or not) and, for a personal token, mark read or unread, up to 100 conversations INBOX_READ (archive and spam: reply access to the mailbox)
reply_to_thread Reply, reply-all or forward in a conversation INBOX_REPLY
compose_message Write a new email from a mailbox INBOX_REPLY
draft_reply Leave a reply for a person to approve INBOX_DRAFT or INBOX_REPLY
hand_over_thread Hand a conversation to the people of its address INBOX_DRAFT or INBOX_REPLY
claim_thread Say you are working on a conversation (claim it, or renew your claim) INBOX_DRAFT or INBOX_REPLY
release_thread Let go of your claim on a conversation INBOX_DRAFT or INBOX_REPLY
claim_next_thread Claim the conversation of a mailbox that has waited longest with nobody on it INBOX_DRAFT or INBOX_REPLY
list_suppressions Suppressed addresses SUPPRESSIONS_READ
add_suppression Suppress an address globally or on a stream SUPPRESSIONS_WRITE
remove_suppression Lift a suppression (not the platform's hard-bounce list) SUPPRESSIONS_WRITE
resend_message Send a transactional email again, to the same or corrected recipients SEND and MESSAGES_READ (listed only with both)
list_requests The caller's own API and MCP calls (every call with apps:manage), with their results Any key or token
get_request One logged call: its result, error and the message it sent Any key or token

A personal token that administers the organization also gets the administration tools, and a key or a token with campaign rights the campaigns tools.

Domain-restricted keys only see messages, mailboxes, threads and stream suppressions of their domains; they cannot add global suppressions. Mailbox-restricted keys only see those mailboxes, their threads and messages, and send_message only accepts one of them as from.

Personal tokens map their inbox scopes to these permissions: mailboxes:read → MAILBOXES_READ, inbox:read → INBOX_READ, inbox:reply → INBOX_REPLY, inbox:draft → INBOX_DRAFT. With those scopes a token gets at most list_mailboxes, list_threads, get_thread, reply_to_thread, compose_message, draft_reply, hand_over_thread, claim_thread, release_thread, claim_next_thread and update_threads (and list_requests, get_request for its own calls), on the mailboxes its user can access (and its own mailbox subset); replying and composing also need reply access to the mailbox. A token never sends app email (send_message). Stream-restricted keys can only send on their stream types — note that reply_to_thread always sends on the transactional stream.

The read tools (every tool annotated readOnlyHint: true: get_message, search_messages, list_mailboxes, list_threads, get_thread, list_suppressions, list_requests, get_request, and the administration tools that only read) each take one of the organization's reads per minute, shared with the REST API's GET requests and set by its plan (plans). Past it, the tool fails with READ_RATE_LIMITED: … Try again in <n> seconds.: wait that long, and prefer the organization's webhooks to polling. The other tools are not reads.

Every tool carries MCP annotations, so the agent's client knows which calls to confirm with the person:

Effect Annotations Tools
Reads readOnlyHint: true, idempotentHint: true get_* (but get_webhook_secret), list_*, search_messages
Hands out a secret (the client should confirm it) readOnlyHint: false, idempotentHint: true get_webhook_secret
Adds readOnlyHint: false, destructiveHint: false add_*, create_*, claim_starter_address, draft_reply
Sets a state (repeating changes nothing more) idempotentHint: true update_* (update_threads too), verify_domain, set_*, hand_over_thread, acknowledge_alert
Takes something away that can be given back destructiveHint: true, idempotentHint: true remove_address_access, update_person (it may disable a person), remove_suppression
Sends email or calls outside openWorldHint: true send_message, reply_to_thread, compose_message, email_dns_records, invite_person, resend_invitation, send_test_message, send_test_webhook, replay_webhooks

Every successful result has a text part (the result as a JSON string) and structuredContent (a JSON object; list results are wrapped as { "items": [...] }). Failures return isError: true with a text such as SCOPE_DENIED: This API key may not use this domain or VALIDATION_FAILED: Provide an HTML or a text body [html Provide an HTML or a text body].

send_message

Argument Type Notes
from string, required "Faturas <faturas@mail.cliente.pt>"; verified domain of the tenant, within the key's scope
to string[], required Recipients (with cc and bcc: at most 50)
cc, bcc string[]
subject string Required without a template
text, html string At least one, without a template
template, model string, object A template's alias, and the values of its {{fields}}, instead of a subject and a body
stream transactional | broadcast Default transactional
tags string[] Searchable in the log
idempotencyKey string The same key never sends twice for the app

Metadata, custom headers, attachments and reply-to are only available through the REST API.

{
  "id": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "status": "QUEUED",
  "submittedAt": "2026-09-27T10:15:30.412Z",
  "to": ["joao@exemplo.pt"],
  "suppressedRecipients": [],
  "replayed": false
}

replayed: true means the idempotency key had been used before and this is the original result. If every recipient is suppressed the tool fails with ALL_RECIPIENTS_SUPPRESSED.

send_message, reply_to_thread and compose_message are refused exactly as on the REST API, with isError: true and the code first in the text. The platform-sending and daily-limit refusals (all but TENANT_SENDING_PAUSED, STREAM_PAUSED and the two per-sender daily limits only from a domain sent through the platform; those from any domain):

Code Meaning for the agent
DAILY_LIMIT_REACHED The organization's daily recipient cap (or a starter address's 100 per day) is used up. It resets at midnight UTC: do not retry before.
USER_DAILY_LIMIT_REACHED Through a personal token: the person the token acts for reached their daily recipient limit (any domain). It resets at midnight UTC: do not retry before; a tenant admin can ask the platform team for a higher limit. Text: USER_DAILY_LIMIT_REACHED: <message>.
KEY_DAILY_LIMIT_REACHED With an API key: the key reached its own daily recipient limit (any domain). It resets at midnight UTC: do not retry before; a tenant admin can raise the key's limit. Text: KEY_DAILY_LIMIT_REACHED: <message>.
PLAN_VOLUME_REACHED The organization's plan has no extra emails and this month's are used up (any domain). Sending resumes on the day the error's message names (the organization's next month: the 1st, or the day of the month it began paying), or sooner on another plan: do not retry before; tell a person. Received mail keeps arriving.
BROADCAST_NOT_IN_PLAN stream: broadcast from an organization whose plan has no marketing sends. Send transactional mail, or tell a person.
BROADCAST_CONSENT_REQUIRED stream: broadcast before an admin of the organization accepted the terms of service, which include consent before marketing (in the web app, at their next sign-in). Tell a person; never retry.
TENANT_SENDING_PAUSED Platform admins paused the organization's sending, whatever domain it sends from. Do not retry; tell a person.
ORGANIZATION_RESTING The organization (Free) rested after 90 days without activity; this call woke it, and its sending is back within a few minutes. Retry in a few minutes.
STARTER_BROADCAST_NOT_ALLOWED stream: broadcast from a starter address; send transactional mail or use the organization's own domain.
ALL_RECIPIENTS_INVALID No recipient can receive mail: their domains take no mail or, for broadcast, are disposable-address services. Nothing was sent; do not retry with the same addresses.
BROADCAST_DMARC_REQUIRED stream: broadcast from a domain sent through the platform that has no DMARC record. Send transactional mail, or ask a person to publish the domain's DMARC record.
STREAM_PAUSED The stream was paused (by hand or automatically). Do not retry until it is resumed.

Recipients on the platform's hard-bounce list are reported in suppressedRecipients, like the organization's own suppressions — and so, from a domain sent through the platform, are recipients whose domain takes no mail and, for broadcast, those at a disposable-address service (the send goes to the others). Transactional mail, reply_to_thread and compose_message reach a disposable address (its domain must take mail).

get_message

Argument: id (string, required).

{
  "id": "01926a3e-7c1b-7d2e-9f40-6b1d2c3e4f50",
  "direction": "OUTBOUND",
  "status": "DELIVERED",
  "from": "faturas@mail.cliente.pt",
  "to": ["joao@exemplo.pt"],
  "subject": "A sua fatura",
  "createdAt": "2026-09-27T10:15:30.412Z",
  "tags": ["invoice"],
  "events": [
    { "type": "QUEUED", "recipient": "", "occurredAt": "2026-09-27T10:15:30.412Z" },
    { "type": "SENT", "recipient": "joao@exemplo.pt", "occurredAt": "2026-09-27T10:15:31.020Z" },
    { "type": "DELIVERED", "recipient": "joao@exemplo.pt", "occurredAt": "2026-09-27T10:15:33.120Z" }
  ]
}

search_messages

All arguments optional: recipient (exact recipient or sender address), tag, messageId (platform id, sending id or Message-ID header), status (one, e.g. DELIVERED, or several comma-separated, e.g. BOUNCED,COMPLAINED,FAILED), stream (TRANSACTIONAL, BROADCAST), direction (OUTBOUND, INBOUND), q (free text: subject, sender or recipient, over the last 90 days unless since is given; a whole address finds the mail to or from it exactly, at any time), since (ISO-8601), from (one sender address), appId, apiKeyId, page (0-based), size (default 25, max 100). Newest first. total counts up to 10,000, with totalCapped when more match.

{
  "items": [
    { "id": "01926a3e-…", "direction": "OUTBOUND", "status": "BOUNCED", "from": "faturas@mail.cliente.pt",
      "to": ["nao-existe@exemplo.pt"], "subject": "Endereço inválido", "createdAt": "2026-09-27T10:16:02.000Z", "tags": [] }
  ],
  "page": 0,
  "total": 1
}

resend_message

id (required): the message to send again. to (optional): the recipients this time — then they are its only recipients; left out, the original's To, Cc and Bcc. idempotencyKey (optional: the same key never sends twice, even when the original has no app). The copy keeps the original's In-Reply-To and References, so it stays in its email thread. Answers { "id", "status" } of the new send. Refused with RESEND_NOT_ALLOWED (received mail, a marketing send), RESEND_NOT_YET (still going out), RESEND_CONTENT_GONE (its content is no longer kept), ALL_RECIPIENTS_SUPPRESSED. Same rules as POST /api/v1/messages/{id}/resend.

list_requests

All arguments optional: result (ok or failed), channel (API or MCP), appId, since (ISO-8601), page, size. The caller's own calls, newest first, for the last 30 days — every call of the organization for a token with apps:manage. Each item: id, createdAt, channel, call (POST /api/v1/messages, or the tool's name), statusCode, errorCode, error (addresses taken out), durationMs, app, messageId. Use it to find out why calls failed.

get_request

id (required): one call from list_requests, with the same fields. See requests.

list_mailboxes

No arguments.

{
  "items": [
    { "id": "01926a30-…", "address": "suporte@mail.cliente.pt", "direction": "BOTH", "displayName": "Suporte Cliente" },
    { "id": "01926a31-…", "address": "marketing@mail.cliente.pt", "direction": "OUTBOUND", "displayName": "Cliente" }
  ]
}

list_threads

Argument Notes
mailboxId required
filter The boxes: INBOX (the default: everything received, not archived or spam), SENT (the mailbox wrote in it, by its latest send), ARCHIVED, SPAM; inside the Inbox: UNCLAIMED (awaiting an agent's reply with nobody on it: what you may claim), WITH_AGENT (an agent claims it and is working on it; claim.yours marks yours), NEEDS_YOU (a person's: a draft awaits approval, a person has it, or no agent claims it; see agents and people), MINE (what your token's person holds), NEEDS_REPLY (the latest message that counts came in, whoever has it), ANSWERED (it went out), UNREAD. Only mail a person wrote counts as coming in: an automatic reply, a delivery report, bulk mail or a no-reply sender carries a label (AUTOMATIC, BULK) instead, and spam is in SPAM. A reply that failed, was refused or suppressed does not count, nor one that bounced for every recipient (replyFailed); a forward counts.
q Searches the subject, the participants and the latest message's beginning
cursor Where the previous page ended (its nextCursor); omit for the first page
size Page size, default 25, max 100
{
  "items": [
    { "id": "01926a45-69ff-…", "subject": "Problema com a fatura", "participants": ["joao.pereira@exemplo.pt"],
      "snippet": "Olá, a fatura deste mês tem um valor diferente do habitual…", "lastActivityAt": "2026-09-27T09:58:12Z",
      "messageCount": 1, "unread": true, "handledBy": "AGENT", "lane": "WITH_AGENT", "pendingDraft": false,
      "label": "", "replyFailed": false, "archived": false, "spam": false, "latestDraftStatus": "" }
  ],
  "nextCursor": ""
}

Read state is a person's, so for API keys unread is true for every conversation with something to read (one only the mailbox wrote in has nothing) and UNREAD lists those; a personal token sees its person's.

update_threads

Argument Notes
threadIds required, up to 100
archived true archives, false moves back to the inbox (reply access to their mailboxes)
spam true moves them to the Spam box, false is not spam: their received messages count as a person's again (reply access)
read true marks read, false unread, for your token's person; an app's key has no read state and is refused

All or nothing: a conversation you cannot see makes the whole call fail with NOT_FOUND, and nothing changes.

{ "updated": 2 }

get_thread

Argument: threadId (required). Messages are in the order they reached the platform; text is the plain-text body (or the HTML converted to text), cut at 20,000 characters; attachments lists file names. Received messages also carry the inbound scan's spamVerdict and virusVerdict (PASS, FAIL, GRAY, PROCESSING_FAILED).

A message the inbound scan flagged as a virus (virusVerdict: FAIL) has neither text nor attachments: a notice replaces them, so an agent never acts on infected content.

handledBy is AGENT or PERSON (a person has the conversation: leave a draft instead of replying). claim is the agent working on it (agent, claimedAt, expiresAt, empty for no limit, active while it holds it, yours), or {} when none; list_threads gives it for every conversation. Reading a conversation does not claim it. latestDraft, when the conversation has one, says what became of its latest draft, whichever agent left it (list_threads gives its latestDraftStatus, empty when there is none): status PENDING, SENT (with edited and the messageId that went out), DISCARDED or SUPERSEDED, with its reason and decidedAt.

{
  "id": "01926a45-69ff-…",
  "subject": "Problema com a fatura",
  "handledBy": "AGENT",
  "messages": [
    { "id": "01926a45-6a00-…", "direction": "INBOUND", "status": "RECEIVED", "from": "joao.pereira@exemplo.pt",
      "to": ["suporte@mail.cliente.pt"], "subject": "Problema com a fatura", "createdAt": "2026-09-27T09:58:13.200Z",
      "tags": [], "spamVerdict": "PASS", "virusVerdict": "PASS",
      "text": "Olá, a fatura deste mês tem um valor diferente do habitual. Podem verificar?", "attachments": ["fatura.pdf"] },
    { "id": "01926a46-…", "direction": "INBOUND", "status": "RECEIVED", "from": "joao.pereira@exemplo.pt",
      "to": ["suporte@mail.cliente.pt"], "subject": "Re: Problema com a fatura", "createdAt": "2026-09-27T10:02:40.100Z",
      "tags": [], "spamVerdict": "PASS", "virusVerdict": "FAIL",
      "notice": "This message was flagged as containing a virus: its body and attachments are withheld." }
  ]
}

reply_to_thread

Argument Notes
threadId required
mode REPLY (default), REPLY_ALL, FORWARD
text, html The new content; the quoted original and the mailbox signature are added by the platform
to Required for FORWARD; for replies it defaults to the latest message's Reply-To addresses, or else its sender (reply-all also fills cc)
cc

The reply answers the latest message of the thread, from the thread's mailbox, on the transactional stream, never tracked (no open image, no rewritten link), with correct In-Reply-To/References. INBOX_REPLY alone is enough for an API key (the original is quoted without INBOX_READ; a personal token's inbox:reply scope always includes inbox:read, which it requires); the key's domain scope must include the mailbox's domain. The quote keeps the original's inline images (up to 2 MB each, while they fit the 10 MB limit; larger ones are left out of the quote — a reply drops them, FORWARD attaches them as regular files while they fit), and FORWARD attaches its files — except for a message flagged as a virus, whose files are never sent on.

{ "messageId": "01926a50-…", "threadId": "01926a45-69ff-…" }

While a person handles the conversation (handledBy: PERSON), the reply is refused with CONVERSATION_WITH_PERSON — so is compose_message or send_message mail that would thread into it: leave a draft with draft_reply instead. While another agent claims it, the reply is refused with CONVERSATION_CLAIMED. Your reply ends your claim.

draft_reply

The same arguments as reply_to_thread, plus reason (up to 280 characters, shown to the person, e.g. "Held because it offers a refund"). Instead of sending, it leaves the reply for a person to approve: they send it as is, edit it, or discard it, from the inbox. Use it when the reply needs a person's eye (money, commitments, a sensitive customer); with INBOX_DRAFT and no INBOX_REPLY, it is the only way the agent writes. A newer draft replaces your pending one, and the draft ends your claim; drafting is allowed while a person handles the conversation, not while another agent claims it. Follow the outcome with get_thread (latestDraft) or the conversation webhook.

{ "id": "01926a60-…", "status": "PENDING", "createdAt": "2026-10-11T10:00:00Z", "reason": "Held because it offers a refund" }

hand_over_thread

Argument Notes
threadId required
note Why, shown to the person (up to 280 characters)
assignee The email of the teammate to hand it to: one who may reply from the address (NOT_FOUND for anyone else); omit for anyone

Hands the conversation to the people of its address, or to one of them: it moves to their Needs you (and the assignee's Mine) with your note, and agents may not reply there (drafts stay allowed) until a person hands it back.

{ "threadId": "01926a45-69ff-…", "handledBy": "PERSON" }

Refused with CONVERSATION_CLAIMED while another agent claims the conversation; your hand-over ends your claim.

claim_thread, release_thread and claim_next_thread

Tool Arguments
claim_thread threadId (required), minutes
release_thread threadId (required)
claim_next_thread mailboxId (required), minutes

Claim a conversation before you work on it: it moves to the people's With the agent with your name, and other agents leave it alone. A conversation nobody claims stays in their Needs you, even where agents may answer, so people never wait on an agent that is not running. The claim lasts the address's agent wait (agentTimeoutMinutes, 30 minutes by default), or minutes when shorter; claim again to renew it. It ends when you reply, leave a draft, hand the conversation over or release it, when a person takes the conversation over, is assigned it or replies (your app gets a conversation webhook, claim_ended), and when its time runs out (the conversation shows the person that you didn't answer in time). claim_next_thread claims the conversation of the mailbox that has waited longest with nobody on it, so several agents can share one mailbox's queue without picking the same one; threadId is empty when nothing waits. Refused with CONVERSATION_WITH_PERSON while a person handles the conversation and CONVERSATION_CLAIMED while another agent claims it. Same rules as the REST API (claims).

{ "threadId": "01926a45-69ff-…", "subject": "Problema com a fatura", "lane": "WITH_AGENT",
  "claim": { "agent": "Helpdesk", "claimedAt": "2026-10-22T10:42:00Z", "expiresAt": "2026-10-22T11:12:00Z", "active": true, "yours": true } }

compose_message

Argument Notes
mailboxId required: a mailbox the caller can reply from
to required: recipients (with cc and bcc: at most 50)
cc, bcc
subject required
text, html At least one; the mailbox's signature is added and its display name is the sender name

A new conversation from the mailbox, on the transactional stream, exactly like composing in the web inbox (attachments are only available through the REST API).

{ "messageId": "01926a52-…", "threadId": "01926a52-…" }

list_suppressions

Arguments (optional): address (substring), reason (HARD_BOUNCE, INVALID_ADDRESS, SOFT_BOUNCE, COMPLAINT, UNSUBSCRIBE, MANUAL), page (50 per page).

With a complete address, and an organization that sends through the platform, the result also shows whether the platform's hard-bounce list holds it (the address bounced as non-existent for any organization): an item with reason PLATFORM_HARD_BOUNCE and scope GLOBAL, without its source. Only the platform team removes it. A partial address never lists such entries, and domain-restricted keys never see them.

{
  "items": [
    { "id": "01926a5f-…", "address": "antigo.cliente@exemplo.pt", "scope": "GLOBAL", "reason": "MANUAL", "createdAt": "2026-09-27T08:00:00Z" }
  ],
  "total": 1
}

add_suppression

Argument Notes
address required
scope required: GLOBAL (the whole tenant) or STREAM
streamId required for STREAM (stream ids are shown on the domain page and in GET /api/v1/domains/{id})
note Why
{ "id": "01926a60-…", "address": "maria@exemplo.pt", "scope": "GLOBAL" }

remove_suppression

id (required): a suppression's id from list_suppressions. Email to the address goes out again, as DELETE /api/v1/suppressions/{id} does: within the key's streams, NOT_FOUND otherwise. The platform's hard-bounce list is not lifted here (only the platform team removes it). Lifting a complaint or a hard bounce hurts the organization's deliverability: do it only for an address you know wants its email.

{ "id": "01926a5f-…", "removed": true }

get_my_access and get_person_access

get_my_access takes nothing and every caller has it: what you reach, in plain words (summary) and by name (domains, apps, addresses, canAdd, campaigns), what your token narrows it to (agentScopes), and what needs a person signed in (signedInOnly). It is the same answer as GET /api/v1/me/access. get_person_access (team:manage) gives another person's. Changing someone's access needs a person signed in (SIGNED_IN_ONLY).

A member's token never does more than the member: tools/list shows only what they can use, a domain or an app they don't run answers NOT_FOUND with where to look, and anything else answers OUTSIDE_YOUR_ACCESS, with who can change it.

Campaigns

Contacts, lists, sign-up forms, hosted images, templates, newsletters and automations, as in REST. An API key needs CONTACTS (contacts, lists, forms) or CAMPAIGNS (everything); a personal token campaigns:manage or campaigns:send.

Tool Purpose Needs
list_contacts, get_contact Contacts by list, state or a prefix of the address or name; one contact (by id or email) contacts
upsert_contact Add or update a contact by address, with optIn evidence or confirm: true (they confirm by email) contacts
remove_contact Remove a contact (its mail and suppressions stay) contacts
record_contact_event Record an app event for a contact, which starts the automations listening for it contacts
list_lists, create_list, update_list Lists, with how many can be emailed contacts
add_to_list, remove_from_list Put contacts on a list or take them off (by ids or addresses) contacts
list_forms, create_form Sign-up forms, with their link and embed code contacts
upload_image, list_images Host an image (base64 data, optimized on upload) and get its url campaigns
list_templates, get_template, create_template, update_template Templates, from blocks or your own HTML campaigns
preview_email Render blocks or HTML as a contact would get it (subject, HTML, text) campaigns
list_newsletters, get_newsletter, create_newsletter, update_newsletter Newsletters; creating answers the draft and its review campaigns
review_newsletter Who it reaches after suppressions, and what blocks it campaigns
send_newsletter_test A test to at most five addresses campaigns
send_newsletter Send now, at a time (at) or spread (spreadHours 1–24, or spreadOver as PT4H) campaigns and sending
pause_newsletter, resume_newsletter, cancel_newsletter While it goes out campaigns and sending
get_newsletter_results Delivered, opened, clicked, bounced, unsubscribed, the top links and the progress campaigns
list_automations, create_automation Automations: a trigger (joining a list, or an event) and send or wait steps campaigns
activate_automation, pause_automation Turn one on (includeCurrentMembers for the people already on the list) or off campaigns and sending
  • Lists by name. Every tool that takes lists accepts names or ids: create_newsletter {lists: ["List A", "List B"]}, or everyone: true for everyone who can be emailed.
  • Content is html (the final HTML; {{contact.first_name}} and {{unsubscribe_url}} work in it, and without an unsubscribe link and the postal address the standard footer is added) or blocks in the brand's layout, with subject and preheader.
  • The hold. A newsletter sent, or an automation turned on, by an agent waits 15 minutes: every admin gets an AGENT_CAMPAIGN_STARTED notice with Cancel, and only a signed-in person can let it go earlier. send_newsletter answers when it starts (holdUntil).
  • Sending tools are annotated openWorldHint: true, so the client confirms them with the person. update_newsletter changes drafts only (a scheduled one is SIGNED_IN_ONLY), and test sends are at most 10 a minute per organization.

A typical flow:

upload_image {name: "hero.jpg", data: "<base64>"}                     → {url}
create_newsletter {from: "news@cliente.pt", subject: "October news",
                   html: "<p>Hello {{contact.first_name}}…</p>", lists: ["List A", "List B"]}
                                                                        → {newsletter: {id, …}, review: {mailable: 1198, leftOut: 42, blockers: []}}
send_newsletter_test {id, to: ["me@cliente.pt"]}
send_newsletter {id, spreadHours: 4}                                    → {status: "SENDING", holdUntil: now + 15 min}
get_newsletter_results {id}

send_message also takes template (a template's alias) and model (the values of its {{fields}}) instead of a subject and a body.

Administering the organization

An organization admin's agent sets up and runs the organization with the admin's personal token, when the admin grants it one or more administration scopes (Settings → AI agents → Create token → Administration). A tenant admin can grant all of them; a member who runs some domains and apps can grant domains:manage, addresses:manage, apps:manage and suppressions:manage, and those of Campaigns they hold (400 VALIDATION_FAILED on scopes for anything else). The token still gets only what both the scopes and its user's access allow, read on every call. A role or status change of its user, or a password reset, ends the token as any other. How a person does it, step by step: the user manual's AI agents and Getting started.

Scope Permissions Tools
organization:read the plan, alerts, the audit log (never mail: reading messages is the inbox scopes', within the token's addresses) get_organization_overview, get_plan_and_usage, get_deliverability, list_alerts, acknowledge_alert, list_changes
domains:manage domains list_domains, get_domain, add_domain, claim_starter_address, update_domain, verify_domain, email_dns_records, update_stream, get_queue, cancel_queued, get_queue_cancellation
addresses:manage addresses list_addresses, create_address, update_address, list_address_people, set_address_access, remove_address_access
apps:manage apps and webhooks (keys that send) list_apps, create_app, update_app, get_webhook_secret, list_api_keys, create_api_key, set_api_key_limit, send_test_webhook, list_webhook_deliveries, replay_webhooks, send_test_message
team:manage people (members) list_people, get_person_addresses, invite_person, update_person, resend_invitation
suppressions:manage blocked addresses list_suppressions, add_suppression, remove_suppression
campaigns:manage contacts, lists, forms, images, templates, newsletter drafts and tests the campaigns tools but those that send
campaigns:send that, and sending newsletters and turning automations on every campaigns tool

The tools call the same services as the REST endpoints of the web app, with the same checks, limits and errors: an id of another organization is NOT_FOUND, a scope the token lacks is FORBIDDEN (and its tools are not listed), a domain that is not verified yet refuses addresses (VALIDATION_FAILED), and so on. The same token also works on those REST endpoints. A token's own addresses (its mailbox subset) narrow the conversations it reads and answers, not the addresses it manages.

  • What stays with a signed-in person. A token is answered 403 SIGNED_IN_ONLY: <what> needs a person signed in to Mailroomly…, ending with the manual's section on it on the server's own host (<public base URL>/docs/en/ai-agents#signed-in-only, e.g. https://app.mailroomly.com/docs/en/ai-agents#signed-in-only) for the agent to show the person, over REST as over MCP, for:

    • what cannot be undone: deleting a domain, an address (with its conversations) or an app, revoking an API key or someone's personal token, and renewing a webhook secret (these have no tool);
    • administrator rights: inviting an admin, promoting a member, and demoting, disabling or enabling an admin (which also ends that admin's tokens). A token invites members and disables or enables members;
    • mail: a key a token creates may only have SEND, SUPPRESSIONS_READ and SUPPRESSIONS_WRITE (the message log serves bodies and attachments), and a token never routes an address's mail: it cannot change the webhook (URL or events) of an app that owns addresses or has received their mail (a replay would send it again to the new URL), nor set an address's app or inbound webhook URL;
    • changing what an existing key reaches (its domains and addresses);
    • closing a notice about agents or keys (ADMIN_TOKEN_CREATED, AGENT_API_KEY_CREATED, AGENT_QUEUE_CANCELED, API_KEY_REACH_WIDENED);
    • a domain in the organization's own AWS account (the enterprise option); the dashboard's account tiles are not shown to a token either.

    So do privacy erasure, the terms, closing and exporting the organization, minting tokens and anything of the platform's.

  • Secrets. create_api_key returns the new key's secret once, and get_webhook_secret the app's webhook secret, as the web app shows them. The result tells the agent to hand them to the person and not repeat them.

  • Notices. When a token that administers is created, every admin of the organization gets an ADMIN_TOKEN_CREATED notice (and its email), so another admin can revoke a token they do not recognize; when such a token creates an API key, which works until revoked even after the token, they get AGENT_API_KEY_CREATED; when it cancels queued mail, AGENT_QUEUE_CANCELED. The platform's view of the organization marks such tokens (administers), never their scopes or secrets.

  • Audit. Every change is recorded in the audit log with the admin as the actor and the token it acted through (actor_personal_token_id), acknowledged notices, webhook replays and resent invitations included. Admins read it in Settings › Audit log, and an agent with organization:read with list_changes, where such a change reads Ana Silva, through the agent Claude.

  • Errors are in Mailroomly's words, as everywhere: no provider names, regions, account ids or resource names. A domain's DNS records name the hosts to publish, as in the web app.

Inputs

  • add_domain: name (required), inboundEnabled, trackingEnabled (both default false). It always sends through the platform; a domain in the organization's own AWS account is added in the web app.
  • claim_starter_address: slug, the organization's name in its free address (<slug>.<starter domain>). One per organization.
  • Fields an update may clear accept null in their schema ("type": ["string", "null"]), as in a REST PATCH: a field left out is unchanged, null clears it.
  • update_domain: inboundEnabled, trackingEnabled, catchAllMailboxId (null clears it); fields left out are unchanged. With inboundEnabled: false the domain only sends: its addresses stop receiving but keep their conversations and direction (list_addresses shows receiving: false), and they resume when it is on again with its inbound MX record published. update_stream: streamId (from get_domain's streams), trackOpens, trackClicks, paused.
  • email_dns_records: domainId, email; refused for a free address (DNS_MANAGED_BY_PLATFORM) and when every record is in place (DNS_RECORDS_IN_PLACE).
  • create_address: domainId, localPart (required), direction (BOTH by default; receiving needs inbound on the domain, otherwise use OUTBOUND or turn inbound on with update_domain), displayName, signatureText, signatureHtml. update_address adds agentTimeoutMinutes (15, 30, 60 or 240; null for never) and null clears displayName and the signatures. An address's app and inbound webhook are set in the web app.
  • set_address_access: addressId, userId, permission (READ or REPLY). list_address_people lists the organization's admins with permission: ADMIN (they reach every address by their role) and the people given access; get_person_addresses answers { "allAddresses": true } for an admin, with the addresses they were given.
  • create_app: name, optional webhookUrl, webhookFormat (NATIVE, POSTMARK, SENDGRID or MAILGUN), webhookEvents. update_app: the same, fields left out unchanged, webhookUrl: null removes the webhook and webhookEvents: null sends the format's default events: every event but canceled, and accepted and dropped only for SENDGRID and MAILGUN (webhooks); the webhook of an app that owns addresses, or has received their mail, is SIGNED_IN_ONLY.
  • create_api_key: appId, name, permissions (SEND, SUPPRESSIONS_READ, SUPPRESSIONS_WRITE; keys that read mail or answer addresses are a signed-in person's to give), optional domainIds, streamTypes, mailboxIds, dailyRecipientLimit. The agent asks the person which addresses the key should reach first: without domainIds or mailboxIds it reaches every address, and only a signed-in person changes that later, in the web app. set_api_key_limit: dailyRecipientLimit, null for no limit of its own.
  • send_test_message: appId, from (a sending address of a verified domain), optional subject and text; it goes to the token's person, and counts against their daily limit. send_test_webhook: appId.
  • invite_person: email, name; the person is invited as a member (admins are made by a signed-in person). update_person: userId, and any of name, status (ACTIVE or DISABLED); an admin's status is SIGNED_IN_ONLY.
  • get_queue: what waits in the send queue per stream, and the cancels running (send queue). cancel_queued: optional streamId (from get_queue; left out, everything queued but campaign copies and mail from an address that receives); it cannot be undone, and every admin is told. get_queue_cancellation: id (the cancellation's), to follow one until DONE.
  • get_plan_and_usage: days (default 30). list_alerts: open (default true).
  • list_changes: the organization's audit log, newest first, each change a sentence (summary) with who acted: areas (a list of TEAM, AGENTS, APPS, DOMAINS, ADDRESSES, INBOX, SENDING, CAMPAIGNS, CONTACTS, ORGANIZATION, PRIVACY; every area when left out), actor (person:<userId>, token:<id>, key:<id>, support or system), from, to (ISO 8601), cursor (the previous nextCursor), size (1 to 100, 50 by default), lang (en or pt-PT; the person's own by default, else the request's Accept-Language, else English). The same entries as the REST endpoint.

get_organization_overview answers where the setup stands, in the steps of Getting started (address, team, agent, and mail: mail received in the last 30 days), with nextSteps naming the tools for each step left, the plan and this month's emails, the limits and their use, the last 24 hours and the open notices.

The set_up_organization prompt

prompts/list offers it to a caller that administers (an API key or an inbox-only token gets an empty list). prompts/get with set_up_organization (optional argument domain: the organization's own domain) returns the order in which to set up an organization, naming the tools: the overview, the free address or the domain and its DNS records, the addresses, the team and their access, agents and apps, and a test. It asks the agent to confirm each change with the person and to leave deletions to them.

A typical onboarding: get_organization_overview → claim_starter_address (or add_domain, get_domain, email_dns_records, verify_domain) → create_address for support@, sales@, billing@ → invite_person and set_address_access → create_app and create_api_key for an app → send_test_message → get_deliverability once mail flows.

Adding the server to Claude Code

claude mcp add --transport http mailroomly https://<host>/mcp --header "Authorization: Bearer mp_live_…"

Then ask, for example, "List my mailboxes and summarize the unanswered conversations in suporte@".

With a personal token the command is the same with mp_pat_….

Generic MCP client

Most clients accept a JSON configuration like this (Claude Code's project file .mcp.json uses exactly this shape):

{
  "mcpServers": {
    "messaging": {
      "type": "http",
      "url": "https://messaging.example.com/mcp",
      "headers": { "Authorization": "Bearer mp_live_…" }
    }
  }
}

Cursor reads the same mcpServers shape from .cursor/mcp.json; a remote server there is just its url and headers (no type).

Keep the key out of version control (use the client's environment-variable expansion or secret store where available).

To test the endpoint by hand:

curl -s https://messaging.example.com/mcp \
  -H "Authorization: Bearer mp_live_…" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_mailboxes","arguments":{}}}'