# MCP server

Pharos exposes its project-scoped API to coding agents (Claude Code, Cursor,
Codex) through an MCP server. The goal is that wiring a new app into Pharos is
one step for an agent rather than a re-derivation from the docs each time.

This is the productized version of the Chutometro integration — see
`docs/decisions.md`, "The product surface is the API, not the dashboard."

---

## Why there is prerequisite work

Pharos has two authentication paths, and until now almost nothing was on the
one an agent can use.

| Helper | Credential | Routes |
| --- | --- | --- |
| `authenticateProjectApiRequest` (`lib/project-api-auth.ts`) | `Bearer phk_…`, project-scoped, with scopes | (historical) `/api/v1/events`, `/api/v1/contacts/sync` |
| `requireProjectApiAccess` (`lib/authz.ts`) | browser session cookie only | everything else |

An agent holding a project API key could sync contacts and fire a transactional
event, and nothing else — no lists, no templates, no campaign preview.

`lib/api-access.ts` closes the gap with `requireProjectAccessOrKey`, which
accepts **either** credential and requires a scope on the API-key path. A
browser session implies every scope, so dashboard behaviour is unchanged.
Every key-authenticated route goes through it now, including the two v1 routes
that predate it — which is what makes organization keys work everywhere. It is
also where per-key rate limiting lives: over-budget requests get `429` with
`Retry-After` (`lib/rate-limit.ts`).

**There are two kinds of key.** A project key (`phk_…`) is pinned to one
project — `authenticateProjectApiRequest` joins the key's own project against
`projects.slug`, so it can never reach another. An organization key (`pha_…`)
reaches every project in its organization, resolving by id or slug; slugs are
globally unique, so this stays unambiguous. The org key is what lets a
single global MCP registration serve every repo on a machine instead of pinning
it to one project.

A project outside the key's organization returns 404, not 403 — an org key must
not be usable to probe for project names elsewhere.

**Routes must also be exempted from the proxy session gate.**
`proxy.ts` rejects any `/api/*` request without a session cookie before it
ever reaches the route, so every key-authenticated path has to be listed in
`SESSION_GATE_EXEMPT_PREFIXES` (`lib/admin-auth.ts`). Those paths are not
public — each route still authorizes itself — but forgetting one shows up as a
bare `{"error":"Unauthorized"}` that the route never sees.

**`POST /api/contacts/bulk-delete` stays session-only.** Mass deletion is not
something an agent should reach even with a write scope, and carving out a
`contacts:delete` scope for one route is not worth the vocabulary. Delete from
the dashboard.

## Scopes

Scopes follow the `resource:action` convention already established by
`contacts:write` and `events:write`. Existing keys keep working.

| Scope | Grants |
| --- | --- |
| `projects:read` | enumerate projects and read the key's own organization (organization keys only) |
| `projects:write` | create projects in the key's organization (organization keys only) |
| `contacts:read` | list and export contacts |
| `contacts:write` | create, update, import, sync contacts |
| `lists:read` | list lists and subscriber counts |
| `lists:write` | create and update lists |
| `templates:read` | read email templates |
| `templates:write` | create and update email templates |
| `campaigns:read` | list campaigns with delivery/engagement stats |
| `campaigns:preview` | render a campaign and resolve its recipient set |
| `campaigns:send` | **actually send** |
| `events:write` | submit and preview transactional events |
| `events:send` | **actually deliver** a transactional event |
| `automations:read` | list automations, steps and run history |
| `insights:read` | mentions (beacons), revenue metrics, store reviews |
| `*` | everything |

One endpoint requires two scopes at once: `GET /api/sends` returns contact
identities crossed with delivery activity, so it demands `campaigns:read`
**and** `contacts:read` — `campaigns:read` alone must not become a back door
to recipient emails. `requireProjectAccessOrKey` accepts an array for exactly
this shape.

Every key-authenticated request is recorded in `api_key_audit` (key, route,
scope, outcome; 90-day retention) — `pnpm key-audit [days] [key-id]` reads it.
**Scopes are fixed at creation**, so keys issued before `campaigns:read` /
`automations:read` existed will 403 on the new read tools until re-issued.

### The split that matters

Preview and send are separate on purpose, twice: `campaigns:preview` vs
`campaigns:send`, and `events:write` vs `events:send`. The second exists
because a key that can loop a per-recipient transactional send over the
contact list is a key that can send a campaign — one email at a time. An agent
key is provisioned with everything *except* the two send scopes:

Create one under **Settings → API keys** in the dashboard — the **agent**
preset selects exactly this scope set — or from the CLI:

```bash
pnpm org-key:create <org-slug> "Claude Code" --agent   # every project
pnpm api-key:create <project> "Claude Code" --agent    # one project
```

The model can draft a campaign, resolve exactly who would receive it, and
render the final HTML — and it cannot send, regardless of what it decides to
do. The guarantee lives in the credential, not in a prompt.

Granting send is a deliberate, separate act:

```bash
pnpm api-key:create <project> "CI release mailer" campaigns:send
pnpm api-key:create <project> "App server" events:write,events:send
```

## Preview

`POST /api/send` accepts `mode: "preview"`. It resolves the recipient set and
renders the email exactly as the send path would — same locale resolution, same
token interpolation, same template — then returns instead of sending. No
campaign row is written, no QStash batches are queued, no `send_events` rows
are created.

```jsonc
// → POST /api/send   { "projectId": "mooni", "template": "newsletter",
//                      "subject": "…", "content": "…",
//                      "recipientMode": "list:newsletter", "mode": "preview" }
{
  "mode": "preview",
  "recipientCount": 412,
  "sampleRecipient": { "email": "…", "language": "pt-BR" },
  "subject": "…",
  "html": "<!doctype html>…",
  "text": "…"
}
```

The sample recipient is the first of the resolved set, so the preview shows the
locale a real recipient would get rather than a hardcoded `en`.

## Tool surface

What the server can reach follows from the key it holds. With a project key
(`phk_…`) it is scoped to one project — an agent works in one repo, which is
one app — and a `project` argument naming anything else is refused before the
request leaves the machine. With an organization key (`pha_…`) every
project-scoped tool takes an optional `project`, defaulting to `PHAROS_PROJECT`
when set, and three organization-level tools appear that a project key does not
get at all.

| Tool | Scope | Backed by |
| --- | --- | --- |
| `list_organizations` | `projects:read` | `GET /api/v1/organizations` — org keys only |
| `list_projects` | `projects:read` | `GET /api/v1/projects` — org keys only |
| `create_project` | `projects:write` | `POST /api/v1/projects` — org keys only |
| `list_contacts` | `contacts:read` | `GET /api/contacts` |
| `create_contact` | `contacts:write` | `POST /api/contacts` |
| `update_contact` | `contacts:write` | `PATCH /api/contacts/:id`, resolved by email |
| `sync_contacts` | `contacts:write` | `POST /api/v1/contacts/sync` |
| `list_lists` | `lists:read` | `GET /api/lists` |
| `create_list` | `lists:write` | `POST /api/lists` |
| `list_templates` | `templates:read` | `GET /api/email-templates` |
| `create_template` | `templates:write` | `POST /api/email-templates` |
| `update_template` | `templates:write` | `PATCH /api/email-templates/:id` |
| `list_tokens` | — | `lib/email-render.ts` |
| `list_campaigns` | `campaigns:read` | `GET /api/campaigns`, with send/open/click/bounce stats |
| `list_sends` | `campaigns:read` + `contacts:read` | `GET /api/sends`, per-recipient delivery records |
| `preview_campaign` | `campaigns:preview` | `POST /api/send` with `mode: "preview"` |
| `send_event` | `events:write` preview / `events:send` deliver | `POST /api/v1/events` (preview-default) |
| `list_automations` | `automations:read` | `GET /api/automations` |
| `get_automation` | `automations:read` | `GET /api/automations/:id`, steps + run history |
| `list_mentions` | `insights:read` | `GET /api/mentions/hits`, the beacons inbox |
| `revenue_summary` | `insights:read` | `GET /api/revenue/summary`, monthly rows + latest snapshot |
| `list_reviews` | `insights:read` | `GET /api/reviews`, `maxRating` narrows to complaints |

Automation *editing* stays session-only, like `contacts/bulk-delete`: a
drip sequence is a standing decision about who gets mailed when, and an agent
rewriting one is a send-adjacent act that belongs in the dashboard.

**There is no `send_campaign` tool.** The agent key already lacks
`campaigns:send`, so exposing one would only produce a 403 — but not exposing it
means the model never reaches for a lever it must not pull. Defense in depth,
and one less thing to reason about. Sending happens in the dashboard.

`list_contacts` takes `limit` / `search` / `status`, which `GET /api/contacts`
now applies server-side — handing an agent five thousand rows is not useful,
and a filtered request should not ship the whole contact table over the wire.
(The client re-applies the filters as a no-op backstop against an older
deployment.) `preview_campaign` takes `includeHtml` (default false) for the
same reason — the recipient count and subject come back either way, and the
full rendered email only when asked for.

`list_tokens` matters more than it looks, and carries a caveat that is easy to
get wrong. `lib/email-render.ts` defines the tokens, but `interpolateEmail` only
runs on the **snapshot HTML** path — a stored template's HTML carried onto the
campaign. When a campaign uses a built-in React template with raw `content`,
`renderEmail` renders that copy literally, so a `{{contact.email}}` written
there ships as those exact characters. The tool says so explicitly; without it
an agent would reasonably assume tokens work everywhere.

## Resources

The server serves the existing docs as MCP resources:

- `docs/contact-sync-api.md`
- `docs/llm-and-transactional-events.md`
- `docs/pharos-automations.md`

That is how an agent learns the contract without a bespoke system prompt, and
it means the docs stay the single source of truth for both humans and models.

## Packaging

Lives at `packages/mcp` (the workspace already has `@pharos/observability`) and
builds with tsup to a single ESM entry with a shebang:

```bash
pnpm --filter @pharos/mcp build
```

The package is `private: true` — with no external clients there is nothing to
publish yet, so registration points at the built file directly.

Register once for every repo on the machine, using an organization key:

```bash
claude mcp add pharos -s user \
  -e PHAROS_API_KEY=pha_… \
  -- node /Users/danvilela/Code/Pharos/packages/mcp/dist/index.js
```

`-s user` writes to `~/.claude.json`. Without `PHAROS_PROJECT`, tools take a
`project` argument and `list_projects` shows what the key can reach. An
individual repo can still pin itself with a project-scoped `.mcp.json`:

```jsonc
{
  "mcpServers": {
    "pharos": {
      "command": "node",
      "args": ["/Users/danvilela/Code/Pharos/packages/mcp/dist/index.js"],
      "env": {
        "PHAROS_API_KEY": "pha_…",
        "PHAROS_PROJECT": "mooni"
      }
    }
  }
}
```

`PHAROS_BASE_URL` defaults to `https://pharosbase.com`; point it at
`http://localhost:4326` to work against a local dev server.

**Tradeoff worth naming:** an org key in `~/.claude.json` is readable by
anything running as you and grants write access to contacts, lists and
templates across every project at once. It still cannot send. That is a fine
trade for a solo studio and would need revisiting with customers on the
platform.

Dependencies stay external rather than bundled, so the server runs out of this
repo's `node_modules`. Publishing as `npx -y @pharos/mcp` is the step to take
when there is someone outside this machine to install it — at which point
`docs/` needs copying into the package at build time, since the doc resources
currently resolve relative to the repo root.

## Hosted HTTP endpoint

The same tool surface is served over Streamable HTTP at `/api/mcp`
(`app/api/mcp/route.ts`), which is the zero-install registration:

```bash
claude mcp add pharos -s user -t http https://pharosbase.com/api/mcp \
  -H "Authorization: Bearer pha_…"
```

Both transports register through one `registerPharosTools` in
`packages/mcp/src/server.ts` — stdio builds one client from the environment
for the life of the process, the route builds one per request from the
presented key. The endpoint is stateless (no MCP sessions), and the MCP layer
holds no authority of its own: every tool call is forwarded to the HTTP API
with the caller's key, where scopes, rate limits, expiry and the audit log
apply exactly as if the caller had hit the API directly. A project key's
pinned project — `PHAROS_PROJECT` over stdio — is derived from the key itself
over HTTP.

Auth for the endpoint is the API key itself; MCP OAuth 2.1 is the step to take
when external customers exist and keys-in-config stops being acceptable.

## Bootstrap

Creating a project used to be the one step that forced an agent back into this
repo, and the reason was real: a project key is pinned to a project that
already exists, so it can never be the credential that brings one into being.

The organization key dissolves that. Its reach *is* the organization, so a
project created inside that organization is within what the caller already
holds — it is not an escalation, it is the same boundary. `POST /api/v1/projects`
takes the organization **from the key**, never from the request body, which
makes "create into someone else's organization" unexpressible rather than
merely rejected.

```bash
# → POST /api/v1/projects   { "name": "Acme App", "sesFromEmail": "hi@acme.dev" }
```

```jsonc
{
  "project": { "id": "prj_…", "slug": "acme-app", "name": "Acme App", … },
  "defaultList": { "slug": "newsletter", "name": "Newsletter" }
}
```

The same key reaches the new project immediately, so **no credential is
provisioned during bootstrap** — that is what makes the whole flow one step.
The default `newsletter` list is seeded so the project can accept subscribers
before anyone opens the dashboard.

Both create paths run through `createProject` in `lib/projects.ts`. That
sharing is load-bearing rather than tidiness: child tables (`lists`,
`contacts`, `campaigns`) key on `projects.slug`, not on the row id, so a second
implementation that normalized slugs differently would produce a project whose
own data does not resolve.

`list_organizations` returns an array holding exactly one organization — the
key's. The shape is what a user-scoped credential would return several of, so
keeping it plural now means adding one later is not a breaking change. It reads
on `projects:read` rather than a scope of its own: an org key already is the
organization, and reading back its own name discloses nothing the holder did
not need to know to present the key. The slug is worth having because dashboard
URLs are `/<team>/<project>/…` where `team` is that slug — it is how an agent
hands a human a link.

**`projects:write` is newer than any key issued before it, and scopes are fixed
at creation time.** An existing agent key gets `list_organizations` (it already
carries `projects:read`) but 403s on `create_project` until re-issued:

```bash
pnpm org-key:create <org-slug> "Claude Code" --agent
```

Then update `PHAROS_API_KEY` wherever the server is registered. The client says
as much on a 403 for any scope other than `campaigns:send` — that one is the
design working as intended, and re-issuing will never change it.

Still deliberately absent: **deleting** a project, and provisioning API keys
over the API. Creation is additive and cheap to undo by hand; deletion cascades
across every table and is a dashboard action. A key that can mint keys is a key
that can escape its own scopes, including the `campaigns:send` omission the
whole model rests on.
