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
401andWWW-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 same401, witherror="invalid_token". - Session cookies are never accepted (
/mcpis 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
429andRetry-After, before any tool runs (conventions). The refusal is in the request log, under the call, not a tool. tools/listshows 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.jsonand 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 gets401: 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:
- The client calls
/mcpwithout a token and gets401with the protected resource metadata's address. - It reads
/.well-known/oauth-protected-resource/mcp(RFC 9728) and/.well-known/oauth-authorization-server(RFC 8414;/.well-known/openid-configurationanswers the same). - It registers at
/oauth/register(RFC 7591), unless itsclient_idis the https address of its client metadata document, which Mailroomly fetches instead. - It sends the person's browser to
/oauth/authorizewith 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. - It exchanges the code at
/oauth/tokenand calls/mcpwithAuthorization: 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"]}, oreveryone: truefor 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) orblocksin the brand's layout, withsubjectandpreheader. - The hold. A newsletter sent, or an automation turned on, by an agent waits 15 minutes: every admin gets an
AGENT_CAMPAIGN_STARTEDnotice with Cancel, and only a signed-in person can let it go earlier.send_newsletteranswers when it starts (holdUntil). - Sending tools are annotated
openWorldHint: true, so the client confirms them with the person.update_newsletterchanges drafts only (a scheduled one isSIGNED_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_READandSUPPRESSIONS_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_keyreturns the new key'ssecretonce, andget_webhook_secretthe 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_CREATEDnotice (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 getAGENT_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 withorganization:readwithlist_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 defaultfalse). 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
nullin their schema ("type": ["string", "null"]), as in a RESTPATCH: a field left out is unchanged,nullclears it. update_domain:inboundEnabled,trackingEnabled,catchAllMailboxId(nullclears it); fields left out are unchanged. WithinboundEnabled: falsethe domain only sends: its addresses stop receiving but keep their conversations anddirection(list_addressesshowsreceiving: false), and they resume when it is on again with its inbound MX record published.update_stream:streamId(fromget_domain'sstreams),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(BOTHby default; receiving needs inbound on the domain, otherwise useOUTBOUNDor turn inbound on withupdate_domain),displayName,signatureText,signatureHtml.update_addressaddsagentTimeoutMinutes(15, 30, 60 or 240;nullfor never) andnullclearsdisplayNameand the signatures. An address's app and inbound webhook are set in the web app.set_address_access:addressId,userId,permission(READorREPLY).list_address_peoplelists the organization's admins withpermission: ADMIN(they reach every address by their role) and the people given access;get_person_addressesanswers{ "allAddresses": true }for an admin, with theaddressesthey were given.create_app:name, optionalwebhookUrl,webhookFormat(NATIVE,POSTMARK,SENDGRIDorMAILGUN),webhookEvents.update_app: the same, fields left out unchanged,webhookUrl: nullremoves the webhook andwebhookEvents: nullsends the format's default events: every event butcanceled, andacceptedanddroppedonly forSENDGRIDandMAILGUN(webhooks); the webhook of an app that owns addresses, or has received their mail, isSIGNED_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), optionaldomainIds,streamTypes,mailboxIds,dailyRecipientLimit. The agent asks the person which addresses the key should reach first: withoutdomainIdsormailboxIdsit reaches every address, and only a signed-in person changes that later, in the web app.set_api_key_limit:dailyRecipientLimit,nullfor no limit of its own.send_test_message:appId,from(a sending address of a verified domain), optionalsubjectandtext; 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 ofname,status(ACTIVEorDISABLED); an admin's status isSIGNED_IN_ONLY.get_queue: what waits in the send queue per stream, and the cancels running (send queue).cancel_queued: optionalstreamId(fromget_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 untilDONE.get_plan_and_usage:days(default 30).list_alerts:open(defaulttrue).list_changes: the organization's audit log, newest first, each change a sentence (summary) with who acted:areas(a list ofTEAM,AGENTS,APPS,DOMAINS,ADDRESSES,INBOX,SENDING,CAMPAIGNS,CONTACTS,ORGANIZATION,PRIVACY; every area when left out),actor(person:<userId>,token:<id>,key:<id>,supportorsystem),from,to(ISO 8601),cursor(the previousnextCursor),size(1 to 100, 50 by default),lang(enorpt-PT; the person's own by default, else the request'sAccept-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":{}}}'