Build Your Own MCP Server: Complete Guide

By Brent Dunn Jan 24, 2026 16 min read

Build Your First AI Project This Weekend

Stop consuming tutorials. Start creating. Get the free step-by-step guide.

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:

  1. Exposes tools (functions Claude can call)
  2. Provides resources (data Claude can read)
  3. 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:

  1. domain_search - Find all email addresses at a company domain
  2. email_finder - Find a specific person’s email
  3. email_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:

  • McpServer is the main class from the MCP SDK
  • StdioServerTransport handles communication via standard input/output
  • z (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:

  1. See all registered tools
  2. Call tools with test inputs
  3. View responses

Test queries to try:

  • domain_search with domain: “hubspot.com”
  • email_finder with domain: “stripe.com”, first_name: “Patrick”, last_name: “Collison”
  • email_verifier with 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-here with 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:

  1. Check the path is absolute (not relative)
  2. Run npm run build to compile
  3. Test with MCP Inspector first
  4. Check claude mcp list for errors

Tools fail silently:

  1. Use console.error() for debugging
  2. Check API responses manually with curl
  3. Verify environment variables are set

API key errors:

  1. Make sure you’re passing --env when adding the server
  2. Check the key is valid in the API’s dashboard
  3. 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:

  1. Create the project structure
  2. Define your tools with Zod schemas
  3. Implement the API calls
  4. Handle responses and errors
  5. 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.


Have questions about building MCP servers?

Contact me.

Previous Claude Code for Marketers: Build Sites With AI Next How to Build a Content Website That Actually Makes Money (2026)