Skip to content

Repository files navigation

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 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).

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

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:

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:

# .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):

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

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 leadIds 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages