A remote MCP server that wraps your .NET LeadCampaign public API so Claude can:
get_pending_leads— fetch leads withStatus='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 toEmail 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.
These aren't edge cases — they're the actual failure modes of this design.
/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 leadIds 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).
- Set it
trueon a merely temporary failure → a good lead is silently dropped forever. - Leave it
falseon 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.
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__ExternalApiKeyon the .NET host (double underscore — ASP.NET's env-var form of the nested config keyLeadCampaign:ExternalApiKey). - Never commit it into
appsettings.json. - This MCP server's own
LEADS_API_KEYenv var must hold that exact same secret — it's sent asX-Api-Keyon 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.
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.
npm install
cp .env.example .env
# edit .env with your real values
npm startServer starts on http://localhost:8787 (or your chosen PORT), with the
MCP endpoint at POST /mcp, and an unauthenticated GET /health for
uptime checks.
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:
npm run mock-api
# -> mock LeadCampaign API listening on http://localhost:4000
# -> Auth header: X-Api-Key: mock-keyTerminal 2 — MCP server pointed at the mock:
# .env
LEADS_API_BASE_URL=http://localhost:4000
LEADS_API_KEY=mock-key
MCP_SERVER_AUTH_TOKEN=dev-secret
npm startTerminal 3 — test client (acts like Claude would):
MCP_SERVER_URL=http://localhost:8787/mcp \
MCP_SERVER_AUTH_TOKEN=dev-secret \
npm testThis 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.
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_TOKENset to a long random secret, never blank- Only your Claude org's connector config ever sees that secret
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- Go to your Claude connector/settings admin panel
- Add a new custom MCP connector
- URL:
https://<your-deployed-host>/mcp - Auth: Bearer token = the same value as
MCP_SERVER_AUTH_TOKEN - Save — Claude will discover
get_pending_leadsandreport_sent_batch
"Send today's pending lead emails and report the results."
Claude would:
- Call
get_pending_leads(take=100) - Send each
subject/htmlas-is via Gmail (no rewriting) - Call
report_sent_batchonce with the outcome of every lead attempted — successes, and any failures correctly marked permanent vs. transient - If the batch was partial for any reason, the tool response says exactly
which
leadIds are still outstanding and blocks the next fetch until they're reported
- 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
callLeadsApiif 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_KEYshould be scoped/rotatable independently of other API keys, given it's the sole guard on a PII-exposing endpoint.