replylayer-mcp
The MCP server for ReplyLayer — safe email for AI agents. It exposes ReplyLayer's
email surface (send, reply, read, monitor, release) as MCP tools so an MCP client
(Claude Desktop, Cursor, …) can drive transactional and operational email with built-in
security scanning. Not a bulk or marketing tool.
Server identity: replylayer v0.13.0. 44 tools. The hosted server
(POST /v1/mcp on api.replylayer.ai) reports the same identity and injects the
same server-level workflow primer as this binary — both read the version from this
package, so neither can drift from it. The hosted sign-in endpoint
(POST /v1/mcp/oauth) lists all of them except the four in
AGENT_UNAVAILABLE_TOOL_NAMES: a sign-in connection is an agent key, which can never
use those four, and its primer describes the connection instead of an API key. Its
tools also carry OAuth linking metadata (securitySchemes, top-level and in _meta, and
_meta["mcp/www_authenticate"] on a tool error caused by a revoked or expired token).
Install
Published to npm as replylayer-mcp. Most MCP clients launch it via npx (no
global install needed) — see Client config. To run or
install it directly:
npx -y replylayer-mcp
Credentials
Auth is a single ReplyLayer API key. The server resolves it from the
REPLYLAYER_API_KEY environment variable first, then from the stored credentials file
at ~/.replylayer/credentials. Production (https://api.replylayer.ai) is the default
endpoint; REPLYLAYER_API_URL is a testing-only override.
Set up the stored credential interactively (writes ~/.replylayer/credentials,
mode 0600):
replylayer-mcp init
For CI / automation, skip init and pass REPLYLAYER_API_KEY in the environment.
A key only works once a human has finished the account's one-time signup verification:
confirming the account's email address and then its mobile number (a 6-digit SMS code).
Web signup at https://app.replylayer.ai/signup walks through both before any key is
created. A key returned by rly signup stays inert until rly auth verify and
rly auth verify-phone succeed; until then calls fail with EMAIL_NOT_VERIFIED or
PHONE_NOT_VERIFIED. The server runs with a key a human has already provisioned and
verified.
Client config (copy-paste)
Claude Desktop (claude_desktop_config.json) or Cursor (mcpServers) — launch via
npx so there's no global install or absolute path to manage:
{
"mcpServers": {
"replylayer": {
"command": "npx",
"args": ["-y", "replylayer-mcp"],
"env": {
"REPLYLAYER_API_KEY": "rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z"
}
}
}
}
If you ran replylayer-mcp init to store the key, the env block is optional. With a
global install (npm i -g replylayer-mcp), use "command": "replylayer-mcp" and drop
the args.
The directional safety model
Every message is security-scanned, and scanning is directional:
- Outbound text that resembles prompt-injection is your agent's own authored content —
delivered clean. The secrets scanner is outbound-only.
- Inbound prompt-injection is quarantined.
The send tools (send_email, reply_to_message, send_draft) return an outcome —
branch on the outcome, not on success/failure. The discriminator is
email_effect.effect_status: sent | held_for_review | held_infrastructure | blocked
(the Governed Email Effect contract). Treat an
effect_status you do not recognize as held, never as sent. Where email_effect is
absent, fall back to the top-level status: sent | quarantined | pending_review | blocked. sent means ReplyLayer accepted the message for sending — it is not proof
the recipient received it.
Those send responses also carry the why inline: scan (the scanner
verdict — inspect scan.verdict, scan.findings, and scan.findings[].agent_instructions)
and hold_context (the hold reason plus agent_instructions on held sends — a typed
policy cause, a policy change to the scanner's decision, or a genuine scan-explained
hold; null on sent/terminal outcomes and normally on infrastructure holds). Call
read_message for full detail. A finding with
failure_class: "inference_error" is an infrastructure error, not a content judgment —
retry later, do not rewrite.
Recovery: inbound quarantined → release_quarantined_message; firewall_blocked →
release_firewall_blocked_message; pending_review needs human approval (agent keys
cannot approve — escalate). RATE_LIMITED → if details.reason is
failed_authentication, the API key is wrong or revoked: stop and ask a human to fix the
key, do not retry; if details.reset_at is present it is the daily send quota (back off
until then); otherwise it is a short-window limit — wait for details.retry_after (or
the time in the error message), then retry. UNAUTHORIZED / API_KEY_REVOKED → the key
is wrong or revoked: stop and ask a human, do not retry.
RECIPIENT_SUPPRESSED / BILLING_* / EMAIL_NOT_VERIFIED → escalate, do not retry.
This server does no implicit retry (neither on 5xx nor on a 429 Retry-After):
every tool call fails fast and returns the structured error, so the agent owns retry
timing and a tool call is never held open by a silent server-side wait.
Idempotent sends
send_email and reply_to_message take an optional idempotency_key. Pick one stable
key per send or reply intent and reuse that exact key for every retry of that intent: a
replay returns the original outcome instead of sending a second message. The record is
permanent. On IDEMPOTENT_REQUEST_IN_FLIGHT, retry the same key after the indicated
delay. On IDEMPOTENT_REQUEST_NOT_PROVEN_SENT, stop and report it for investigation —
the message was not re-sent, and minting a new key could duplicate it.
create_draft's idempotency_key is a different thing: it is valid only alongside
send_at (scheduled sends) and replays for 24 h, not permanently.
Trusted instruction sources
By default every inbound read carries the standing untrusted-content contract:
agent_safety_context.untrusted_content: true plus a guidance string telling the
agent not to follow instructions embedded in the message body. Relaxing that on a given
read is entirely server-side and operator-configured — this server has no client-side
opt-in or env var for it. The server relaxes a read only when the customer has (a) put
the mailbox into instruction_trust_mode: 'enabled', (b) enabled this API key's
instruction-trust capability, and (c) designated that exact sender address (address-grain
only — no domain-wide trust) as a trusted instruction source, and the message itself
is verified_aligned (Mailgun-verified domain authenticity), clean-scanning, and
available from that sender. When all of that lines up, guidance is replaced with
trusted-instruction guidance (the agent may act on
that verified sender's own explicit requests in this message) and a thin
instruction_trust basis object is attached — untrusted_content stays true. This is
read-relaxation only: it never changes how an outbound send is gated.
Default is off end to end: no operator configuration → no relaxation → byte-identical
legacy reads. There is currently no MCP tool to grant, list, or revoke trusted sources —
see the note at the end of Tool surface.
Tool surface (44 tools)
Reads (no side effects): list_messages (search + filters + paging), read_message,
get_thread, list_threads, wait_for_message,
list_mailboxes, list_recipients, list_suppressions, list_allowlist,
list_allowlist_blocked_attempts, list_drafts, get_draft, list_inbound_blocklist,
list_inbound_allowlist, list_inbound_firewall_blocked_attempts, get_account_usage,
get_agent_quota, get_attachment_preview, get_link_scanning_status.
get_link_scanning_status reports whether malicious link scanning (URL reputation, backed by
Google Web Risk) is active for the account. Turning it on is an admin action (it enables an
account-wide sub-processor data flow), so the MCP surface is read-only here — enable it from the
dashboard, the SDK, or rly account link-scanning enable.
Sends (dispatch outbound mail): send_email, reply_to_message, send_draft.
The high-traffic tools — the 6 reads (list_messages, read_message, get_thread,
list_threads, list_mailboxes, wait_for_message) and the 3 sends (send_email,
reply_to_message, send_draft) — now return structuredContent (a typed object you can
consume without re-parsing the text blob) alongside the text block, with declared
outputSchemas. get_attachment_preview also returns structuredContent.
Drafts: create_draft, update_draft, delete_draft (plus send_draft above).
Inbound triage: release_quarantined_message, block_quarantined_message,
report_and_block (block + blocklist the sender, inbound-only),
release_firewall_blocked_message. Human-in-the-loop (admin-only; agent keys 403):
approve_review, deny_review.
Delete: delete_message — soft-deletes a message (any direction) and purges its
raw MIME; agent keys require the account delete policy to be enabled (else 403).
Policy / contacts: add_recipient, add_suppression, add_inbound_blocklist,
add_inbound_allowlist_entry, mark_message_read, mark_thread_read.
Star / unstar: star_message (toggle a message's starred flag),
star_thread (toggle a thread's starred flag).
Bulk policy mutations: add_inbound_allowlist_bulk (up to 1000 sender addresses for a
mailbox's inbound allowlist in one call), add_suppressions_bulk (up to 1000 addresses to
the account-wide do-not-contact list), add_inbound_blocklist_bulk (up to 1000 addresses
to the account-wide inbound blocklist).
Policy stub (intentionally unavailable): remove_suppression — always returns an
isError result explaining that suppression removal is a per-human admin decision; use
rly suppressions remove, an SDK admin key, or the dashboard instead.
Not yet a tool: trusted-instruction-source management (grant / list / revoke, plus the
mailbox mode and per-key capability toggles — see above)
has no MCP wrapper today. That includes the revoke endpoint, which — like the inbound-triage
release tools — is unprivileged and agent-key-callable server-side; it just isn't exposed
here yet. Manage trusted sources from the ReplyLayer dashboard (or the REST API directly)
in the meantime.
Tool annotations
Every tool declares a title and explicit boolean readOnlyHint, destructiveHint
and openWorldHint; every tool that changes something also declares idempotentHint.
One tools/list serves every client, so where client guidelines differ the stricter
reading wins. The hints are advisory for the host. The API enforces the permission gates.
| Reads | the 19 read tools listed above | true | false | false | not declared |
| Sends | send_email, reply_to_message, send_draft, approve_review | false | true | true | false |
| Scheduled sends | create_draft, update_draft (a send_at dispatches with no further call) | false | true | true | false |
| Irreversible state changes | delete_draft, deny_review, block_quarantined_message, release_quarantined_message, release_firewall_blocked_message | false | true | false | false |
| Irreversible, safe to repeat | delete_message (a re-delete succeeds), report_and_block (a re-report changes nothing further) | false | true | false | true |
| List and marker writes | add_suppression, add_suppressions_bulk, add_inbound_blocklist, add_inbound_blocklist_bulk, add_inbound_allowlist_entry, add_inbound_allowlist_bulk, mark_message_read, mark_thread_read, star_message, star_thread | false | true | false | true |
| Recipient write | add_recipient (emails the recipient a confirmation link; with attest, sends nothing but makes the person sendable) | false | true | true | false |
| Stub | remove_suppression (always an error) | true | false | false | not declared |
Tool notes
add_recipient — wraps POST /v1/recipients (free Sandbox accounts only; other
tiers get 403 FORBIDDEN, and on a paid plan the description tells the agent that a
mailbox restricted to people the account owner approves needs the user to approve
the person for that mailbox). Input: email, optional attest: boolean. Without
attest the person is emailed a confirmation link and becomes sendable when they
click it. With attest: true no email is sent and the person is sendable at once: the
account vouches for them, spending one of its per-trial attestations (never refunded),
and the result carries attestation: "account_attested" and a complaint_notice. The
description tells the agent to attest only when the user has said they know the
person. If someone added that way reports the account's email as spam, the account
loses its attestations, and a second report suspends the Sandbox account. An agent
key, including every sign-in connection on /v1/mcp/oauth, is refused with
INSUFFICIENT_SCOPE until the operator turns agent recipient writes on, and with
RECIPIENT_WRITE_AGENT_CONTAINED when a bound mailbox only lets the agent email people
the account owner approves; in both cases the hint says to ask the user to add the
person (for this tool, INSUFFICIENT_SCOPE does not suggest an admin key).
Attesting with none left is TIER_LIMIT with attestations_remaining: 0
(forfeited: true after a complaint), or attestations_limit: 0 while attestation is
off. RECIPIENT_WRITE_UNAVAILABLE means retry once; if it fails again, stop and tell
the user. A send refused with SANDBOX_RECIPIENT_NOT_VERIFIED gets a hint naming
add_recipient.
star_message — wraps PATCH /v1/messages/:id/star. Input: message_id,
starred: boolean. Use one tool call to star or unstar (matches the SDK/REST {starred}
shape — there are no separate star/unstar verbs).
star_thread — wraps PATCH /v1/mailboxes/:id/threads/:thread_id/star. Input:
mailbox, thread_id, starred: boolean.
get_attachment_preview — wraps GET /v1/messages/:id/attachments/:idx/preview.
Returns derived text preview only (PDF, Office, text/plain, text/csv). Surfaces
404 ATTACHMENT_PREVIEW_NOT_AVAILABLE as an isError result when the derivative is not
ready, failed, or unavailable. Returns structuredContent alongside the text block.
add_inbound_allowlist_bulk — wraps POST /v1/mailboxes/:id/inbound-allowlist/bulk.
Input: mailbox, emails: string[] (max 1000). Returns the full partial-success shape:
added, already_existed, invalid (with per-entry reason), and counts.
add_suppressions_bulk — wraps POST /v1/suppressions/bulk. Input:
emails: string[] (max 1000). Same partial-success shape.
add_inbound_blocklist_bulk — wraps POST /v1/inbound-blocklist/bulk. Input:
emails: string[] (max 1000). Same partial-success shape.
remove_suppression — policy-explanation stub. Always returns isError with code
SUPPRESSION_REMOVE_NOT_AVAILABLE. Improves agent UX by explaining why removal is not
available via MCP rather than returning a generic unknown-tool error.
wait_for_message timeout — the tool reflects the real server cap: max 30 s per
call (not 120 s). For longer waits, call the tool again.
- Fields left out of results — on every transport the tools drop internal
identifiers and bookkeeping from the API response before building the text block and
structuredContent (src/lib/trim-response.ts): reviewed_by_id and six attachment
bookkeeping fields (read_message, get_thread), r2_objects_failed (becomes
r2_objects_failed_count, always present, delete_message), added_by_actor_id (the suppression,
blocklist and allowlist tools), actor_id (list_allowlist_blocked_attempts),
warmup.velocity_gate_mode (get_agent_quota), attachment.hash and
preview.extractor (get_attachment_preview), and the scanner, PII-detector,
instruction-trust and consent-bookkeeping fields (list_mailboxes). A vendor-named suppression
source reads provider_webhook or provider_sync, and an unrecognised one reads
system. A 400 validation error keeps only instancePath and message per
details.validation item, and SELF_HOSTED_DECRYPT_FAILED drops details.domain_id.
next_cursor is an opaque paging token; pass it back unchanged. The REST API, SDK and CLI are unchanged. The full table is
in the MCP guide.
list_drafts — returns has_more beside drafts; pass the last draft's id as
before for the next page.
Browse & search
Inbox search and full thread reads are first-class on MCP: list_messages filters by
keyword search, sender, date range, direction, status (incl. the held-mail states
quarantined / pending_review for triage) and starred, and pages older via
before=<oldest message id from the previous page>. list_threads lists conversations
(with starred / has_inbound), and get_thread reads a full thread — pass mailbox
to disambiguate a thread key that resolves in more than one mailbox. list_messages also
supports has_attachment (true/false) — it probes /v1/health for the
messages.has_attachment_filter capability and returns a structured
SEARCH_OPERATOR_UNSUPPORTED on older servers that don't advertise it.
Hosted vs local attachments
send_email, reply_to_message, create_draft and update_draft take an
attachments array. What that array means depends on which server you are talking to.
Local server (this package, npx -y replylayer-mcp) — attachments are paths on
your own machine. The server reads each file, uploads it, waits for its content scan,
and references the resulting handles on the send. This is the normal way to attach files.
Hosted server (POST /v1/mcp on api.replylayer.ai) — the hosted server never
reads files. It runs in ReplyLayer's own infrastructure, so a path in attachments
would name a file on our server, not yours. Supplying a non-empty attachments array
is refused before any file is opened, with a stable code:
{
"error": "The hosted MCP server cannot read files from your machine. To attach files, run the local MCP server (npx -y replylayer-mcp) or upload through the REST API / SDK and send from there; hosted attachment handles are not yet available on MCP.",
"code": "HOSTED_LOCAL_ATTACHMENTS_UNSUPPORTED",
"details": { "attachments": 1 }
}
details reports only how many paths you sent; the paths themselves are never echoed back.
To attach a file on the hosted server, pick one of:
- Run the local MCP server (
npx -y replylayer-mcp) and use attachments as
documented above; or
- Upload through the REST API or an SDK —
POST /v1/attachments returns a handle id,
and POST /v1/messages/send accepts attachment_ids. Send from there.
Two details worth knowing:
- An empty
attachments: [] is not a rejection — it behaves exactly like omitting the
field.
- A same-key idempotent retry still replays. If an earlier send from the local
server carried real paths and you retry it against the hosted server with the same
idempotency_key, the replay probe runs first and returns the original result. The
attachments parameter therefore stays in the hosted schema (so such a retry still
parses) — only its description changes, and it is refused on a probe miss.
Hosted attachment handles on MCP are not available yet; when they land, they will be an
explicit handle-id parameter rather than a path.
Changelog
- 0.13.0 — Tool titles and complete annotations. Every tool declares a
title and
explicit boolean readOnlyHint, destructiveHint and openWorldHint, plus
idempotentHint on every tool that changes something (see
Tool annotations). create_draft, update_draft, both release
tools, add_recipient, and the ten list and marker writes (add_suppression*,
add_inbound_blocklist*, add_inbound_allowlist*, mark_*_read, star_*) are now
destructive, so hosts that honour the hints ask before running them.
remove_suppression is now read-only (it always returns an error). create_draft
and update_draft say that no email is sent unless send_at is set. The masking
guidance in the idempotency_key descriptions and the primer no longer names a
specific host. delete_message and report_and_block declare idempotentHint: true.
New grant registration option ('any' by default): the hosted sign-in endpoint
registers with grant: 'agent', which leaves out AGENT_UNAVAILABLE_TOOL_NAMES
(approve_review, deny_review, get_account_usage, remove_suppression; the
other 40 remain) and ships OAUTH_SERVER_INSTRUCTIONS, the primer with its API-key
wording replaced by connection wording. A grant: 'agent' registration also takes
oauth: { scope, challenge } (required) and adds OAuth linking metadata: every tool
carries securitySchemes ([{ type: 'oauth2', scopes: [scope] }]) in _meta and,
through a wrapped tools/list handler, top-level too, and a tool
error caused by a 401 from the API carries _meta["mcp/www_authenticate"]: [challenge],
which a client that signs in per tool (ChatGPT) needs to re-link. The stdio server and
/v1/mcp are unchanged by the grant. Apart from add_recipient's new attest (below),
no parameter changes. Results leave out internal identifiers and bookkeeping on every transport (see
Tool notes): reviewed_by_id, added_by_actor_id, actor_id,
r2_objects_failed (now r2_objects_failed_count), warmup.velocity_gate_mode,
preview.extractor, attachment hash and bookkeeping fields, and the mailbox
scanner, PII-detector, instruction-trust and consent-bookkeeping fields; a vendor-named
suppression source reads provider_webhook or provider_sync; 400 validation
details keep only instancePath and message, and SELF_HOSTED_DECRYPT_FAILED drops
details.domain_id. get_attachment_preview's output schema no longer lists
attachment.hash or preview.extractor, and list_allowlist_blocked_attempts's
description no longer names actor_id. list_drafts returns the API's has_more
again. Sandbox recipient attestation: add_recipient gains an optional attest: boolean (sendable now, no confirmation
email, spends one of the account's per-trial attestations) and a description that
states the Sandbox-only scope, both branches, the complaint consequence and the
restricted-mailbox refusal; it stays listed on the sign-in endpoint. It no longer
says other accounts "can email anyone": they don't use this list, and on a paid plan a
mailbox restricted to approved people still binds agent sends, so the agent asks the
user to approve the person. The Sandbox-recipient hints likewise say pay-as-you-go
credit removes the Sandbox recipient rule, not that it lets the agent email anyone. Tool errors gain
hints for SANDBOX_RECIPIENT_NOT_VERIFIED, RECIPIENT_WRITE_AGENT_CONTAINED,
RECIPIENT_WRITE_UNAVAILABLE and an attestation TIER_LIMIT. Requires
@replylayer/sdk 0.28.0.
- 0.12.3 — Idempotency keys must be literal. The
idempotency_key descriptions on
send_email, reply_to_message and create_draft, and the server-level primer, now
say: pass the literal key in every call, and if your host masks fields ending in
_key (OpenClaw does), retry from a new session with the full literal request instead
of reusing a masked value. The API refuses a masked key (***, a value containing
…, __OPENCLAW_REDACTED__, or a word such as [REDACTED]) with
400 IDEMPOTENCY_KEY_INVALID. The hosted server already carries this text. No tool or
schema change. The release also produces the Claude Desktop extension bundle.
- 0.12.2 — API-key authentication failures. The server-level workflow primer
(
SERVER_INSTRUCTIONS) now tells the agent that a RATE_LIMITED whose
details.reason is failed_authentication means the API key is wrong or revoked:
stop and ask a human to fix the key, do not retry. It also names UNAUTHORIZED /
API_KEY_REVOKED as stop-and-ask-a-human codes. Tool errors gain matching friendly
hints for the failed-authentication 429 (checked before the daily-quota test) and
for API_KEY_REVOKED. No tool or schema change.
- 0.12.1 — documentation only; no tool, schema, or runtime change. The auth note no
longer describes signup as invite-gated: it states the email and mobile-number
verification a new account's key waits on.
- 0.12.0 — the hosted MCP server now refuses local
attachments paths before any
filesystem access (HOSTED_LOCAL_ATTACHMENTS_UNSUPPORTED); see
Hosted vs local attachments. Additive public API: the
tool registrars exported from replylayer-mcp/tools accept an optional third
RegisterToolOptions argument ({ transport?: 'local' | 'hosted' }, default
'local'), so every existing two-argument call is unchanged. The hosted MCP server
also injects the same server-level workflow primer as this binary and advertises the
real package version; both transports read the version from package.json. The
primer branches on email_effect.effect_status (with the legacy top-level status
as a fallback for older servers) and carries the immediate-send idempotency rules.
Additive public API: replylayer-mcp/tools also exports SERVER_INSTRUCTIONS and
MCP_PACKAGE_VERSION.
See also
- Public CLI repo (install & package trust): https://github.com/replylayer/rly
- The agent
SKILL.md, the CLI ↔ MCP ↔ REST verb-parity map, and ENDPOINTS.md
(the REST API contract) will be published with the hosted docs site when it is
available.