MCP server
ReplyLayer ships a Model Context Protocol (MCP) server so an agent host can call the platform as tools instead of raw HTTP. The same tool registry backs two transports, so the tool set, names, and behavior are identical either way:
- Hosted — a streamable-HTTP endpoint at
POST /v1/mcpon the API. No install; authenticate with your API key as a Bearer token. Claude, ChatGPT and Meta Muse instead sign in (OAuth) atPOST /v1/mcp/oauth, the same handler listing every tool except the four an agent key can never use (see below). - stdio — the
replylayer-mcppackage (npm), run locally as a subprocess by your MCP client. Reads the API key from the environment or stored credentials.
Both expose the same 44 tools; a sign-in connection lists 40 of them. Every tool call returns an MCP CallToolResult;
on failure the result carries isError: true and a structured JSON envelope you
branch on (see The result envelope).
Connect over hosted HTTP
The hosted server is stateless: every POST /v1/mcp request is independent —
there is no session id, and GET/DELETE are not supported (with a valid key
they return 405 with an Allow: POST header; without one, 401). Authenticate
every request with Authorization: Bearer <api-key>; a missing or malformed Bearer
token returns 401. Authentication on /v1/mcp is a static Bearer key; this URL
has no OAuth flow and no OAuth discovery metadata.
Sign-in endpoint (/v1/mcp/oauth). The same stateless handler and tool set,
authenticated by OAuth 2.1 for three clients only: Claude, ChatGPT and Meta Muse.
Without a token it answers 401 with
WWW-Authenticate: Bearer resource_metadata="https://api.replylayer.ai/.well-known/oauth-protected-resource/v1/mcp/oauth", scope="mailboxes",
which starts the client's sign-in. Each connection acts as an agent key bound to
the mailboxes the user approved, so every agent-key limit applies, and
tools/list leaves out the four tools an agent key can never use: approve_review,
deny_review, get_account_usage and remove_suppression. Every tool listed
there carries securitySchemes (oauth2, scope mailboxes), top-level and
mirrored in _meta, and a tool error caused by the token being revoked or
expiring mid-request carries _meta["mcp/www_authenticate"]: the same
error="invalid_token" challenge plus
error_description="The ReplyLayer connection is no longer valid; sign in again",
so a client that signs in per tool can send the user back through sign-in;
/v1/mcp carries neither. The two URLs
never share credentials: an API key is refused on /v1/mcp/oauth, and an OAuth
token is refused on /v1/mcp and every REST route. Access tokens last one hour;
a connection ends after 30 days without use and at the latest 90 days after
consent, and the user can revoke it from the dashboard at any time. The
human-facing setup is in MCP server.
A representative MCP client config:
{
"mcpServers": {
"replylayer": {
"type": "streamable-http",
"url": "https://api.replylayer.ai/v1/mcp",
"headers": { "Authorization": "Bearer rly_live_..." }
}
}
}The hosted server does no implicit retry. If the API returns a rate-limit or
transient error, the structured error is surfaced to your tool result immediately
rather than the server sleeping through a Retry-After. Your agent owns the
retry decision — read details.retry_after / details.reset_at and decide (see
Errors).
Connect over stdio
Run the published replylayer-mcp package as a subprocess. It resolves the API
key from REPLYLAYER_API_KEY, or from credentials stored by replylayer-mcp init
(written under ~/.replylayer/). REPLYLAYER_API_URL optionally overrides the API
base (defaults to the public API).
{
"mcpServers": {
"replylayer": {
"command": "npx",
"args": ["-y", "replylayer-mcp"],
"env": { "REPLYLAYER_API_KEY": "rly_live_..." }
}
}
}Like the hosted transport, the stdio server does no implicit retry — a tool call fails fast and returns the structured error so the agent decides retry-vs-abort.
The result envelope
Every tool returns an MCP CallToolResult. On success, content[0].text is the
tool's JSON result (pretty-printed). On failure, isError: true and
content[0].text is a JSON string with this shape:
{
"error": "human-readable message",
"code": "STABLE_ERROR_CODE",
"details": { "...": "structured fields, when present" },
"hint": ["next-step line", "..."]
}Branch on code, not on the prose in error:
codeis the stable machine-readable error code. The full catalog lives in Errors.detailscarries structured fields when the API provides them — e.g. a rate-limit error carriesdaily_limit,sends_remaining, andreset_at; a capability/denial error carriesreason_axisand acheapest_next_step.hintis an array of plain-text next-step lines (the same guidance the CLI prints). It is advisory; the machine contract iscode+details.
Tool-side precondition failures (for example a capability probe that shows the
server cannot honor a has_attachment filter → SEARCH_OPERATOR_UNSUPPORTED)
use the same { error, code, details? } envelope, so you branch on code
uniformly whether the failure came from the API or from the tool itself.
Send tools return isError on a resolved send
send_email, reply_to_message, and send_draft can return isError: true even
when the HTTP call succeeded — because a send the scanner refused, or one held
by an infrastructure fault, is a failed tool call your host must not mark done.
The flag is derived from the governed email-effect on the send result:
email_effect.effect_status | isError | Meaning |
|---|---|---|
blocked | true | Content refused, terminal — edit or escalate; do not retry as-is. |
held_infrastructure | true | Transient infrastructure hold — retryable. Retry the same send later; do not rewrite the content. |
held_for_review | false | Accepted into governance / review — releasable, not a failure. |
sent | false | Dispatched. |
A held_for_review result leads with a HELD FOR REVIEW — not sent line (HELD — not sent for a quarantined send) that tells the agent to tell the account owner (the
person it acts for) the email is waiting for their approval, with the direct link from
hold_context.review_url and the deadline from hold_context.review_expires_at when the
server has them, and not to send it again. An infrastructure hold never gets that line or
the link: it is a failed call whose advice is to retry later. send_draft shapes its result the same
way as send_email and reply_to_message.
An unrecognized or future effect_status fails closed to isError: true — an
older server must not report an outcome it cannot interpret as success. For what
each effect status means and how holds resolve, see
Send gates. send_draft additionally surfaces its
send-time conflicts (rescan rejection, already-sent) as isError JSON keyed on
code, with the API details flattened to the JSON root.
Structured output
From replylayer-mcp 0.14.0, every tool except remove_suppression declares an
outputSchema and returns structuredContent (a machine-parseable object) alongside
the text block. The hosted endpoints (/v1/mcp, /v1/mcp/oauth) serve it once the
server is updated; a local server has it from 0.14.0. remove_suppression always
returns isError, so it has no success shape, and it is not listed on
/v1/mcp/oauth. Earlier versions declare an output schema only on
send_email, reply_to_message, send_draft, list_messages, read_message,
get_thread, list_threads, list_mailboxes, wait_for_message and
get_attachment_preview. Check the tool's outputSchema in tools/list; when a tool
has none, parse the text block.
structuredContentis the same JSON as the text block (for a held send, the text block after the leadingHELDline).- Each schema follows the API route's response, after the fields the tool leaves out (below). Objects accept fields the API adds later, enum-like values are declared as strings (known values are in the field description), and only the top-level fields the route always returns are required.
- A tool error (
isError: true) carries nostructuredContent, except a blocked or infrastructure-held send, which keeps its send result so you can reademail_effect. - Send results and message reads describe
hold_context.review_urlandhold_context.review_expires_aton a held send.
Fields MCP results leave out
MCP results carry what an assistant needs to act, not internal bookkeeping. On every
transport (stdio, /v1/mcp and /v1/mcp/oauth) the tools remove these fields from
the API response before building both the text block and structuredContent. The
REST API, the SDKs and the CLI still return them.
| Tool | Left out | Still there |
|---|---|---|
read_message, get_thread | reviewed_by_id; on each attachments[] entry hash, av_signature, av_scanned_at, content_disposition, raw_retention_reason, raw_deleted_at | reviewed_by_type, review_outcome; attachment filename, content_type, size, policy_action, sniffed_mime_type, declared_mime_type, mime_mismatch, content_id, av_verdict, stored, inline, raw_retention and every preview_* field |
delete_message | r2_objects_failed (storage keys) | r2_objects_failed_count in its place |
list_suppressions, add_suppression, list_inbound_blocklist, add_inbound_blocklist, list_allowlist, list_inbound_allowlist, add_inbound_allowlist_entry | added_by_actor_id | added_by_actor_type |
list_allowlist_blocked_attempts | actor_id | actor_type; aggregated rows are still one per recipient and sending key, so the same recipient can appear twice |
get_agent_quota | warmup.velocity_gate_mode | the rest of warmup |
get_attachment_preview | attachment.hash, preview.extractor | everything else |
list_mailboxes | scanner_policy, pii_redaction_config, last_applied_policy_mode, instruction_trust_mode, instruction_trust_strict_recipient, and the dashboard consent bookkeeping (*_accepted_at, *_accepted_version, attachment_reauth_at, attachment_policy_version, current_*_version, legacy_wildcard_active), which the API already omits for API keys and connections | id, name, address, display_name, effective_from_display, status, created_at, pii_mode, recipient_policy_mode, sender_policy_mode, hitl_mode, agent_send_policy, restricted_by, agent_send_containment, allow_thread_replies, agent_authoring_mode, default_subaddress_mode, attachment_exposure_mode, attachment_allowed_file_families, outbound_attachments.enabled |
The do-not-contact and inbound-blocklist source field names no vendor on MCP: a
bounce or complaint delivered by the sending provider reads provider_webhook, an
address found on the provider's own suppression list at send time reads
provider_sync, and any value the server does not recognise reads system.
customer, one_click_https and mailto_inbound are unchanged, and reason still
says why the entry exists.
A 400 VALIDATION_ERROR keeps details.validation, but each item carries only
instancePath (the field) and message. The top-level error already names the
first failing field and, for an enum, its allowed values. A
500 SELF_HOSTED_DECRYPT_FAILED keeps details.endpoint (your mail host) and leaves
out details.domain_id.
next_cursor on the list tools is an opaque paging token: pass it back unchanged as
cursor and do not parse it or show it to a user.
list_drafts returns has_more beside drafts; when it is true, pass the last
draft's id as before for the next page.
Server instructions
Both transports send the same server-level instructions primer: the stdio
server and the hosted /v1/mcp server. It is a short workflow guide (branch on
message status, recovery paths for held mail, how to read a rate-limit error)
that a host can inject into the model context at connect time. The sign-in endpoint
/v1/mcp/oauth sends the same primer with its API-key wording reworded for a
connection: there is no API key, a revoked or expired connection means asking the
user to reconnect, and the note on held-for-review mail says the connection (not an
agent key) cannot approve. For held mail the primer tells the model to tell the account
owner the send is waiting for them, give them hold_context.review_url, and not
send it again.
Some hosts ignore server instructions (Hermes does), so do not depend on the primer
being in context. The per-tool description fields are self-contained and carry
what a model needs to call each tool correctly.
Tool registry (44 tools)
From replylayer-mcp 0.14.0, every tool except remove_suppression declares an
outputSchema and emits structuredContent (see
Structured output). Every tool declares a Title and explicit boolean
readOnlyHint, destructiveHint and openWorldHint annotations, plus
idempotentHint on every tool that changes something; the Annotations column
lists the hints that are true. One tools/list serves every client, so where client
guidelines differ the stricter reading wins: a tool that modifies account data is
destructive even when it can be undone (the list and marker writes), and a tool that
can email someone outside the account is openWorld. Annotations are advisory
metadata for the host — the authoritative permission gates are enforced server-side.
Messages & threads
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
send_email | Send email | Send a new email from a mailbox, or continue a thread with thread_id; returns status, scan verdict, and the governed email-effect. | destructive, openWorld |
reply_to_message | Reply to message | Reply to an inbound message from the mailbox that received it, threading correctly. | destructive, openWorld |
list_messages | List messages | List / search messages in a mailbox (search, sender, date, direction, status, starred, has_attachment); page older via before. | readOnly |
read_message | Read message | Read one message by ID: plaintext body projection, attachment metadata/status, scan results. Side-effect-free (does not advance read state). | readOnly |
wait_for_message | Wait for message | Long-poll a mailbox; returns when a new message arrives or the timeout expires. | readOnly |
get_thread | Get thread | Read a full thread as one ordered conversation (oldest → newest, safe view). | readOnly |
list_threads | List threads | List conversation threads (most-recent first) with participants, unread_count, last_message_at. | readOnly |
mark_message_read | Mark message read | Mark one inbound message read (outbound / deleted / firewall-blocked rows are a 200 no-op). | destructive, idempotent |
mark_thread_read | Mark thread read | Bulk-mark every visible inbound unread message in a thread read; returns marked_count. | destructive, idempotent |
star_message | Star or unstar message | Star or unstar a message. | destructive, idempotent |
star_thread | Star or unstar thread | Star or unstar a thread. | destructive, idempotent |
delete_message | Delete message | Permanently delete a message (any direction, deletable state); purges raw bytes + derivatives. Refuses draft / scheduled / dispatching rows; agent keys require the account's agent-deletion policy. | destructive, idempotent |
get_attachment_preview | Get attachment preview | Get a derived text preview of an inbound attachment (text, CSV, PDF, Office); capped in length. Raw bytes are not available via MCP. | readOnly |
Mailboxes & account
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
list_mailboxes | List mailboxes | List account mailboxes (name, full address, status). | readOnly |
get_account_usage | Get account usage | Get account usage, limits, and storage-quota breakdown. | readOnly |
get_agent_quota | Get send quota | Get the send-budget quota (sends today, effective daily limit, remaining, reset time, bound mailboxes); works with agent-scoped keys. | readOnly |
get_link_scanning_status | Get link scanning status | Read malicious link scanning (URL reputation) activation status. Enabling it is a human/admin action, not an agent one. | readOnly |
Drafts & scheduled send
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
create_draft | Create draft | Create a draft (the scanner runs synchronously and attaches a verdict); pass send_at to schedule dispatch. | destructive, openWorld |
update_draft | Update draft | Update a draft and re-scan; set/clear sub-address and send_at fields; clear_send_at cancels a scheduled send. | destructive, openWorld |
get_draft | Get draft | Read one draft by ID (body, scan results, schedule + sub-address fields). | readOnly |
list_drafts | List drafts | List drafts in a mailbox. | readOnly |
delete_draft | Delete draft | Soft-delete a draft (hides it from list_drafts; cancels any schedule). | destructive, openWorld |
send_draft | Send draft | Send a draft; the scanner re-runs authoritatively at send time. Distinct send-time conflicts surface as isError JSON. | destructive, openWorld |
Recipients
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
list_recipients | List recipients | List recipients with confirmation status. | readOnly |
add_recipient | Add recipient | Sandbox only. Add a person to the account's recipient list: they are emailed a confirmation link and become sendable when they click it. An attest argument adds the person without confirmation, using one of the trial's limited attestations; when none are left the call is refused with TIER_LIMIT, and when agent recipient writes are unavailable with INSUFFICIENT_SCOPE. A sign-in connection that isn't allowed to add recipients should ask the user to add the person. | destructive, openWorld |
Do-not-contact list
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
list_suppressions | List do-not-contact entries | List do-not-contact entries (system-added + customer-added; exact email or @domain). | readOnly |
add_suppression | Add do-not-contact entry | Add an exact email or @domain to the do-not-contact list. One-way on MCP — removal is not exposed. | destructive, idempotent |
add_suppressions_bulk | Add do-not-contact entries in bulk | Bulk-add up to 1000 do-not-contact entries; returns a partial-success added / already_existed / invalid response. | destructive, idempotent |
remove_suppression | Remove do-not-contact entry (unavailable) | Always returns isError — removal is intentionally not available via MCP; use the CLI, an SDK admin key, or the dashboard. | readOnly |
Suppression removal is a per-human admin decision by design. See the Do-not-contact guide for the full add / list / remove contract.
Recipient allowlist (outbound)
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
list_allowlist | List recipient allowlist | List a mailbox's recipient allowlist (only consulted in allowlist mode). The response's always_allowed array lists the four ReplyLayer test addresses, which are allowed without an entry unless the do-not-contact list suppresses them. Agents cannot mutate the allowlist via MCP — mutation is admin-only. | readOnly |
list_allowlist_blocked_attempts | List allowlist-blocked sends | Inspect sends rejected by the recipient allowlist (aggregated summary by default; aggregate=false for raw history). | readOnly |
Inbound firewall
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
list_inbound_blocklist | List inbound blocklist | List the account-wide inbound sender blocklist. | readOnly |
add_inbound_blocklist | Add to inbound blocklist | Add an exact email or @domain to the inbound blocklist (one-way on MCP). | destructive, idempotent |
add_inbound_blocklist_bulk | Add to inbound blocklist in bulk | Bulk-add up to 1000 inbound blocklist entries; partial-success response. | destructive, idempotent |
list_inbound_allowlist | List inbound allowlist | List a mailbox's inbound sender allowlist. | readOnly |
add_inbound_allowlist_entry | Add to inbound allowlist | Add a sender (email or @domain) to a mailbox's inbound allowlist. | destructive, idempotent |
add_inbound_allowlist_bulk | Add to inbound allowlist in bulk | Bulk-add up to 1000 inbound allowlist entries; partial-success response. | destructive, idempotent |
list_inbound_firewall_blocked_attempts | List firewall-blocked senders | List incoming senders the inbound firewall rejected (aggregated by default; aggregate=false for raw history). | readOnly |
release_firewall_blocked_message | Release firewall-blocked message | Release a firewall-blocked message back into scanner processing (returns 202; re-read the message for the verdict). Mailbox-bound keys are limited to their mailbox. | destructive |
Triage & review
| Tool | Title | Purpose | Annotations |
|---|---|---|---|
release_quarantined_message | Release quarantined message | Release a quarantined inbound message back to available. Refuses outbound rows. Mailbox-bound keys are limited to their mailbox. | destructive |
block_quarantined_message | Block quarantined message | Block a quarantined inbound message (terminal, no delivery). Refuses outbound rows. | destructive |
report_and_block | Report and block sender | Block a held inbound message and add its resolved sender to the account-wide inbound blocklist (idempotent). Refuses outbound rows. | destructive, idempotent |
approve_review | Approve message held for review | Approve a pending_review message and dispatch it. Admin keys + dashboard sessions only — agent keys get 403. | destructive, openWorld |
deny_review | Deny message held for review | Deny a pending_review message (terminal block, no dispatch). Admin keys + dashboard sessions only — agent keys get 403. | destructive |
Where contracts live
This page owns the MCP transport, tool registry, and result-envelope contract. For the platform contracts a tool references, see the owning page:
- Message states, read semantics, and verdict vocabulary → Messages
- What happens when a send is held, blocked, or contained → Send gates
- Full error-code catalog → Errors
- Attachment staging and preview lifecycle → Attachments
- Tier and sandbox send limits → Limits
- Per-field trust and untrusted-content handling → Security model
- Webhook event catalog (dispatch, review, schedule signals) → Webhooks
- CLI equivalents and exit codes → CLI
The replylayer-mcp package is published on
npm.