# Plainsend MCP server

Customers' agents (Claude Code, claude.ai, any MCP client) drive Plainsend through `https://api.plainsend.app/mcp`,
served by the API Worker itself (streamable HTTP). Code: `apps/api/src/mcp/`. Tests: `apps/api/test/mcp.test.ts`, `apps/api/test/cli-login.test.ts`,
`apps/api/test/mcp-oauth.test.ts`.

## Connect

**Start here (a project folder):** install the CLI, sign in, and let `init` set the folder up:

```sh
curl -fsSL https://plainsend.app/install | sh   # or npm i -g @plainsend/cli (packages/cli; not published yet)
plainsend login                                 # browser sign-in; --agent for a drafts-only agent key
plainsend init [--pack <pack>]                  # plainsend.config, AGENTS.md section, .claude skill, test email to you
claude mcp add --transport http plainsend https://api.plainsend.app/mcp
```

**Claude Code, with OAuth**: the `claude mcp add` line above, with no header. Claude Code registers itself and opens
the consent page, like claude.ai below.

**Claude Code, with an API key** (an owner creates an agent key: `plainsend keys create claude --kind agent --label "Claude for Brendan"`):

```sh
claude mcp add --transport http plainsend https://api.plainsend.app/mcp \
  --header "Authorization: Bearer ps_..."
```

**claude.ai (custom connector), with OAuth**: Settings → Connectors → Add custom connector → URL
`https://api.plainsend.app/mcp`, no client ID or secret. Claude registers itself and opens Plainsend's consent page.
With WorkOS sign-in configured (production), the page runs on the console's host (`app.plainsend.app`, where the
session cookie lives). A person signed in there approves, and the connector gets a new agent key in their current
workspace labelled "Claude for <their name>" (only an owner or admin may). Signed out, the page offers "Sign in with
your Plainsend account" (`/auth/login?return_to=<the page>`) or an **owner** key pasted once with a name (used for two
calls and never stored); without WorkOS only the owner key. A person signed in with no workspace yet is sent to "Name
your workspace" (`/?return_to=<the page>`) and comes back to the page once it exists. Revoke the connector by revoking that key (`plainsend keys list`, `plainsend keys revoke <id>`); its
next call gets a 401 and Claude asks to connect again.

**The CLI (`plainsend login`)**: OAuth 2.1 with PKCE as the static public client `plainsend-cli` (stored on first use by
`src/mcp/oauth.ts`; redirect `http://127.0.0.1/callback`, any port, RFC 8252), on a random loopback port. Scope
`person` (the default; the CLI skips the owner-key form and goes straight to sign-in, and "Name your workspace" when
there is none): the consent page starts a CLI person session (`person_sessions.via = 'cli'`) and the token
acts as the person in their role, re-checked with WorkOS like a console session, audited as `person:<email> via
cli`; it lasts 30 days. Scope `agent` (`--agent`): an agent key labelled "Plainsend CLI for <name>". Over SSH,
`--no-browser` prints the address and takes the pasted callback address (the provider has no device-code grant, and
this needs nothing more from it). Tokens are stored in `~/.config/plainsend/config.json` (0600) and refreshed by the
CLI. `plainsend logout` calls `DELETE /v1/session`, which ends the session and so every token from it.

Any other client: point it at the same URL. It discovers OAuth from the 401 (`WWW-Authenticate: Bearer ...
resource_metadata="https://api.plainsend.app/.well-known/oauth-protected-resource/mcp"`), or send an API key as a
bearer header.

## What an agent can do

Every tool calls the REST route it mirrors, in-process, with the caller's key (`src/mcp/api.ts`), so `authorize()`
decides exactly as for REST (`src/http/access.ts`, `src/modules/policy.ts`). With an agent key:

- Reads, drafts, previews, simulations, preflights, test sends, imports and syncs run.
- `journey_request_activation` and `send_request_broadcast` return
  `{ approvalRequired: true, approvalId, consoleUrl, action, target, next }` (not an error); an owner approves in the
  console ("Needs you"), with `plainsend approvals approve <id> [--part <part>]` or a one-time approve link sent to
  Slack; `approval_status` follows it, part by part (a broadcast's audience, message, send settings and checks; a
  journey's entry, steps, goal and checks). A part the agent changes after an owner approved it resets ("changed",
  with the diff since) and needs a new OK.
- Anything else the agent kind may not do (`domain_add`, raw quarantine rows) is refused: `isError: true` with
  `{ error: { code: "forbidden", message, details: { action, keyKind }, fix } }`.

With an owner key the same tools act directly (an activation activates). There is deliberately no tool to approve or
reject, erase contacts, change settings, manage keys or enrol by hand.

| Tool | REST | | Tool | REST |
|---|---|---|---|---|
| `workspace_get` | `GET /v1/me`, settings, brands | | `journey_list` | `GET /v1/journeys` |
| `brand_list` / `brand_put` | `GET /v1/brands`, `PUT /v1/brands/:key` | | `journey_get` | `GET /v1/journeys/:key` (+ the version's document) |
| `template_list` / `template_get` | `GET /v1/templates[/:key]` | | `journey_validate` / `journey_put` | `POST .../validate`, `PUT /v1/journeys/:key` (object or YAML) |
| `template_lint` / `template_put` | `POST /v1/templates/:key/lint`, `PUT /v1/templates/:key` (both return `issues`) | | `journey_plan` | `GET /v1/journeys/:key/plan` |
| `template_preview` | `POST /v1/templates/:key/preview` | | `journey_simulate` | `POST /v1/journeys/:key/simulate` |
| `template_test_send` | `POST /v1/sends/test` | | `journey_request_activation` | `POST .../versions/:v/activate` (default: newest draft) |
| `source_list` / `source_put` | `GET /v1/sources`, `PUT /v1/sources/:key` | | `journey_funnel` | `GET /v1/reports/journeys/:key/funnel` |
| `source_import_file` | `POST /v1/sources/:key/runs` (base64 CSV/XLSX, dry run by default) | | `enrolment_trace` | `GET /v1/enrolments/:id` (or a contact's latest) |
| `source_sync` | `POST /v1/sources/:key/sync` (dry run by default) | | `send_preflight` | `POST /v1/sends:preflight` |
| `source_runs` / `source_quarantine` | `GET /v1/sources/:key/runs`, `/quarantine` | | `send_request_broadcast` | `POST /v1/sends` |
| `source_upload` | `POST /v1/sources/:key/uploads` (columns, suggested choices, first rows) | | `source_upload_plan` | `POST .../uploads/:id/plan` (dry run, writes nothing) |
| `source_upload_import` | `POST .../uploads/:id/import` (a run, paged) | | `source_connect_link` | `GET /v1/sources/connect/:provider/start` (a sign-in link for a person) |
| `contact_find` | `GET /v1/contacts` (`email` or `q`) | | `report_sends` | `GET /v1/reports/sends[/:id]` |
| `contact_get` | `GET /v1/contacts/:id` + skipped sends + enrolments (the why-not) | | `approval_status` | `GET /v1/approvals[/:id]` |
| `domain_add` | `POST /v1/domains` (owner only) | | `domain_status` | `GET /v1/domains[/:domain]`, `POST .../check` |
| `domain_warmup` | `GET /v1/domains/:domain/warmup` | | `report_health` | `GET /v1/reports/health` (`window` 24h/7d/30d) |
| `reputation_request_resume` | `POST /v1/brands/:key/reputation/resume` (agent: an approval) | | `billing_status` | `GET /v1/billing` (read only; paying is an owner's, in the console) |
| `log_search` | `GET /v1/activity` (actor, kind, since, cursor) | | `setup_status` | `GET /v1/setup` (the first-run checklist, what blocks sends; read only) |
| `workspace_plan` | `POST /v1/workspace:plan` (a whole bundle, in dependency order) | | `workspace_apply` | `POST /v1/workspace:apply` (drafts; go-live is one approval) |
| `workspace_export` | `GET /v1/workspace:export` (no secrets) | | | |

Tool names use underscores, not the dots of the ticket (`journey.plan`): the Claude API's tool-name pattern is
`^[a-zA-Z0-9_-]{1,64}$`. Results carry a one-line summary and the JSON (as text and as `structuredContent`). Errors
keep the API's `{ code, message, details }` and always add a `fix` (from the details when the route gave one,
else per code: `src/mcp/result.ts`).

**Resources** (read-only, bundled at build time by `scripts/build-mcp-docs.ts` into `src/mcp/docs.generated.ts`; run
`pnpm mcp:docs` after editing the sources, a test fails while the bundle is stale): `plainsend://docs/agents`
(`docs/reference/agents.md`), `plainsend://docs/mappings` (`docs/mappings/README.md` + `sheet.yaml`),
`plainsend://docs/journeys` (`docs/reference/journeys.md`), `plainsend://docs/templates`
(`docs/reference/templates.md`). Segments have no document yet, so no segment reference.

**Prompts**: `setup_from_spreadsheet { file, brand? }`, `build_welcome_journey { brand, subscriptionType }`.
