<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex</title>
    <description>The latest articles on DEV Community by Alex (@alekseypanf).</description>
    <link>https://dev.to/alekseypanf</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4008875%2Ff865df5d-9fc8-4365-806f-a95a2f4954ed.jpg</url>
      <title>DEV Community: Alex</title>
      <link>https://dev.to/alekseypanf</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alekseypanf"/>
    <language>en</language>
    <item>
      <title>How I Built an MCP Server That Combines Hunter.io and Apollo for B2B Lead Enrichment</title>
      <dc:creator>Alex</dc:creator>
      <pubDate>Tue, 30 Jun 2026 02:26:05 +0000</pubDate>
      <link>https://dev.to/alekseypanf/how-i-built-an-mcp-server-that-combines-hunterio-and-apollo-for-b2b-lead-enrichment-lnk</link>
      <guid>https://dev.to/alekseypanf/how-i-built-an-mcp-server-that-combines-hunterio-and-apollo-for-b2b-lead-enrichment-lnk</guid>
      <description>&lt;p&gt;&lt;strong&gt;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.&lt;/strong&gt;&lt;br&gt;
That's what MCP servers are for. And that's what I built.&lt;br&gt;
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.&lt;br&gt;
I'll show you the architecture, the parts that broke, and how I worked around them.&lt;br&gt;
The Problem With Single-API Lead Gen&lt;br&gt;
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.&lt;br&gt;
So most people end up with one of these setups:&lt;/p&gt;

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

&lt;p&gt;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.&lt;br&gt;
Why MCP and Why FastMCP&lt;br&gt;
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.&lt;br&gt;
FastMCP is the Python SDK that makes building a server stupidly simple. You write async functions, decorate them with &lt;a class="mentioned-user" href="https://dev.to/mcp"&gt;@mcp&lt;/a&gt;.tool(), and that's basically it. The SDK handles JSON-RPC, transport, schema generation, all the boring stuff.&lt;br&gt;
&lt;strong&gt;Here's what a minimal tool looks like:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;from fastmcp import FastMCP&lt;br&gt;
import httpx&lt;/p&gt;

&lt;p&gt;mcp = FastMCP("b2b-enrichment")&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/mcp"&gt;@mcp&lt;/a&gt;.tool()&lt;br&gt;
async def find_emails(domain: str, limit: int = 10) -&amp;gt; dict:&lt;br&gt;
    """Find emails associated with a domain via Hunter.io."""&lt;br&gt;
    async with httpx.AsyncClient() as client:&lt;br&gt;
        response = await client.get(&lt;br&gt;
            "&lt;a href="https://api.hunter.io/v2/domain-search" rel="noopener noreferrer"&gt;https://api.hunter.io/v2/domain-search&lt;/a&gt;",&lt;br&gt;
            params={"domain": domain, "limit": limit, "api_key": HUNTER_KEY}&lt;br&gt;
        )&lt;br&gt;
        return response.json()&lt;/p&gt;

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

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

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

&lt;p&gt;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.&lt;br&gt;
Rate Limiting Without Losing Your Mind&lt;br&gt;
This is where most tutorials hand-wave and you discover the hard way that your server is getting 429'd every other call.&lt;br&gt;
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.&lt;br&gt;
&lt;strong&gt;The pattern I use is a token bucket per provider, enforced inside the client wrapper:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;import asyncio&lt;br&gt;
from time import monotonic&lt;/p&gt;

&lt;p&gt;class RateLimiter:&lt;br&gt;
    def &lt;strong&gt;init&lt;/strong&gt;(self, rate: float, burst: int):&lt;br&gt;
        self.rate = rate&lt;br&gt;
        self.burst = burst&lt;br&gt;
        self.tokens = burst&lt;br&gt;
        self.last = monotonic()&lt;br&gt;
        self.lock = asyncio.Lock()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 &amp;lt; 1:
            wait = (1 - self.tokens) / self.rate
            await asyncio.sleep(wait)
            self.tokens = 0
        else:
            self.tokens -= 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;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.&lt;br&gt;
The Apollo Free Tier Trap&lt;br&gt;
Here's the part that bit me, and the part I had to redesign around.&lt;br&gt;
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.&lt;br&gt;
My options were:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;I went with option 3. The flow now looks like this:&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;That decision saved the project. If I'd stayed stubborn on option 1, nobody would have tried it.&lt;br&gt;
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.&lt;br&gt;
Fault Isolation When Tools Call Tools&lt;br&gt;
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.&lt;br&gt;
The naive approach is to await them sequentially. Don't. If one provider is down, your whole tool stalls.&lt;br&gt;
&lt;strong&gt;The pattern I landed on uses asyncio.gather with return_exceptions=True, so a Hunter outage doesn't kill the Apollo results:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/mcp"&gt;@mcp&lt;/a&gt;.tool()&lt;br&gt;
async def full_domain_enrichment(domain: str) -&amp;gt; dict:&lt;br&gt;
    """Combine Hunter and Apollo data for a single domain."""&lt;br&gt;
    hunter_task = find_emails_by_domain(domain, limit=20)&lt;br&gt;
    apollo_task = enrich_company(domain)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)},
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;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.&lt;br&gt;
Real Usage&lt;br&gt;
A small outbound team I work with runs this server through Claude Desktop. Workflow looks like this:&lt;/p&gt;

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

&lt;p&gt;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.&lt;br&gt;
What I'd Do Differently&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
Try It&lt;br&gt;
&lt;strong&gt;Repo is here: github.com/Aleksey-Panf/b2b-enrichment-mcp&lt;br&gt;
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.&lt;br&gt;
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.&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikcdg6oy01r4qnhou7id.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikcdg6oy01r4qnhou7id.png" alt=" " width="543" height="784"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>mcp</category>
      <category>claude</category>
    </item>
  </channel>
</rss>
