enrichment-mcp
enrichment-mcp: Depth-First Lead Discovery for a Bug-Bounty Offering
Project Overview
Selling a penetration-testing / bug-bounty service is a high-consideration, low-volume sale. Spraying hundreds of generic emails is the wrong shape for it -- the win is a few right companies, each reached through the right person, not a big funnel of weak contacts. Most lead-gen tooling optimises for the opposite: discover everything, enrich everything, auto-send. That produces volume and burns goodwill.
enrichment-mcp inverts that. It is a depth-first lead finder: per run it surfaces a handful of candidate custom-built B2B / high-blast-radius commerce operators (distributors, wholesalers, B2B webshops, marketplaces, trade platforms holding valuable/sensitive customer data -- not low-stakes B2C consumer-hobby shops), qualifies them hard, and for each keeper resolves the single best decision-maker. The deliverable stops at a stored, qualified lead carrying lean context -- enough to recognise the company, one verified contact, and a one-line "why". A human decides what to do next; the system never reaches out and never tests anything.
The design splits cleanly across two execution contexts. Claude (in claude.ai or Claude Code) runs discovery and qualification in-session using its own web_search / web_fetch -- no third-party search API, no LLM key pool. A thin FastMCP server does only the two things the chat sandbox cannot: paid contact resolution through an enrichment chain -- Prospeo primary, Apollo failover (outbound APIs the sandbox blocks) and a durable lead store that remembers state across sessions. This is a ground-up rebuild of an older volume-funnel pipeline (clay-enrichment); roughly 5,000 lines of key-pool and worker machinery were dropped, four assets salvaged.
How It Works
For each run, Claude drives the loop and calls the server only when it must:
Claude session (lead-finder skill)
web_search / web_fetch ── discovery, hard qualification, NO_MATCH fallback mining
│ tool calls over HTTPS (static bearer)
▼
https://enrichment-mcp-<num>.<region>.run.app/mcp (Cloud Run — auto-HTTPS, scales to zero)
│
Cloud Run container (GCP) — env from Secret Manager
FastMCP server ── 7 tools, bearer auth, Streamable HTTP at /mcp
│
▼
Supabase Postgres Prospeo enrich-person ─┐ MyEmailVerifier
(leads + prospeo_usage) (X-KEY multi-key pool) ├─► (validate_single)
Apollo people/match ─┘
(x-api-key, failover)The qualification loop is deliberately discard-heavy:
Discover -- run one to three angles via
web_searchto surface a handful of candidates (not a funnel).Qualify hard --
web_fetcheach site and score it 0-10 against the ICP: is it a custom (non-platform) stack, is it receptive (no existing public bounty program, no in-house AppSec), is its breach blast-radius high (B2B / distributor / marketplace holding valuable/sensitive customer data -- a gated criterion; low-stakes B2C hobby shops are out), is it right-sized to pay and stay unwatched, is there a reachable technical decision-maker.Keep only winners -- discard anything under the
>=7gate immediately and explicitly. Quality over count.Resolve one contact -- name the single best decision-maker, then call
resolve_contact; the server runs the enrichment chain (Prospeo, then Apollo if Prospeo is out of quota or has no record) and returns one verified email + LinkedIn + title, tagged with the provider that found it.Fallback on miss -- on
reason: no_match(every provider answered, none had the person), mine the site's team/about pages in-session, guess the email pattern, and confirm withverify_email; accept only aValidresult.Store lean -- write the lead via
add_qualified_lead. If no contact resolves, the company is still stored (flagged), never thrown away.
Distinguish no_match from reason: provider_unavailable: the latter means every provider was out of quota or unreachable, so nothing was learned about the person -- top up credits rather than falling back to a guess.
Data-flow invariant: web tools run in the session; enrichment and state run on the server. The server holds no loop, no scheduler, no autonomous discovery -- it only acts when Claude calls a tool.
Targeting Scope
Dimension | In scope | Out of scope |
Stack | Custom / bespoke webshops (own checkout, APIs, accounts) | Templated SaaS stores whose security is the platform's problem |
Size | Mid-market: big enough to pay a bounty | Large enterprises with in-house security teams |
Security posture | No existing public bounty program; signs of receptiveness | Companies already on HackerOne / Bugcrowd / Intigriti |
Contact | One reachable decision-maker (CTO / lead dev / founder) | Bulk people enumeration; every employee |
The qualification rubric scores five criteria 0-2 each (custom-stack confidence, receptiveness, attack surface, ability to pay, reachable decision-maker) and sums to a 0-10 bounty_fit_score. The keep-gate is >=7 and custom-stack >=1 and receptiveness >=1, so a clear platform store or an existing bounty program is an automatic discard regardless of total.
Quick Start
Requirements: Python 3.10+ and a Postgres instance (the design targets Supabase; any Postgres works).
# 1. Create a virtualenv and install dependencies
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
# 2. Configure environment
cp .env.example .env
# Edit .env and fill in the values (see the table below)
# 3. Apply the database schema (SUPABASE_DB_URL must be set) -- run EVERY numbered
# file in order; each is idempotent, so re-running is safe.
psql "$SUPABASE_DB_URL" -f schema/001_leads.sql
psql "$SUPABASE_DB_URL" -f schema/002_engagement_statuses.sql
psql "$SUPABASE_DB_URL" -f schema/003_nudge_channels.sql
psql "$SUPABASE_DB_URL" -f schema/004_project_partition.sql
psql "$SUPABASE_DB_URL" -f schema/005_provider_column.sql
# 4. Run the server
.venv/bin/python -m src.mcp_server
# Serves Streamable HTTP at http://MCP_HOST:MCP_PORT/mcp (default 0.0.0.0:8000)Environment variables (.env.example is the authoritative list):
Variable | Purpose |
| Full Postgres DSN to the lead store (required) |
| Comma-separated Prospeo keys (free-tier pool, rotated round-robin) -- primary tier |
|
|
| Comma-separated Apollo keys -- failover tier. Empty = Prospeo-only. Needs a paid Apollo plan; free/trial keys 403 on |
|
|
| MyEmailVerifier key for the guess-and-verify fallback |
| Static bearer the server requires on every request |
| Bind address / port (default |
Usage
Connecting Claude
Claude Code (default path -- static bearer):
claude mcp add --transport http enrichment-mcp \
https://enrichment-mcp-<num>.<region>.run.app/mcp \
--header "Authorization: Bearer <MCP_BEARER_TOKEN>"Use the live Cloud Run service URL (printed by the deploy) with /mcp appended, and the MCP_BEARER_TOKEN from Secret Manager.
claude.ai web connector (OAuth). The web app cannot send a bearer header, so it uses OAuth. The server supports it via MCP_OAUTH_PROVIDER (see auth.py):
workos(recommended) -- WorkOS AuthKit via the fullWorkOSProvider: FastMCP serves the OAuth + DCR endpoints to claude.ai itself and proxies the login to AuthKit with a pre-registered client (so it works even though AuthKit's metadata omits a registration endpoint). SetMCP_OAUTH_PROVIDER=workos,WORKOS_AUTHKIT_DOMAIN=https://<name>.authkit.app,WORKOS_CLIENT_ID,WORKOS_CLIENT_SECRET(the WorkOS API key), andMCP_BASE_URL=<public run.app URL>, then redeploy. Add<MCP_BASE_URL>/auth/callbackto the WorkOS application's redirect URIs. claude.ai (Settings -> Connectors -> Add custom connector -> paste<base>/mcp) then drives the login.oidc-- any OIDC provider (Descope, Auth0, Google, ...) viaOIDCProxy: setMCP_OIDC_CONFIG_URL,MCP_OIDC_CLIENT_ID(+ secret), andMCP_BASE_URL.supabase-- reuse the project's own Supabase Auth, but only if that project advertises an OAuth registration endpoint (many do not -- preferworkos).
Switching modes is pure config -- no code change. Bearer mode (empty MCP_OAUTH_PROVIDER) stays available for Claude Code.
Running a lead hunt
The skills/lead-finder/ skill drives the depth-first workflow end-to-end. In a Claude session with the connector attached, give it a brief ("independent EU streetwear shops on custom stacks") and it runs discovery, qualifies, resolves contacts, and stores keepers -- calling the tools below. Across sessions, get_uncontacted is the durable memory of who is qualified but not yet acted on.
Tools
Lead store (CRM / state)
Tool | Purpose |
| Upsert a qualified lead within a |
| List leads (COMPACT rows) for a |
| Fetch one lead (full row) by domain within a |
| Advance the status enum for a domain within a |
| Leads (compact) at |
Contact resolution (server-side network)
Tool | Purpose |
| Run the Prospeo |
| Verify a guessed address via MyEmailVerifier; the fallback accepts only |
Status flow: qualified --> contact_resolved --> contacted --> replied --> agreement_sent --> signed --> authorized_ready --> running --> reported --> closed / rejected.
Project partition. The leads table is shared by more than one outreach
pipeline (pentest / bug-bounty and the Avelero licensing pipeline). Every row
carries a project tag (pentest | avelero) and every lead-store tool is scoped
to it: reads filter by it, writes stamp it, and a domain owned by one project can't
be taken by the other. The scope is the LEADS_PROJECT env default (pentest),
overridable per call via the tools' project argument. list_leads /
get_uncontacted return a COMPACT projection (scan fields + a truncated summary) so
large lists stay under the MCP output cap; get_lead returns the full row.
Technical Details
Module breakdown
Path | Responsibility |
| FastMCP app, pluggable bearer auth ( |
| Typed config -- plain |
| asyncpg pool built from |
| The five lead-store operations (parametrized, dict returns) |
| Shared |
| Multi-key Prospeo |
| Multi-key Apollo |
| MyEmailVerifier client (inlined |
| Write-free |
|
|
| The Claude-side skill: |
The lead record (intentionally lean)
One leads table, keyed on domain:
Column | Notes |
| Primary key |
| Enough to recognise the lead |
|
|
| The 0-10 qualification score |
| One-line rationale (receptiveness evidence folded in) |
| The funnel enum above |
| The one best contact -- all nullable |
|
|
A second table, prospeo_usage, meters credit burn across the key pools. Its provider column (schema 005) splits Prospeo spend from Apollo spend; the table keeps its original name for migration safety.
Credit model
Call | Cost on hit | Cost on miss |
Prospeo | 1 credit | NO_MATCH is free |
Prospeo | 10 credits | free |
Prospeo duplicate within ~90 days | free | -- |
Apollo | 1 credit | no-data is free |
Apollo | up to 9 credits | free |
MyEmailVerifier | 1 credit | 1 credit (still returns a status) |
Depth-first targeting keeps these low by design -- one resolution per kept lead, not per candidate.
Hosting
The server runs on Google Cloud Run as a container: automatic HTTPS, a public *.run.app URL, and scale-to-zero (no cost when idle). Secrets live in Secret Manager and are injected as env vars; the app-layer bearer is what actually protects the public endpoint (--allow-unauthenticated at the IAM layer so claude.ai, which carries no Google identity, can reach it).
deploy/deploy-cloudrun.sh is the reproducible runbook (project, APIs, secrets, deploy). The Dockerfile builds the image; Cloud Run injects $PORT, which config.py honors. One-liner to redeploy after a code change:
gcloud run deploy enrichment-mcp --source . --region <region> \
--allow-unauthenticated --max-instances 1 \
--set-secrets=MCP_BEARER_TOKEN=MCP_BEARER_TOKEN:latest,SUPABASE_DB_URL=SUPABASE_DB_URL:latest,PROSPEO_API_KEYS=PROSPEO_API_KEYS:latest,APOLLO_API_KEYS=APOLLO_API_KEYS:latest,MYEMAILVERIFIER_API_KEY=MYEMAILVERIFIER_API_KEY:latestCloud Run pins secret versions per revision, so after updating a secret value, redeploy (or roll a new revision) to pick it up.
Roadmap
Platform fingerprint tool -- an optional server-side check (
cdn.shopify.com,/wp-content/, platform headers) if Claude's page-judgment of custom-vs-platform proves unreliable. Deliberately deferred; Claude judges from the page for now.OAuth path for the claude.ai web connector -- FastMCP
OIDCProxy+ a hosted IdP, swappable into the existing auth layer.domain-searchassist -- fall back to Prospeo's domain search when naming the decision-maker is hard (currently out of scope to preserve the one-best principle).
Disclaimer
This is a sales-qualification tool, not a reconnaissance or testing tool. It discovers publicly listed companies, judges their fit for a security-research engagement, and identifies one business contact for a human to approach. It does not contact prospects, does not send email, and does not run or trigger any security test -- those remain separate, human-gated actions. Process contact data in line with GDPR and applicable anti-spam rules, and only pursue testing against a target with explicit written authorization. Built for the author's own bug-bounty offering; not licensed for external distribution.