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:
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:
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.
// → 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.mddocs/llm-and-transactional-events.mddocs/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:
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:
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:
{
"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:
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.
# → POST /api/v1/projects { "name": "Acme App", "sesFromEmail": "hi@acme.dev" }
{
"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:
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.