Build Your First AI Project This Weekend
Stop consuming tutorials. Start creating. Get the free step-by-step guide.
The MCP servers that exist today won’t cover your specific use case.
No connector for your CRM. Nothing for your internal analytics platform. That niche API you rely on daily? Forget it.
Waiting for someone else to build what you need is a losing strategy. Building it yourself takes a few hours - even with zero backend experience.
This guide walks through creating a complete MCP server from scratch. We’ll build one for Hunter.io (the email finder tool) as the example. By the end, you’ll have working code and the pattern to connect Claude Code to any API or data source you need.
MCP servers are how you unlock unlimited leverage - connecting AI to your proprietary data, internal tools, and third-party services.
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 a standardized way for AI applications to connect to external tools and data sources.
Think of it like this:
Claude Code is powerful, but it can only see what’s in your codebase and what you paste into the chat. MCP servers extend what Claude can see and do.
An MCP server is a small program that:
- Exposes tools (functions Claude can call)
- Provides resources (data Claude can read)
- Speaks a standard protocol that Claude understands
Example: You build an MCP server for Hunter.io. Now you can ask Claude:
- “Find email addresses for everyone at stripe.com”
- “Verify these 50 emails and tell me which ones are valid”
- “Find the email for John Smith at Acme Corp”
Claude calls your MCP server, gets the data, and does the work. One prompt.
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 use cases, you’ll focus on tools. That’s what we’ll build.
Our Example: Hunter.io API
We’re going to build an MCP server that connects to Hunter.io - the email finding and verification platform.
Why Hunter.io:
- Every marketer doing outreach needs email finding
- Free tier (25 searches/month) lets you test immediately
- Simple API, clear use case
- You’ll actually use this
The tools we’ll build:
domain_search- Find all email addresses at a company domainemail_finder- Find a specific person’s emailemail_verifier- Check if an email is valid
After this guide, you’ll be able to ask Claude:
- “Find email addresses at hubspot.com and list them by department”
- “Find the email for Sarah Jones, CMO at Acme Corp”
- “Verify these emails and tell me which are deliverable”
Get your API key: Sign up at hunter.io and grab your free API key from the dashboard.
Project Setup
We’ll use TypeScript because it’s the most common for MCP servers and has the best SDK support.
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
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 to your package.json:
{
"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 clear sections.
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:
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;
}
}
Important: For STDIO-based servers, never use console.log() - it writes to stdout and corrupts the MCP protocol. Use console.error() for debugging (it writes to stderr).
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.
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
Now ask Claude:
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 will use your MCP server to find the emails, verify them, and give you actionable results.
Using Claude Code to Build Your MCP Server
Here’s the real power move.
You don’t need to write the code yourself. Claude Code can build MCP servers for you.
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.
Marketing MCP Server Ideas
Here are practical MCP servers you could build:
Lead Generation & 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 & 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 & 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 |
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:
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 Errors
Return actionable error messages:
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 - Test with MCP Inspector first
- Check
claude mcp listfor errors
Tools fail silently:
- Use
console.error()for debugging - Check API responses manually with curl
- Verify environment variables are set
API key errors:
- Make sure you’re passing
--envwhen adding the server - Check the key is valid in the API’s dashboard
- Verify you haven’t exceeded rate limits
Your New Superpower
Building MCP servers gives you superpowers.
What you can do now:
- Connect Claude to any marketing tool
- Automate lead research
- Build custom integrations
- Query data without switching apps
The pattern is always the same:
- Create the project structure
- Define your tools with Zod schemas
- Implement the API calls
- Handle responses and errors
- Connect to Claude Code
Or just ask Claude Code to build it for you.
That’s the real leverage. You describe what you want, Claude writes the code, you review and deploy. Custom integrations in hours, not weeks.
Start with the Hunter.io example. Get it working. Then build the MCP server you actually need.
Related Resources
- Claude Code Guide - Complete Claude Code walkthrough
- AI Tools for Media Buyers - AI workflow guide
- MCP Official Docs - Protocol documentation
- MCP TypeScript SDK - SDK reference
Have questions about building MCP servers?