Build Your First AI Project This Weekend
Stop consuming tutorials. Start creating. Get the free step-by-step guide.
You’ve got Claude Code running. You’ve built a website or two. Now you want it to do more.
The problem: Claude can’t see your CRM, your analytics, or that API your business runs on. You’re stuck copy-pasting data between tools like it’s 2015.
MCP servers fix this. They let Claude call external APIs directly.
I built my first MCP server in about two hours, with zero backend experience. It connected Claude to Hunter.io for email prospecting. Now when I need contact info for outreach, I ask Claude. One prompt, data delivered.
This guide walks you through building that exact server. You’ll end up with three working tools: domain search, email finder, and email verification. More importantly, you’ll understand the pattern well enough to connect Claude to any API your business needs.
That’s the real advantage. Custom integrations your competitors can’t access because they’re waiting for someone else to build them.
Quick navigation
| Section | What you’ll learn |
|---|---|
| What is an MCP server | The basics in plain English |
| Project setup | Get your environment ready |
| Building the server | Complete walkthrough with code |
| Testing your server | Verify it works |
| Installing in Claude Code | Connect it to Claude |
| Using Claude to build MCP servers | Let AI do the heavy lifting |
| Marketing use cases | Practical applications |
What is an MCP server
MCP (Model Context Protocol) is how you give Claude access to external tools and data.
Without MCP, Claude Code can only see your codebase and whatever you paste into the chat. With MCP, Claude can call APIs, query databases, and pull data from anywhere.
An MCP server is a small program you run locally. It:
- Exposes tools (functions Claude can call)
- Provides resources (data Claude can read)
- Speaks a standard protocol Claude understands
Once you connect the Hunter.io MCP server, you can ask Claude things like:
- “Find email addresses for decision makers at stripe.com”
- “Verify these 50 emails and tell me which ones will bounce”
- “Find the email for Sarah Jones, CMO at Acme Corp”
One prompt. Data delivered. No tab switching, no manual lookups.
MCP server components
| Component | What it does | Example |
|---|---|---|
| Tools | Functions Claude can execute | domain_search(domain), verify_email(email) |
| Resources | Data Claude can read | Database records, API responses |
| Prompts | Pre-built templates | “Analyze this data and summarize” |
For most marketing use cases, you’ll focus on tools. That’s what we’ll build.
Why Hunter.io for this example
I picked Hunter.io because it solves a real problem: finding contact info for outreach.
If you’re doing cold email, affiliate outreach, or partnership development, you need emails. Hunter.io finds them. The free tier gives you 25 searches per month, enough to test this integration.
The tools we’ll build:
domain_search- Find all email addresses at a companyemail_finder- Find a specific person’s emailemail_verifier- Check if an email will bounce
After this, when you need contact info, you’ll ask Claude instead of logging into another dashboard.
Before you start: Sign up at hunter.io and grab your free API key from the dashboard. You’ll need it.
Project setup
We’ll use TypeScript. It has the best MCP SDK support and most production servers use it.
If you prefer Python, the official MCP SDK supports it. Same concepts, different syntax. But I recommend following along with TypeScript first since that’s what this guide covers.
Prerequisites
- Node.js 18+ installed
- A code editor (VS Code recommended)
- Hunter.io API key (free tier works)
Create the project
# Create project directory
mkdir hunter-mcp
cd hunter-mcp
# Initialize npm project
npm init -y
# Install dependencies
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
# Create source directory
mkdir src
Configure TypeScript (tsconfig.json)
Create tsconfig.json in your project root:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Update package.json
Add these fields:
{
"type": "module",
"bin": {
"hunter-mcp": "./build/index.js"
},
"scripts": {
"build": "tsc",
"start": "node build/index.js"
}
}
Building the server step by step
Now let’s write the actual server. I’ll break this into sections so you understand what each part does. If you want to skip ahead, the complete code is in the Complete code section.
Step 1: Imports and setup
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Hunter.io API configuration
const HUNTER_API_BASE = "https://api.hunter.io/v2";
const API_KEY = process.env.HUNTER_API_KEY;
// Validate API key exists
if (!API_KEY) {
console.error("HUNTER_API_KEY environment variable is required");
console.error("Get your free API key at https://hunter.io");
process.exit(1);
}
// Create server instance
const server = new McpServer({
name: "hunter",
version: "1.0.0",
});
What’s happening here:
McpServeris the main class from the MCP SDKStdioServerTransporthandles communication via standard input/outputz(Zod) is used for input validation and schema generation- We read the API key from environment variables (secure, not hardcoded)
Step 2: Helper function for API requests
Add this below your imports:
// Helper function for Hunter API requests
async function hunterRequest<T>(endpoint: string, params: Record<string, string> = {}): Promise<T | null> {
const url = new URL(`${HUNTER_API_BASE}${endpoint}`);
url.searchParams.set("api_key", API_KEY!);
// Add any additional params
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
try {
const response = await fetch(url.toString());
if (!response.ok) {
const error = await response.json();
console.error(`Hunter API error: ${error.errors?.[0]?.details || response.statusText}`);
return null;
}
const result = await response.json();
return result.data as T;
} catch (error) {
console.error("Request failed:", error);
return null;
}
}
Critical: Never use console.log() in STDIO servers. It writes to stdout and corrupts the MCP protocol. Use console.error() for debugging instead (it writes to stderr). This trips up more people than you’d expect.
Step 3: Define response types
Add TypeScript interfaces for the API responses:
// Type definitions for Hunter.io API responses
interface DomainSearchResult {
domain: string;
disposable: boolean;
webmail: boolean;
accept_all: boolean;
pattern: string | null;
organization: string | null;
emails: Array<{
value: string;
type: string;
confidence: number;
first_name: string | null;
last_name: string | null;
position: string | null;
department: string | null;
linkedin: string | null;
twitter: string | null;
phone_number: string | null;
}>;
}
interface EmailFinderResult {
first_name: string;
last_name: string;
email: string;
score: number;
domain: string;
position: string | null;
twitter: string | null;
linkedin_url: string | null;
company: string | null;
}
interface EmailVerifierResult {
email: string;
status: "valid" | "invalid" | "accept_all" | "webmail" | "disposable" | "unknown";
result: "deliverable" | "undeliverable" | "risky" | "unknown";
score: number;
regexp: boolean;
gibberish: boolean;
disposable: boolean;
webmail: boolean;
mx_records: boolean;
smtp_server: boolean;
smtp_check: boolean;
accept_all: boolean;
block: boolean;
}
Step 4: Register the domain search tool
Now add the first tool. This finds all emails at a company:
// Tool 1: Domain Search - Find emails at a company
server.tool(
"domain_search",
"Find email addresses associated with a company domain. Returns names, positions, and confidence scores.",
{
domain: z.string().describe("Company domain to search (e.g., 'stripe.com')"),
limit: z.number().optional().default(10).describe("Max results to return (default 10, max 100)")
},
async ({ domain, limit }) => {
const data = await hunterRequest<DomainSearchResult>("/domain-search", {
domain,
limit: String(Math.min(limit, 100))
});
if (!data) {
return {
content: [{ type: "text", text: `Could not search domain: ${domain}. Check if the domain is valid.` }]
};
}
if (data.emails.length === 0) {
return {
content: [{ type: "text", text: `No emails found for ${domain}. This could mean:\n- The domain has no public emails\n- Hunter hasn't indexed this domain yet` }]
};
}
// Format the results
const emailList = data.emails.map(email => {
const name = [email.first_name, email.last_name].filter(Boolean).join(" ") || "Unknown";
const role = email.position || email.department || "Unknown role";
const confidence = email.confidence;
let extras = [];
if (email.linkedin) extras.push(`LinkedIn: ${email.linkedin}`);
if (email.phone_number) extras.push(`Phone: ${email.phone_number}`);
return `- ${email.value}
Name: ${name}
Role: ${role}
Confidence: ${confidence}%${extras.length ? "\n " + extras.join("\n ") : ""}`;
}).join("\n\n");
const summary = `
Domain: ${data.domain}
Organization: ${data.organization || "Unknown"}
Email Pattern: ${data.pattern || "Unknown"}
Accept-All: ${data.accept_all ? "Yes (can't verify individual emails)" : "No"}
Found ${data.emails.length} emails:
${emailList}
`.trim();
return {
content: [{ type: "text", text: summary }]
};
}
);
Breaking this down:
server.tool()registers a new tool with Claude- First argument: tool name (what Claude calls)
- Second argument: description (helps Claude know when to use it)
- Third argument: input schema using Zod (validates inputs)
- Fourth argument: the actual function that runs
Step 5: Register the email finder tool
This finds a specific person’s email:
// Tool 2: Email Finder - Find a specific person's email
server.tool(
"email_finder",
"Find the email address of a specific person at a company. Provide their name and company domain.",
{
domain: z.string().describe("Company domain (e.g., 'stripe.com')"),
first_name: z.string().describe("Person's first name"),
last_name: z.string().describe("Person's last name")
},
async ({ domain, first_name, last_name }) => {
const data = await hunterRequest<EmailFinderResult>("/email-finder", {
domain,
first_name,
last_name
});
if (!data || !data.email) {
return {
content: [{
type: "text",
text: `Could not find email for ${first_name} ${last_name} at ${domain}.
Possible reasons:
- Person doesn't exist at this company
- Email pattern not found
- Name spelling might be different
Try:
- Check LinkedIn for correct spelling
- Try domain_search to see all emails at ${domain}`
}]
};
}
let extras = [];
if (data.position) extras.push(`Position: ${data.position}`);
if (data.linkedin_url) extras.push(`LinkedIn: ${data.linkedin_url}`);
if (data.twitter) extras.push(`Twitter: @${data.twitter}`);
const result = `
Found email for ${data.first_name} ${data.last_name}:
Email: ${data.email}
Confidence: ${data.score}%
Domain: ${data.domain}
${extras.length ? "\n" + extras.join("\n") : ""}
Note: ${data.score >= 90 ? "High confidence - likely accurate" : data.score >= 70 ? "Medium confidence - probably accurate" : "Low confidence - verify before using"}
`.trim();
return {
content: [{ type: "text", text: result }]
};
}
);
Step 6: Register the email verifier tool
This checks if an email is deliverable:
// Tool 3: Email Verifier - Check if an email is valid
server.tool(
"email_verifier",
"Verify if an email address is valid and deliverable. Returns detailed verification results.",
{
email: z.string().email().describe("Email address to verify")
},
async ({ email }) => {
const data = await hunterRequest<EmailVerifierResult>("/email-verifier", { email });
if (!data) {
return {
content: [{ type: "text", text: `Could not verify email: ${email}. Try again later.` }]
};
}
// Build a clear status message
let statusMessage: string;
switch (data.result) {
case "deliverable":
statusMessage = "VALID - This email should receive messages";
break;
case "undeliverable":
statusMessage = "INVALID - This email will bounce";
break;
case "risky":
statusMessage = "RISKY - May or may not deliver (accept-all server or catch-all)";
break;
default:
statusMessage = "UNKNOWN - Could not determine deliverability";
}
// Build details
const checks = [
`Format Valid: ${data.regexp ? "Yes" : "No"}`,
`MX Records: ${data.mx_records ? "Found" : "Missing"}`,
`SMTP Server: ${data.smtp_server ? "Responding" : "Not responding"}`,
`SMTP Check: ${data.smtp_check ? "Passed" : "Failed"}`,
`Gibberish: ${data.gibberish ? "Yes (suspicious)" : "No"}`,
`Disposable: ${data.disposable ? "Yes (temporary email)" : "No"}`,
`Webmail: ${data.webmail ? "Yes (Gmail, Yahoo, etc.)" : "No (company email)"}`,
`Accept-All: ${data.accept_all ? "Yes (can't fully verify)" : "No"}`
];
const result = `
Email: ${data.email}
Status: ${statusMessage}
Score: ${data.score}/100
Verification Details:
${checks.map(c => "- " + c).join("\n")}
Recommendation: ${
data.result === "deliverable" ? "Safe to send" :
data.result === "undeliverable" ? "Do NOT send - will bounce" :
data.result === "risky" ? "Send with caution - monitor bounces" :
"Verify manually before sending"
}
`.trim();
return {
content: [{ type: "text", text: result }]
};
}
);
Step 7: Start the server
Add this at the bottom of your file:
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Hunter.io MCP Server running");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Complete code
Here’s the full src/index.ts for easy copying:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const HUNTER_API_BASE = "https://api.hunter.io/v2";
const API_KEY = process.env.HUNTER_API_KEY;
if (!API_KEY) {
console.error("HUNTER_API_KEY environment variable is required");
process.exit(1);
}
const server = new McpServer({ name: "hunter", version: "1.0.0" });
// Types
interface DomainSearchResult {
domain: string;
organization: string | null;
pattern: string | null;
accept_all: boolean;
emails: Array<{
value: string;
confidence: number;
first_name: string | null;
last_name: string | null;
position: string | null;
department: string | null;
linkedin: string | null;
phone_number: string | null;
}>;
}
interface EmailFinderResult {
first_name: string;
last_name: string;
email: string;
score: number;
domain: string;
position: string | null;
linkedin_url: string | null;
}
interface EmailVerifierResult {
email: string;
result: "deliverable" | "undeliverable" | "risky" | "unknown";
score: number;
regexp: boolean;
gibberish: boolean;
disposable: boolean;
webmail: boolean;
mx_records: boolean;
smtp_server: boolean;
smtp_check: boolean;
accept_all: boolean;
}
// API helper
async function hunterRequest<T>(endpoint: string, params: Record<string, string> = {}): Promise<T | null> {
const url = new URL(`${HUNTER_API_BASE}${endpoint}`);
url.searchParams.set("api_key", API_KEY!);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
try {
const response = await fetch(url.toString());
if (!response.ok) return null;
const result = await response.json();
return result.data as T;
} catch {
return null;
}
}
// Tool 1: Domain Search
server.tool(
"domain_search",
"Find email addresses at a company domain",
{
domain: z.string().describe("Company domain (e.g., stripe.com)"),
limit: z.number().optional().default(10).describe("Max results (default 10)")
},
async ({ domain, limit }) => {
const data = await hunterRequest<DomainSearchResult>("/domain-search", {
domain,
limit: String(Math.min(limit, 100))
});
if (!data || data.emails.length === 0) {
return { content: [{ type: "text", text: `No emails found for ${domain}` }] };
}
const emails = data.emails.map(e => {
const name = [e.first_name, e.last_name].filter(Boolean).join(" ") || "Unknown";
return `- ${e.value} | ${name} | ${e.position || "Unknown role"} | ${e.confidence}% confidence`;
}).join("\n");
return {
content: [{
type: "text",
text: `Found ${data.emails.length} emails at ${data.domain}:\n\n${emails}`
}]
};
}
);
// Tool 2: Email Finder
server.tool(
"email_finder",
"Find a specific person's email at a company",
{
domain: z.string().describe("Company domain"),
first_name: z.string().describe("First name"),
last_name: z.string().describe("Last name")
},
async ({ domain, first_name, last_name }) => {
const data = await hunterRequest<EmailFinderResult>("/email-finder", {
domain, first_name, last_name
});
if (!data?.email) {
return { content: [{ type: "text", text: `No email found for ${first_name} ${last_name} at ${domain}` }] };
}
return {
content: [{
type: "text",
text: `Found: ${data.email}\nConfidence: ${data.score}%\nPosition: ${data.position || "Unknown"}`
}]
};
}
);
// Tool 3: Email Verifier
server.tool(
"email_verifier",
"Verify if an email address is valid and deliverable",
{
email: z.string().email().describe("Email to verify")
},
async ({ email }) => {
const data = await hunterRequest<EmailVerifierResult>("/email-verifier", { email });
if (!data) {
return { content: [{ type: "text", text: `Could not verify: ${email}` }] };
}
const status = data.result === "deliverable" ? "VALID" :
data.result === "undeliverable" ? "INVALID" :
data.result === "risky" ? "RISKY" : "UNKNOWN";
return {
content: [{
type: "text",
text: `${email}: ${status} (Score: ${data.score}/100)\nDisposable: ${data.disposable ? "Yes" : "No"}\nWebmail: ${data.webmail ? "Yes" : "No"}`
}]
};
}
);
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Hunter MCP Server running");
}
main().catch(console.error);
Testing your server
Build the server
npm run build
This compiles TypeScript to JavaScript in the build/ directory.
Test with MCP inspector
The MCP Inspector lets you test your server before connecting it to Claude:
HUNTER_API_KEY=your-key-here npx @modelcontextprotocol/inspector node build/index.js
This opens a web interface where you can:
- See all registered tools
- Call tools with test inputs
- View responses
Test queries to try:
domain_searchwith domain: “hubspot.com”email_finderwith domain: “stripe.com”, first_name: “Patrick”, last_name: “Collison”email_verifierwith a known email address
Installing in Claude Code
Once your server works, connect it to Claude Code. If you haven’t set up Claude Code yet, see the Claude Code guide first.
Add to Claude Code
claude mcp add hunter --env HUNTER_API_KEY=your-key-here -- node /ABSOLUTE/PATH/TO/hunter-mcp/build/index.js
Replace:
your-key-herewith your actual Hunter.io API key/ABSOLUTE/PATH/TO/with the actual path to your project
Verify installation
In Claude Code, run:
/mcp
You should see hunter listed with 3 tools.
Test it
Ask Claude something like:
Find email addresses for decision makers at notion.so
Or:
Find the email for Brian Chesky at airbnb.com and verify if it's deliverable
Claude calls your MCP server, pulls the data, and returns actionable results. No more logging into Hunter.io every time you need a contact.
Using Claude Code to build your MCP server
Here’s the shortcut most people miss.
You don’t need to write MCP servers yourself. Claude Code builds them for you.
I’ve used this approach to create servers in under an hour that would have taken a full day writing from scratch. It works because MCP servers follow a predictable structure, and Claude knows the pattern.
The prompt template
Build an MCP server in TypeScript for [API NAME].
API Documentation: [URL or paste docs]
The server should have these tools:
1. [TOOL NAME]
- Description: [what it does]
- Inputs: [parameters needed]
- Output: [what to return]
2. [TOOL NAME]
- Description: [what it does]
- Inputs: [parameters needed]
- Output: [what to return]
Requirements:
- Use the @modelcontextprotocol/sdk package
- Use Zod for input validation
- Handle errors gracefully
- Use STDIO transport
- Never use console.log (use console.error for debugging)
- Read API key from environment variable
Create all files including package.json and tsconfig.json.
Include installation and usage instructions.
Real example: Ahrefs MCP server
Build an MCP server in TypeScript for the Ahrefs API.
API Docs: https://ahrefs.com/api/documentation
The server should have these tools:
1. backlinks_one_per_domain
- Description: Get backlinks to a URL (one per referring domain)
- Inputs: target (URL), limit (number)
- Output: List of backlinks with DR, traffic, anchor text
2. organic_keywords
- Description: Get organic keywords a URL ranks for
- Inputs: target (URL), country (string), limit (number)
- Output: Keywords with position, volume, traffic
3. domain_rating
- Description: Get domain rating and metrics
- Inputs: target (domain)
- Output: DR, referring domains, backlinks count
Requirements:
- Use env variable AHREFS_API_KEY for authentication
- Use the @modelcontextprotocol/sdk package
- Use Zod for input validation
- Handle rate limits and errors gracefully
- Use STDIO transport
Create all necessary files.
Claude Code will generate the entire server, ready to build and use.
MCP servers worth building for your business
Here are the integrations that actually matter for marketing and lead generation:
Lead generation and outreach
| API | What you could do |
|---|---|
| Hunter.io | Find emails, verify contacts (we just built this) |
| Clearbit | Company enrichment, lead scoring |
| Apollo.io | Prospect search, sequence management |
| Lemlist | Manage outreach campaigns |
SEO and content
| API | What you could do |
|---|---|
| Ahrefs | Backlink analysis, keyword research |
| SEMrush | Competitor analysis, rank tracking |
| SurferSEO | Content optimization scores |
| Clearscope | Content grading and recommendations |
Analytics and data
| API | What you could do |
|---|---|
| Google Analytics | Pull reports, analyze traffic |
| Mixpanel | User behavior, funnel analysis |
| Amplitude | Product analytics queries |
| Your Database | Query customers, segments, revenue |
Advertising
| API | What you could do |
|---|---|
| Meta Marketing API | Campaign data, audience insights |
| Google Ads API | Performance data, keyword ideas |
| TikTok Ads | Campaign metrics, creative performance |
| LinkedIn Ads | B2B campaign management |
The pattern is always the same: find the API docs, define your tools, let Claude build it. Most APIs have enough documentation for Claude to generate a working server on the first try.
Common patterns
Authentication
Most APIs need auth. Here’s the pattern:
const API_KEY = process.env.MY_API_KEY;
if (!API_KEY) {
console.error("MY_API_KEY environment variable is required");
process.exit(1);
}
async function makeRequest(url: string) {
const response = await fetch(url, {
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
}
});
return response.json();
}
Then when adding to Claude Code:
claude mcp add myserver --env MY_API_KEY=your-key -- node /path/to/build/index.js
Rate limiting
Add simple rate limiting to avoid hitting API limits:
let lastRequestTime = 0;
const MIN_INTERVAL = 1000; // 1 second
async function rateLimitedFetch(url: string) {
const elapsed = Date.now() - lastRequestTime;
if (elapsed < MIN_INTERVAL) {
await new Promise(r => setTimeout(r, MIN_INTERVAL - elapsed));
}
lastRequestTime = Date.now();
return fetch(url);
}
Helpful error messages
Return error messages that help Claude (and you) debug issues:
if (!data) {
return {
content: [{
type: "text",
text: `Error: Could not fetch data.
Possible causes:
- Invalid API key
- Rate limit exceeded
- Network issue
Try again in a few seconds.`
}]
};
}
Troubleshooting
Server doesn’t show up in Claude Code:
- Check the path is absolute, not relative
- Run
npm run buildto compile (easy to forget) - Test with MCP Inspector first to verify the server works
- Run
claude mcp listto see if there are errors
Tools fail silently:
- Add
console.error()statements to debug (neverconsole.log) - Test API responses manually with curl
- Verify environment variables are set correctly
API key errors:
- Make sure you passed
--envwhen adding the server - Check the key is valid in the API’s dashboard
- Verify you haven’t exceeded rate limits
“Fatal error in main()”:
- Check your TypeScript compiled successfully (
npm run build) - Look for syntax errors in your code
- Verify all imports are correct and packages installed
What to do next
You now have two options:
Option 1: Build the Hunter.io server yourself. Follow the steps above. Test it with MCP Inspector. Connect it to Claude Code. You’ll have working email prospecting in a couple hours.
Option 2: Let Claude build the MCP server you actually need. Use the prompt template from this guide. Point it at your CRM’s API docs, your analytics platform, or whatever tool you wish Claude could access. Review the code, deploy it, and you’re connected.
Either way, you’re no longer waiting for someone else to build your integrations. That’s a meaningful advantage over competitors still stuck with disconnected tools.
The pattern is always the same: create the project, define tools with Zod schemas, implement API calls, handle errors, connect to Claude Code. Once you’ve done it once, every future MCP server gets easier.
Start now: Build the Hunter.io server, or pick the API you actually need and ask Claude to build it for you.
Related resources
- Claude Code Guide - Get Claude Code set up if you haven’t already
- AI Tools for Media Buyers - Other AI tools for marketing workflows
- MCP Official Docs - Protocol documentation and advanced patterns
- MCP TypeScript SDK - SDK reference
Building something custom and need help? Contact me.