plunk-mcp
Official# plunk-mcp
A Model Context Protocol server for [Plunk](https://useplunk.com), the open-source self-hosted email platform. Gives Claude (and any MCP client) 94 tools across the Plunk API: transactional email, contacts, campaigns, segments, templates, workflows, events, analytics.
Unofficial. Not affiliated with Plunk. Built by [Ignyte](https://github.com/ignytehq).
## Why this exists
Plunk's official Node SDK does two things: `track` and `send`. The API behind those two methods has grown into a full email automation platform that includes workflows, segments, templates, analytics, but the SDK never caught up. So Claude couldn't reach any of it.
This MCP closes that gap. Every endpoint Claude can usefully call, it can call.
## Requirements
The active Plunk codebase: [`useplunk/plunk`](https://github.com/useplunk/plunk), distributed as `ghcr.io/useplunk/plunk`. Self-hosted or on [useplunk.com](https://useplunk.com).
Note that this MCP does not support the legacy `driaug/plunk` Docker image. If you're on that image, see [Migrating from legacy](#migrating-from-legacy-driaugplunk).
Node.js ≥ 20 (required by the v2 MCP SDK).
## Install
Add to `~/.claude.json` or your Claude Desktop config:
```json
{
"mcpServers": {
"plunk": {
"command": "npx",
"args": ["-y", "@ignytehq/plunk-mcp"],
"env": {
"PLUNK_API_KEY": "sk_your_secret_key_here",
"PLUNK_PUBLIC_KEY": "pk_your_public_key_here",
"PLUNK_API_URL": "https://your-plunk-host"
}
}
}
}
```
Restart Claude.
`PLUNK_API_URL` is optional on hosted Plunk. It defaults to `https://next-api.useplunk.com`, which is
the API for the active `useplunk/plunk` codebase — *not* `api.useplunk.com`, which serves the legacy
API and 404s every route this server uses.
`PLUNK_PUBLIC_KEY` is optional. Plunk's `/v1/track` endpoint is gated by the public key (`pk_*`), not the secret key — if you omit `PLUNK_PUBLIC_KEY`, `plunk_track_event` will 401 against v0.10+ instances.
### Multiple Plunk projects
Plunk API keys are project-scoped. To work with multiple projects in the same session, register one MCP server per project:
```json
{
"mcpServers": {
"plunk-acme": {
"command": "npx",
"args": ["-y", "@ignytehq/plunk-mcp"],
"env": {
"PLUNK_API_KEY": "sk_acme_...",
"PLUNK_API_URL": "https://plunk.acme.com"
}
},
"plunk-personal": {
"command": "npx",
"args": ["-y", "@ignytehq/plunk-mcp"],
"env": {
"PLUNK_API_KEY": "sk_personal_...",
"PLUNK_API_URL": "https://plunk.example.com"
}
}
}
}
```
Claude sees each as its own tool namespace.
## Configuration
| Env var | Required | Default | What it does |
|---|---|---|---|
| `PLUNK_API_KEY` | yes | — | Secret API key (`sk_*`) from your project settings. Used for all admin endpoints and for `/v1/send` / `/v1/verify`. |
| `PLUNK_PUBLIC_KEY` | no | — | Public API key (`pk_*`). Required for `plunk_track_event` — Plunk's `/v1/track` endpoint is gated by the public key, not the secret key. Without this set, `track_event` will 401. |
| `PLUNK_API_URL` | no | `https://next-api.useplunk.com` | Base URL of your Plunk API. For self-hosted, point at the API host (e.g. `https://api.plunk.example.com`, or `https://plunk.example.com/api` if your reverse proxy maps it that way). |
| `PLUNK_READ_ONLY` | no | `false` | `true` registers only the 40 read-only tools. Nothing can be created, changed, sent or deleted. |
| `PLUNK_ALLOW_UNCONFIRMED_SENDS` | no | `false` | `true` skips the confirmation prompt before sends and bulk deletes. For headless automation only. |
| `PLUNK_MCP_API_KEY` | no | — | Takes precedence over `PLUNK_API_KEY`. Use it when `PLUNK_API_KEY` is already taken in the environment — see below. |
| `PLUNK_MCP_API_URL` | no | — | Takes precedence over `PLUNK_API_URL`, for the same reason. |
| `PLUNK_SKIP_CAPABILITY_DETECTION` | no | `false` | Skip the startup probe and expose every tool regardless of what your instance supports. Useful for debugging. |
The flags `--read-only` and `--api-url=<url>` do the same as their environment variables, and win over them.
### If you self-host Plunk on the same machine
Plunk's own API server uses an environment variable called `PLUNK_API_KEY` for its platform
notification emails — and that key belongs to a *different* project. If both are present in the same
environment, this server would silently talk to the wrong project rather than error. Set
`PLUNK_MCP_API_KEY` and it wins.
## Tests
`npm test` builds and runs the suite (vitest). It never touches a real Plunk instance — the
integration tests point the server at an unroutable address, so a tool that gets past a gate fails at
the socket, which is what proves it got past.
Four areas, chosen because each one covers a regression that actually happened during development:
- **Annotation invariants** — every tool classified, no orphan rows, the fail-closed default holds,
and no tool whose verb implies a side effect is admitted to read-only mode.
- **Schema regressions** — identifiers accept well-formed non-RFC UUIDs (zod 4's `z.uuid()` rejects
them where zod 3 did not), and the recursive filter tree is advertised with real structure at both
its outer *and* nested level.
- **Confirmation builders** — thresholds, counts, pluralisation, address-preview capping, and prompts
grounded in a fetched campaign, including when that fetch fails.
- **Protocol integration** — the real binary over stdio: registration counts, read-only withholding,
the confirmation round-trip on both protocol eras, declines, malformed confirmations, the bypass
variable, and startup validation.
## Safety
A Plunk secret key is all-or-nothing over its project, so this server adds its own brakes.
**Read-only mode is enforced by registration.** With `PLUNK_READ_ONLY=true` (or `--read-only`) the 54
mutating tools are never registered, so they do not appear in `tools/list` and cannot be invoked even
by name. The gate reads each tool's `readOnlyHint` annotation, and an unclassified tool defaults to
destructive — a tool is excluded unless it is known to be safe, never the other way round.
**Every tool is annotated.** All 94 carry `readOnlyHint`, `destructiveHint`, `idempotentHint` and
`openWorldHint`, so clients that gate on annotations can prompt before a send or a delete without
pattern-matching on tool names. Sends (`plunk_send_campaign`, `plunk_send_transactional`,
`plunk_start_workflow_execution`) are marked destructive: not destructive in the delete sense, but
irreversible in the only sense that matters for email.
**Sends and bulk deletes ask a human first.** Eight tools are gated behind a confirmation the model
cannot grant itself — it arrives through an elicitation round-trip, on a channel the model never
writes to, so a `true` originated with the person at the keyboard:
| Tool | When it asks |
|---|---|
| `plunk_send_campaign` | always |
| `plunk_send_transactional` | more than one recipient |
| `plunk_bulk_delete_campaigns` / `_workflows` / `_templates` / `_contacts` | always |
| `plunk_bulk_unsubscribe_contacts` | always |
| `plunk_cancel_all_workflow_executions` | always |
The prompt is built from what the API reports, not from what the model claims — asking to send a
campaign fetches its name, subject and real audience size first, so the blast radius in the prompt is
the true one. Both protocol eras are served: modern (2026-07-28) clients get the `input_required`
round-trip, 2025-era clients the push-style elicitation request. A client that can do neither cannot
send, which is the safe direction for mass email; `PLUNK_ALLOW_UNCONFIRMED_SENDS=true` is the
documented way out for headless automation.
A declined or cancelled prompt stops the call. It is not re-asked, so a refusal cannot be worn down by
repetition.
**A note on domain tools.** `plunk_add_domain` and `plunk_delete_domain` are exposed, unlike in
Plunk's own MCP server, which withholds them. Plunk's reasoning is worth knowing: those endpoints skip
the admin-role check when called with an API key, so an agent holding one can do something a non-admin
member of the same project cannot. They are excluded from read-only mode, and `plunk_delete_domain` is
gated behind confirmation, but if that authority is not something you want an agent to hold, run with
`PLUNK_READ_ONLY=true` or use a project whose key you are comfortable handing over.
The gate is deliberately narrower than `destructiveHint`: it covers what is irreversible *and* wide.
Single deletes are annotated but not gated — gating everything would train people to set the bypass
variable and lose the gate altogether. The table lives in
[`src/confirmations.ts`](src/confirmations.ts).
**Misconfiguration fails at startup, not at call time.** A `pk_*` key in the secret slot, a `sk_*` key
in the public slot, or a non-absolute API URL each exit with an explanation instead of letting every
tool 401 one call at a time.
The full classification lives in [`src/annotations.ts`](src/annotations.ts) — one table, 94 rows, so
the security posture of the server can be read in one screen.
An API key still grants full read and write access to its project. Use a separate Plunk project for
anything you would not want an agent to change.
## What's in the box
94 tools across 11 categories — 40 read-only, 54 mutating. At startup, the MCP probes one endpoint per category and only registers tools whose category responds — so on older `useplunk/plunk` releases, missing features are hidden rather than failing at call time.
| Category | Tools | Highlights |
|---|---|---|
| Transactional | 3 | `send_transactional`, `track_event`, `verify_email` |
| Contacts | 19 | CRUD, bulk import/subscribe/unsubscribe/delete, custom field management |
| Campaigns | 15 | Full lifecycle: create, update, send, cancel, test, stats |
| Segments | 10 | Dynamic + static segments, member management, recompute |
| Templates | 8 | Reusable email templates referenced from sends/campaigns/workflows |
| Workflows | 19 | Steps, transitions, executions — the whole automation builder |
| Events | 6 | Read API: history, stats, names, usage, delete |
| Domains | 4 | Add, verify, delete sending domains |
| Activity | 5 | Activity feed, stats, upcoming sends |
| Analytics | 4 | Timeseries, top campaigns, top events |
| Uploads | 1 | Image uploads for templates and campaigns |
Tool names follow `plunk_<verb>_<resource>`. Examples:
- `plunk_send_transactional` — send a one-off email
- `plunk_track_event` — fire an event (which then drives workflows and segment filters)
- `plunk_create_workflow` + `plunk_add_workflow_step` + `plunk_start_workflow_execution`
- `plunk_create_segment` (full filter-condition schema)
- `plunk_get_analytics_timeseries`, `plunk_get_top_campaigns`
Every tool has a typed input schema, a short title, and a structured description:
```
**Purpose:** what it does
**Not for:** the sibling you probably wanted instead, and why
**Returns:** the shape of a success
**Use when:** the situation that should make you reach for it
**Note:** version gates, limits, gotchas
```
With 94 tools the failure mode is not a model that cannot use a tool — it is a model that picks the
wrong one of four that sound alike. `Not for` is the load-bearing field, and every tool has one:
`plunk_delete_contact` points at `plunk_unsubscribe_contact`, `plunk_delete_campaign` at
`plunk_cancel_campaign`, `plunk_send_campaign` at `plunk_test_campaign`. Those cross-references are
checked by the test suite, so a pointer can never name a tool that does not exist.
The descriptions cost roughly 38 KB of context when all 94 tools are registered. `PLUNK_READ_ONLY`
cuts that to the 40 read tools if an agent only needs to look.
## Capability detection, briefly
On startup the MCP makes one probe request per tool family to see what your instance answers. If `/templates` 404s, the seven template tools are hidden for the rest of the session. If `/workflows` answers, the sixteen workflow tools are registered.
The point is to keep Claude from confidently invoking endpoints that don't exist on your specific Plunk version. The probe takes one round trip per family at startup, then nothing.
## Migrating from legacy `driaug/plunk`
The legacy image exposes a smaller, different API. This MCP won't fully work against it. The migration path:
1. Stand up `ghcr.io/useplunk/plunk:latest` on a separate host or subdomain. Don't disrupt your existing sender. The official guide is at [docs.useplunk.com/self-hosting/introduction](https://docs.useplunk.com/self-hosting/introduction).
2. Re-create your project on the new instance. Grab a fresh `sk_*` API key. The schemas differ; there's no in-place upgrade.
3. Export contacts from legacy (CSV from the dashboard). Import on the new instance via `plunk_import_contacts`.
4. Re-create campaigns and templates. Workflows and segments are entirely new on the modern codebase.
5. Repoint your apps to the new host. Decommission legacy.
This is a real migration project, an easy evening or two of work.
## Building from source
```bash
git clone https://github.com/ignytehq/plunk-mcp.git
cd plunk-mcp
npm install
npm run build
PLUNK_API_KEY=sk_... PLUNK_API_URL=https://your-plunk node dist/index.js
```
### Tests
```bash
npm test
```
## Contributing
Issues and PRs welcome. If an endpoint responds unexpectedly, please include:
- Which Plunk version you're running (`docker inspect <container> | grep Image` plus the tag)
- The endpoint path that misbehaved
- The full error message
## License
MIT. See [LICENSE](./LICENSE).
## Acknowledgements
[Plunk](https://github.com/useplunk/plunk) and [Driaug Aerts](https://github.com/sponsors/driaug), for being open source. Anthropic, for the [Model Context Protocol](https://modelcontextprotocol.io).
TDQS
Scored across 94 tools
Individual descriptions are unusually disciplined, each with explicit 'Not for' cross-references that separate close neighbors (add vs insert_workflow_step, refresh_segment_count vs compute_segment, get_import_status vs get_bulk_job_status). However, with 94 tools there remains real overlap among the analytics family (get_campaign_stats vs get_top_campaigns vs get_campaign_breakdown vs get_activity_stats vs get_analytics_timeseries) that an agent could easily misselect.
Virtually every tool follows the same plunk_verb_noun snake_case convention with the verb leading (list_, get_, create_, update_, delete_, send_, bulk_). Modifiers are applied consistently (bulk_ prefix, list_X_contacts/list_X_members patterns), so the naming is highly predictable throughout.
94 tools is far beyond the 25+ threshold the rubric treats as too many; the surface is heavily fragmented into many narrow single-purpose calls (separate archive/unarchive/bulk_archive, separate import vs bulk job status). While the email-marketing domain is broad, this volume will strain an agent's tool-selection budget.
Coverage is exhaustive: full CRUD and lifecycle operations for contacts, campaigns, segments, templates, workflows, events, domains and analytics, plus imports, bulk operations, activities and upcoming sends. Almost no obvious lifecycle gaps or dead ends remain.