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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues