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:

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"):

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:

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.

ToolRESTToolREST
workspace_getGET /v1/me, settings, brandsjourney_listGET /v1/journeys
brand_list / brand_putGET /v1/brands, PUT /v1/brands/:keyjourney_getGET /v1/journeys/:key (+ the version's document)
template_list / template_getGET /v1/templates[/:key]journey_validate / journey_putPOST .../validate, PUT /v1/journeys/:key (object or YAML)
template_lint / template_putPOST /v1/templates/:key/lint, PUT /v1/templates/:key (both return issues)journey_planGET /v1/journeys/:key/plan
template_previewPOST /v1/templates/:key/previewjourney_simulatePOST /v1/journeys/:key/simulate
template_test_sendPOST /v1/sends/testjourney_request_activationPOST .../versions/:v/activate (default: newest draft)
source_list / source_putGET /v1/sources, PUT /v1/sources/:keyjourney_funnelGET /v1/reports/journeys/:key/funnel
source_import_filePOST /v1/sources/:key/runs (base64 CSV/XLSX, dry run by default)enrolment_traceGET /v1/enrolments/:id (or a contact's latest)
source_syncPOST /v1/sources/:key/sync (dry run by default)send_preflightPOST /v1/sends:preflight
source_runs / source_quarantineGET /v1/sources/:key/runs, /quarantinesend_request_broadcastPOST /v1/sends
source_uploadPOST /v1/sources/:key/uploads (columns, suggested choices, first rows)source_upload_planPOST .../uploads/:id/plan (dry run, writes nothing)
source_upload_importPOST .../uploads/:id/import (a run, paged)source_connect_linkGET /v1/sources/connect/:provider/start (a sign-in link for a person)
contact_findGET /v1/contacts (email or q)report_sendsGET /v1/reports/sends[/:id]
contact_getGET /v1/contacts/:id + skipped sends + enrolments (the why-not)approval_statusGET /v1/approvals[/:id]
domain_addPOST /v1/domains (owner only)domain_statusGET /v1/domains[/:domain], POST .../check
domain_warmupGET /v1/domains/:domain/warmupreport_healthGET /v1/reports/health (window 24h/7d/30d)
reputation_request_resumePOST /v1/brands/:key/reputation/resume (agent: an approval)billing_statusGET /v1/billing (read only; paying is an owner's, in the console)
log_searchGET /v1/activity (actor, kind, since, cursor)setup_statusGET /v1/setup (the first-run checklist, what blocks sends; read only)
workspace_planPOST /v1/workspace:plan (a whole bundle, in dependency order)workspace_applyPOST /v1/workspace:apply (drafts; go-live is one approval)
workspace_exportGET /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 }.