# 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/mcp` on the API. No install;
  authenticate with your API key as a Bearer token.
- **stdio** — the `replylayer-mcp` package (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**. 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](#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 (they return `405`
with an `Allow: POST` header). Authenticate every request with
`Authorization: Bearer <api-key>`; a missing or malformed Bearer token returns
`401`.

A representative MCP client config:

```json
{
  "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](/agents/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).

```json
{
  "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:

```json
{
  "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`:

- `code` is the stable machine-readable error code. The full catalog lives in
  [Errors](/agents/errors).
- `details` carries structured fields when the API provides them — e.g. a
  rate-limit error carries `daily_limit`, `sends_remaining`, and `reset_at`; a
  capability/denial error carries `reason_axis` and a `cheapest_next_step`.
- `hint` is an array of plain-text next-step lines (the same guidance the CLI
  prints). It is advisory; the machine contract is `code` + `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. |

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](/agents/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

Only **10 of the 44 tools** declare an `outputSchema`, and only those also emit
`structuredContent` (a machine-parseable object) alongside the text block. The
other 34 return their JSON as text content only — parse `content[0].text`.

Tools with a declared output schema + `structuredContent`:

`send_email`, `reply_to_message`, `send_draft`, `list_messages`, `read_message`,
`get_thread`, `list_threads`, `list_mailboxes`, `wait_for_message`,
`get_attachment_preview`.

This is intentional and uneven: several read-only tools (for example
`list_suppressions`, `list_allowlist`, `list_recipients`, `get_draft`,
`get_account_usage`, `get_agent_quota`) return **text-only** JSON despite being
pure reads. Do not assume a read tool emits `structuredContent` — check for the
tool in the list above, and otherwise parse the text block.

## Server instructions differ by transport

The **stdio** server ships a server-level `instructions` primer — 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 **hosted** `/v1/mcp` server does **not** currently send server instructions. A
host connecting over hosted HTTP will not receive that primer and should rely on
the per-tool `description` fields, which are self-contained. This is current
behavior — plan for it rather than assuming the primer is present on hosted.

## Tool registry (44 tools)

Tools marked `†` declare an `outputSchema` and emit `structuredContent`; all others
return text-only JSON. The **Annotations** column lists the MCP hints each tool
declares (`readOnly`, `destructive`, `idempotent`, `openWorld`); a `—` means no
hint is declared. Annotations are advisory metadata for the host — the
authoritative permission gates are enforced server-side.

### Messages & threads

| Tool | Purpose | Annotations |
|---|---|---|
| `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 an inbound message from the mailbox that received it, threading correctly. | destructive, openWorld |
| `list_messages` † | List / search messages in a mailbox (search, sender, date, direction, status, starred, `has_attachment`); page older via `before`. | readOnly |
| `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` † | Long-poll a mailbox; returns when a new message arrives or the timeout expires. | readOnly |
| `get_thread` † | Read a full thread as one ordered conversation (oldest → newest, safe view). | readOnly |
| `list_threads` † | List conversation threads (most-recent first) with participants, `unread_count`, `last_message_at`. | readOnly |
| `mark_message_read` | Mark one inbound message read (outbound / deleted / firewall-blocked rows are a 200 no-op). | idempotent |
| `mark_thread_read` | Bulk-mark every visible inbound unread message in a thread read; returns `marked_count`. | idempotent |
| `star_message` | Star or unstar a message. | idempotent |
| `star_thread` | Star or unstar a thread. | idempotent |
| `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 |
| `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 | Purpose | Annotations |
|---|---|---|
| `list_mailboxes` † | List account mailboxes (name, full address, status). | readOnly |
| `get_account_usage` | Get account usage, limits, and storage-quota breakdown. | readOnly |
| `get_agent_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` | Read malicious link scanning (URL reputation) activation status. Enabling it is a human/admin action, not an agent one. | readOnly |

### Drafts & scheduled send

| Tool | Purpose | Annotations |
|---|---|---|
| `create_draft` | Create a draft (the scanner runs synchronously and attaches a verdict); pass `send_at` to schedule dispatch. | — |
| `update_draft` | Update a draft and re-scan; set/clear sub-address and `send_at` fields; `clear_send_at` cancels a scheduled send. | — |
| `get_draft` | Read one draft by ID (body, scan results, schedule + sub-address fields). | readOnly |
| `list_drafts` | List drafts in a mailbox. | readOnly |
| `delete_draft` | Soft-delete a draft (hides it from `list_drafts`; cancels any schedule). | destructive |
| `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 | Purpose | Annotations |
|---|---|---|
| `list_recipients` | List recipients with confirmation status. | readOnly |
| `add_recipient` | Add a recipient and send a confirmation email the recipient must click before you can send to them (required on sandbox-tier accounts). | openWorld |

### Do-not-contact list

| Tool | Purpose | Annotations |
|---|---|---|
| `list_suppressions` | List do-not-contact entries (system-added + customer-added; exact email or `@domain`). | readOnly |
| `add_suppression` | Add an exact email or `@domain` to the do-not-contact list. One-way on MCP — removal is not exposed. | idempotent |
| `add_suppressions_bulk` | Bulk-add up to 1000 do-not-contact entries; returns a partial-success `added` / `already_existed` / `invalid` response. | idempotent |
| `remove_suppression` | Always returns `isError` — removal is intentionally **not** available via MCP; use the CLI, an SDK admin key, or the dashboard. | — |

Suppression removal is a per-human admin decision by design. See the
[Do-not-contact guide](/docs/guides/suppressions) for the full add / list / remove
contract.

### Recipient allowlist (outbound)

| Tool | Purpose | Annotations |
|---|---|---|
| `list_allowlist` | List a mailbox's recipient allowlist (only consulted in allowlist mode). Agents cannot mutate the allowlist via MCP — mutation is admin-only. | readOnly |
| `list_allowlist_blocked_attempts` | Inspect sends rejected by the recipient allowlist (aggregated summary by default; `aggregate=false` for raw history). | readOnly |

### Inbound firewall

| Tool | Purpose | Annotations |
|---|---|---|
| `list_inbound_blocklist` | List the account-wide inbound sender blocklist. | readOnly |
| `add_inbound_blocklist` | Add an exact email or `@domain` to the inbound blocklist (one-way on MCP). | idempotent |
| `add_inbound_blocklist_bulk` | Bulk-add up to 1000 inbound blocklist entries; partial-success response. | idempotent |
| `list_inbound_allowlist` | List a mailbox's inbound sender allowlist. | readOnly |
| `add_inbound_allowlist_entry` | Add a sender (email or `@domain`) to a mailbox's inbound allowlist. | idempotent |
| `add_inbound_allowlist_bulk` | Bulk-add up to 1000 inbound allowlist entries; partial-success response. | idempotent |
| `list_inbound_firewall_blocked_attempts` | List incoming senders the inbound firewall rejected (aggregated by default; `aggregate=false` for raw history). | readOnly |
| `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. | — |

### Triage & review

| Tool | Purpose | Annotations |
|---|---|---|
| `release_quarantined_message` | Release a quarantined **inbound** message back to available. Refuses outbound rows. Mailbox-bound keys are limited to their mailbox. | — |
| `block_quarantined_message` | Block a quarantined **inbound** message (terminal, no delivery). Refuses outbound rows. | destructive |
| `report_and_block` | Block a held **inbound** message and add its resolved sender to the account-wide inbound blocklist (idempotent). Refuses outbound rows. | destructive |
| `approve_review` | Approve a `pending_review` message and dispatch it. Admin keys + dashboard sessions only — agent keys get `403`. | destructive, openWorld |
| `deny_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](/agents/messages)
- What happens when a send is held, blocked, or contained → [Send gates](/agents/send-gates)
- Full error-code catalog → [Errors](/agents/errors)
- Attachment staging and preview lifecycle → [Attachments](/agents/attachments)
- Tier and sandbox send limits → [Limits](/docs/limits)
- Per-field trust and untrusted-content handling → [Security model](/agents/security-model)
- Webhook event catalog (dispatch, review, schedule signals) → [Webhooks](/agents/webhooks)
- CLI equivalents and exit codes → [CLI](/agents/cli)

The `replylayer-mcp` package is published on
[npm](https://www.npmjs.com/package/replylayer-mcp).
