DEV Community

Cover image for How I Built an MCP Server That Combines Hunter.io and Apollo for B2B Lead Enrichment
Alex
Alex

Posted on

How I Built an MCP Server That Combines Hunter.io and Apollo for B2B Lead Enrichment

AI agents are powerful, but without real data they're just confident bullshitters. You can plug Claude into a CRM and ask "find me leads in fintech with 50-200 employees" all day, and it'll make stuff up. Until you give it tools that actually fetch data.
That's what MCP servers are for. And that's what I built.
This post walks through b2b-enrichment-mcp, an open-source MCP server I shipped recently. It combines two B2B data APIs (Hunter.io for emails, Apollo.io for company info) into a single tool layer that Claude can use directly. The whole thing is async Python, FastMCP, MIT licensed, and runs on free tier API keys.
I'll show you the architecture, the parts that broke, and how I worked around them.
The Problem With Single-API Lead Gen
If you've ever tried to set up a lead enrichment workflow, you know the pain. Hunter is great for finding emails from a domain. It's not so great at company-level data. Apollo has the company graph, but their email finder is rate-limited into oblivion on the free tier.
So most people end up with one of these setups:

Pay both vendors and write glue code between them
Pick one and pretend the other doesn't exist
Build a Zapier flow that breaks every time a payload looks weird

What you actually want is one interface. The AI agent shouldn't care which API holds which data. It should just say "enrich this domain" and get everything back. That's the whole pitch of MCP, exposing tools to an LLM in a normalized way.
Why MCP and Why FastMCP
Quick context if you're new to MCP. Model Context Protocol is Anthropic's open standard for letting LLMs talk to external tools. Your AI agent on one side, your tool server on the other, structured calls in between. Claude Desktop, Cursor and a bunch of other clients support it natively.
FastMCP is the Python SDK that makes building a server stupidly simple. You write async functions, decorate them with @mcp.tool(), and that's basically it. The SDK handles JSON-RPC, transport, schema generation, all the boring stuff.
Here's what a minimal tool looks like:

from fastmcp import FastMCP
import httpx

mcp = FastMCP("b2b-enrichment")

@mcp.tool()
async def find_emails(domain: str, limit: int = 10) -> dict:
"""Find emails associated with a domain via Hunter.io."""
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.hunter.io/v2/domain-search",
params={"domain": domain, "limit": limit, "api_key": HUNTER_KEY}
)
return response.json()

That's a working tool. Claude can now call find_emails("stripe.com") and get structured data back. No Flask, no FastAPI, no boilerplate.
The Architecture
The server exposes 9 tools split across two providers:
Hunter tools (5):

find_emails_by_domain — list emails for a domain
find_email_by_name — find a specific person's email
verify_email — check deliverability
count_emails — quick volume check before burning API credits
get_account_info — remaining quota, useful for the agent to self-throttle
**
Apollo tools (4):**

enrich_company — company size, industry, tech stack, funding
search_companies — find companies matching criteria
get_company_employees — list people at a company
bulk_enrich — batch up to 10 domains in one call

All async, all sharing a single httpx.AsyncClient instance, all wrapped with the same error handling pattern. The agent picks tools based on intent. If it needs an email, it goes Hunter. If it needs firmographics, it goes Apollo. If it needs both, it calls both and merges the results itself.
Rate Limiting Without Losing Your Mind
This is where most tutorials hand-wave and you discover the hard way that your server is getting 429'd every other call.
Both Hunter and Apollo have generous free tiers, but they're not unlimited. Hunter gives you 25 searches per month on free, Apollo gives you 50 credits. The agent doesn't know this. If you let it just spam calls, you'll burn through credits in five minutes.
The pattern I use is a token bucket per provider, enforced inside the client wrapper:

import asyncio
from time import monotonic

class RateLimiter:
def init(self, rate: float, burst: int):
self.rate = rate
self.burst = burst
self.tokens = burst
self.last = monotonic()
self.lock = asyncio.Lock()

async def acquire(self):
    async with self.lock:
        now = monotonic()
        elapsed = now - self.last
        self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
        self.last = now
        if self.tokens < 1:
            wait = (1 - self.tokens) / self.rate
            await asyncio.sleep(wait)
            self.tokens = 0
        else:
            self.tokens -= 1
Enter fullscreen mode Exit fullscreen mode

Each tool acquires a token before hitting the API. If the bucket is empty, it sleeps. No retries, no exception loops, the agent just waits. Claude is patient, you can let it wait two seconds.
The Apollo Free Tier Trap
Here's the part that bit me, and the part I had to redesign around.
Apollo's free tier officially says you get company enrichment and people search. Halfway through building, I noticed people_search and email_finder started returning empty payloads on free accounts. No error, no 403, just silent nothing. Turns out Apollo quietly restricted these endpoints to paid plans somewhere along the line, and the docs haven't caught up.
My options were:

Tell users they need a paid Apollo account (kills the free tier promise of the project)
Drop Apollo entirely (lose all the company-level data)
Restructure so Hunter does the heavy lifting and Apollo stays for what still works (company enrichment)

I went with option 3. The flow now looks like this:

Email-finding work goes to Hunter (still works on free)
Company firmographics goes to Apollo's enrich_company (still works on free)
People search, gone from public version, kept as a paid-tier code path with a clear note in the README

That decision saved the project. If I'd stayed stubborn on option 1, nobody would have tried it.
The lesson is simple: when you depend on third-party APIs, especially on their free tiers, build like the rug can get pulled. Keep your tool surface modular enough that you can drop a provider without rewriting the whole server.
Fault Isolation When Tools Call Tools
The agent often wants to enrich a domain end-to-end: emails plus company data plus verification. That's three API calls minimum across two providers.
The naive approach is to await them sequentially. Don't. If one provider is down, your whole tool stalls.
The pattern I landed on uses asyncio.gather with return_exceptions=True, so a Hunter outage doesn't kill the Apollo results:

@mcp.tool()
async def full_domain_enrichment(domain: str) -> dict:
"""Combine Hunter and Apollo data for a single domain."""
hunter_task = find_emails_by_domain(domain, limit=20)
apollo_task = enrich_company(domain)

hunter_result, apollo_result = await asyncio.gather(
    hunter_task, apollo_task, return_exceptions=True
)

return {
    "domain": domain,
    "emails": hunter_result if not isinstance(hunter_result, Exception) else {"error": str(hunter_result)},
    "company": apollo_result if not isinstance(apollo_result, Exception) else {"error": str(apollo_result)},
}
Enter fullscreen mode Exit fullscreen mode

The agent gets partial data with clear error fields. It can decide what to do with that, ask the user, retry, ignore. That's the agent's job, not the server's.
Real Usage
A small outbound team I work with runs this server through Claude Desktop. Workflow looks like this:

Sales rep drops a list of target domains into the chat
Claude calls full_domain_enrichment for each
Verified emails get pushed to a Google Sheet via a separate MCP tool
Rep reviews, drafts outreach in Claude with the company context already loaded

Volume is around 300 leads per week. Cost on free tiers is zero. Cost on small paid tiers when they outgrow free is around 30 bucks a month combined. Compare that to a Clay seat or a paid Apollo account, the math works out.
What I'd Do Differently
If I were starting over, I'd build the provider abstraction earlier. Right now Hunter and Apollo each have their own client class. That worked for two providers, but if I add Clearbit or PDL I'll be duplicating a lot of code. Next version, single EnrichmentProvider base class and the specific APIs slot in.
I'd also add a persistent cache from day one. Lots of these lookups are deterministic, the same domain enriched today and tomorrow returns nearly identical data. A simple SQLite cache with a 7-day TTL would cut API usage by 60 to 70 percent on repeat workflows.
Try It
Repo is here: github.com/Aleksey-Panf/b2b-enrichment-mcp
MIT licensed. Free-tier API keys work fine for testing. Setup is git clone, drop your keys in .env, point Claude Desktop or Cursor at it.
If you're building agents and want custom MCP servers wrapping your own data sources or APIs, I do this kind of work. Hit me on Telegram @AlexAi14 or open an issue on the repo.

Top comments (0)