Skip to main content
Glama
muhammad-zee

leads-mcp-server

by muhammad-zee
README.md
# leads-mcp-server

A remote MCP server that wraps your .NET **LeadCampaign** public API so
Claude can:

- **`get_pending_leads`** — fetch leads with `Status='New'`, never emailed,
  oldest first (same eligibility query as the internal Hangfire job)
- **`report_sent_batch`** — report one batch of send outcomes back in a
  single call (flips status to `Email Sent`, or drops/retries on failure)

```
Claude  <-- MCP (HTTP) -->  this server  <-- HTTPS + X-Api-Key -->  LeadCampaign API  <-->  SQL Server
```

Each pending lead comes back with a **pre-rendered `subject` and `html`**
from your template system. Claude's job is to send that content as-is via
the Gmail connector — not rewrite it. Branding stays owned by your
LeadCampaign templates, same as the internal job.

---

## ⚠️ Three things to know before running this for real

These aren't edge cases — they're the actual failure modes of this design.

### 1. No reservation — you can double-email people

`/pending` does **not** lock or mark leads. The same leads come back on
every call until you `report_sent_batch` for them. That's intentional on
the API side (a crashed worker loses nothing), but it means:

- **Run one worker at a time.** Don't have two sessions/processes pulling
  from this MCP server concurrently.
- **Always fully report a batch before fetching the next one.**

This server enforces the second point for you: `get_pending_leads` tracks
which `leadId`s are outstanding (fetched, not yet reported) in memory and
**refuses to fetch again** while any remain. You'll see an explicit error
telling you which leads still need reporting. This guard only covers a
single running process — it resets on restart, and doesn't coordinate
across multiple instances (see "Scaling" below).

### 2. `isPermanentFailure` is asymmetric risk, not a coin flip

- Set it `true` on a merely **temporary** failure → a good lead is
  **silently dropped forever**.
- Leave it `false` on a genuine **hard bounce** → you retry a dead address
  **every hour** until someone notices.

**When unsure, leave it `false`.** Only set `true` for unambiguous
permanent failures (SMTP 5xx "no such user", malformed address). The tool
description in `server.js` states this explicitly so Claude defaults
correctly without being told each time.

### 3. `/public` bypasses JWT entirely — the API key is the *only* guard

Any path containing `"public"` skips `JwtMiddleware` on the .NET side, so
`[ApiKey]` is the sole protection on both endpoints — one returns lead PII,
the other mutates lead status.

- Set the key via the **env var** `LeadCampaign__ExternalApiKey` on the
  .NET host (double underscore — ASP.NET's env-var form of the nested
  config key `LeadCampaign:ExternalApiKey`).
- **Never** commit it into `appsettings.json`.
- This MCP server's own `LEADS_API_KEY` env var must hold that exact same
  secret — it's sent as `X-Api-Key` on every call.
- Treat this key like a database password: anyone who has it can read all
  lead PII and mutate lead status, no second factor involved.

---

## One thing you need to decide: coordinating with the internal Hangfire job

You now have two systems capable of sending the same emails: the internal
Hangfire job and this MCP worker. They share the eligibility query, so they
won't double-pick the *same* lead — but if both run at the same time,
they'll **interleave sends against the same Namecheap sending limit**, and
only the internal job enforces `MaxPerHour`/`MaxPerDay`. This MCP path
deliberately has no quota awareness (that's what "keep it simple" meant).

**Simplest, recommended: set `LeadCampaign:Enabled = false`** so this MCP
worker is the only sender. No coordination needed, no risk of blowing
through your sending limit.

**If you want both running simultaneously**, the quota check needs to move
to a place both paths go through — realistically, that means adding a
clamp to `/pending` itself on the .NET side (reject/limit requests once
the shared hourly/daily count is hit), not something this MCP server can
enforce on its own, since the limit is about total volume across *both*
senders. Say so if you want help specifying that change.

---

## Local setup

```bash
npm install
cp .env.example .env
# edit .env with your real values
npm start
```

Server starts on `http://localhost:8787` (or your chosen `PORT`), with the
MCP endpoint at `POST /mcp`, and an unauthenticated `GET /health` for
uptime checks.

## Test end-to-end before touching real data

A mock version of the LeadCampaign API is included, matching the real
response shapes (including the `status`/`data` envelope and the
`updated`/`failed`/`alreadySent`/`notFound` summary).

**Terminal 1 — mock API:**
```bash
npm run mock-api
# -> mock LeadCampaign API listening on http://localhost:4000
# -> Auth header: X-Api-Key: mock-key
```

**Terminal 2 — MCP server pointed at the mock:**
```bash
# .env
LEADS_API_BASE_URL=http://localhost:4000
LEADS_API_KEY=mock-key
MCP_SERVER_AUTH_TOKEN=dev-secret

npm start
```

**Terminal 3 — test client** (acts like Claude would):
```bash
MCP_SERVER_URL=http://localhost:8787/mcp \
MCP_SERVER_AUTH_TOKEN=dev-secret \
npm test
```

This exercises both tools with a realistic mixed batch (one success, one
permanent failure, one transient failure) and prints the full response,
including the `updated`/`failed`/`alreadySent`/`notFound` counts.

Once that's clean, point `.env` at your real (ideally staging) API and
re-run before deploying.

## Deploy it somewhere reachable

Claude needs to reach this over HTTPS. Roughly easiest first:

- **Render / Fly.io / Railway** — push the repo, set env vars in their
  dashboard.
- **Your own infra** — any VM/container behind HTTPS. If the LeadCampaign
  API is only reachable from inside your network, deploy this server on
  the same private network and expose it via VPN or a Claude Enterprise
  private network connection — since the API is only guarded by an API
  key (see point 3 above), keeping it off the open internet where
  possible is worth the extra setup.

Whichever you pick:
- Serve over **HTTPS**
- `MCP_SERVER_AUTH_TOKEN` set to a long random secret, never blank
- Only your Claude org's connector config ever sees that secret

### Deploying with Docker

```bash
docker build -t leads-mcp-server .
docker run -p 8787:8787 \
  -e LEADS_API_BASE_URL=https://your-api.example.com \
  -e LEADS_API_KEY=your-real-key \
  -e MCP_SERVER_AUTH_TOKEN=your-long-random-secret \
  leads-mcp-server
```

## Register it as a connector in Claude

1. Go to your Claude connector/settings admin panel
2. Add a new custom MCP connector
3. URL: `https://<your-deployed-host>/mcp`
4. Auth: Bearer token = the same value as `MCP_SERVER_AUTH_TOKEN`
5. Save — Claude will discover `get_pending_leads` and `report_sent_batch`

## What the workflow looks like once this is live

> "Send today's pending lead emails and report the results."

Claude would:
1. Call `get_pending_leads(take=100)`
2. Send each `subject`/`html` as-is via Gmail (no rewriting)
3. Call `report_sent_batch` once with the outcome of every lead attempted
   — successes, and any failures correctly marked permanent vs. transient
4. If the batch was partial for any reason, the tool response says exactly
   which `leadId`s are still outstanding and blocks the next fetch until
   they're reported

## Scaling / production hardening notes

- The single-flight lock (point 1 above) is **in-memory, single-process
  only**. If you ever run multiple instances of this server, replace it
  with a shared store (Redis, a DB row, etc.) or the "one worker at a
  time" guarantee breaks.
- Add retry/backoff in `callLeadsApi` if the LeadCampaign API has its own
  rate limits.
- Add structured logging (who called what, when, which leadIds) — this
  touches PII and mutates status, so an audit trail is worth having from
  day one, not added later.
- Consider whether `LEADS_API_KEY` should be scoped/rotatable
  independently of other API keys, given it's the sole guard on a
  PII-exposing endpoint.