Prices increase in 00D : 00H : 00M Upgrade now Pay less later.

Blog / Technical Guide

Build Your First MCP Server in 30 Minutes (and Give Claude Real Tools)

Ask Claude for the weather in Berlin and it will politely tell you it can't check. Ask whether jane@acme.io is a real inbox and it will guess.

That isn't a problem with the model. It just doesn't have tools.

In this tutorial you'll fix that by building an MCP server in TypeScript with three tools:

  1. get_weather: live weather from a public API
  2. lookup_customer: a read-only query against a local SQLite database
  3. check_email: a first-pass email verification check (syntax + MX records)

Then you'll connect it to Claude Desktop and Claude Code, test it, and look at what a production-grade MCP server does differently. The example there is a real email verification MCP server.

Time: ~30 minutes · You need: Node.js 20+, npm, Claude Desktop or Claude Code · Last updated: 1 October 2026

TL;DR - An MCP server exposes functions ("tools") that Claude and other AI clients can call. In TypeScript, it takes about 100 lines with @modelcontextprotocol/sdk and zod. - Tool descriptions decide when the model calls a tool, so write them like instructions. - New data: 68.4% of known disposable email domains pass a DIY syntax + MX check (n=500). And because AWS and Google Cloud block port 25 by default, an MCP server hosted there can't check whether a mailbox exists.


What is an MCP server?

An MCP server is a small program that exposes tools, data or prompts to an AI application through the Model Context Protocol (MCP), an open standard introduced by Anthropic in November 2024. The AI client (Claude, Cursor, VS Code and others) discovers the server's tools, decides when to call them, and gets structured results back over JSON-RPC.

Think of it as USB-C for AI apps: build a tool once and every MCP-compatible client can use it.

Why learn it now: MCP has become the default way to give agents tools. - Anthropic donated the protocol to the Linux Foundation in December 2025, reporting 10,000+ active public MCP servers at the time. - By March 2026, the official TypeScript and Python SDKs reached ~97 million monthly downloads, up from about 100K at launch (Wikipedia, Digital Applied).

An MCP server can expose three kinds of things:

Primitive What it is Example
Tools Functions the model can call get_weather(city)
Resources Read-only data the client can load A file, a DB schema
Prompts Reusable prompt templates "Summarize this ticket"

This tutorial focuses on tools, since they're what turns a chatbot into an agent.


Step 1: Set up the project

mkdir mcp-starter && cd mcp-starter
npm init -y
npm install @modelcontextprotocol/sdk zod better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3

In package.json, mark the project as an ES module and add build scripts:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "seed": "node build/seed.js"
  }
}

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Step 2: Create the server skeleton

Create src/index.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "mcp-starter",
  version: "1.0.0",
});

// Tools are registered here (next steps)

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("mcp-starter running on stdio");

⚠️ The #1 beginner bug: with the stdio transport, stdout is the protocol channel. A stray console.log() corrupts the JSON-RPC stream and the client disconnects with a cryptic error. Always log with console.error().


Step 3: Tool #1, live weather

We'll use Open-Meteo, which is free and needs no API key. Add this above the transport code:

server.registerTool(
  "get_weather",
  {
    title: "Current weather",
    description:
      "Get the current weather for a city. Use this when the user asks about weather conditions right now.",
    inputSchema: {
      city: z.string().min(1).describe("City name, e.g. 'Berlin' or 'Austin'"),
    },
  },
  async ({ city }) => {
    const geo = await fetch(
      `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
    ).then((r) => r.json());

    const place = geo.results?.[0];
    if (!place) {
      return {
        content: [{ type: "text", text: `No location found for "${city}".` }],
        isError: true,
      };
    }

    const wx = await fetch(
      `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}` +
        `&current=temperature_2m,relative_humidity_2m,wind_speed_10m`
    ).then((r) => r.json());

    const c = wx.current;
    return {
      content: [
        {
          type: "text",
          text: `${place.name}, ${place.country}: ${c.temperature_2m}°C, humidity ${c.relative_humidity_2m}%, wind ${c.wind_speed_10m} km/h`,
        },
      ],
    };
  }
);

The handler is ordinary code. What makes it work as a tool is the description, which the model reads to decide when to call it. Write descriptions like instructions to a new teammate, not like code comments.


Step 4: Tool #2, database lookup (read-only)

Agents become useful once they can read your data. First, seed a small SQLite database in src/seed.ts:

import Database from "better-sqlite3";

const db = new Database("customers.db");
db.exec(`
  DROP TABLE IF EXISTS customers;
  CREATE TABLE customers (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL,
    plan TEXT NOT NULL,
    signed_up TEXT NOT NULL
  );
`);

const insert = db.prepare(
  "INSERT INTO customers (name, email, plan, signed_up) VALUES (?, ?, ?, ?)"
);
insert.run("Jane Cooper", "jane@acme.io", "pro", "2026-03-14");
insert.run("Ravi Shah", "ravi@example.com", "free", "2026-07-02");
insert.run("Test User", "asdf@mailinator.com", "free", "2026-09-28");
console.log("Seeded customers.db");

Then add the tool to src/index.ts:

import Database from "better-sqlite3";

const db = new Database("customers.db", { readonly: true, fileMustExist: true });

server.registerTool(
  "lookup_customer",
  {
    title: "Look up customer",
    description:
      "Find customers by exact email or by name prefix. Returns up to 5 matches with plan and signup date.",
    inputSchema: {
      query: z.string().min(2).describe("An email address or the start of a customer's name"),
    },
  },
  async ({ query }) => {
    const rows = db
      .prepare(
        "SELECT name, email, plan, signed_up FROM customers WHERE email = ? OR name LIKE ? LIMIT 5"
      )
      .all(query, `${query}%`);

    return {
      content: [{ type: "text", text: rows.length ? JSON.stringify(rows, null, 2) : "No customers found." }],
    };
  }
);

Three choices here keep the tool safe for an agent:

  • readonly: true: the model can't write or delete, even if a prompt tries to make it.
  • Parameterized queries: never let the model write raw SQL into your database.
  • LIMIT 5: small outputs keep the model's context clean and your costs predictable.

Step 5: Tool #3, email verification (the DIY version)

This one is useful for agents that send email, qualify leads or check sign-ups. Here is a first-pass checker using only Node's built-in DNS module:

import { resolveMx } from "node:dns/promises";

const EMAIL_SYNTAX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

server.registerTool(
  "check_email",
  {
    title: "Check email address",
    description:
      "First-pass email check: validates syntax and confirms the domain has mail (MX) servers. " +
      "Does NOT confirm the mailbox exists.",
    inputSchema: {
      email: z.string().describe("The email address to check"),
    },
  },
  async ({ email }) => {
    const result = { email, syntax_valid: false, has_mx: false, mx_hosts: [] as string[] };

    result.syntax_valid = EMAIL_SYNTAX.test(email);
    if (result.syntax_valid) {
      const domain = email.split("@")[1];
      try {
        const mx = await resolveMx(domain);
        result.mx_hosts = mx
          .sort((a, b) => a.priority - b.priority)
          .map((r) => r.exchange)
          .filter((host) => host !== "" && host !== "."); // RFC 7505 "null MX" = domain refuses mail
        result.has_mx = result.mx_hosts.length > 0;
      } catch {
        result.has_mx = false; // NXDOMAIN or no MX records
      }
    }

    return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
  }
);

Two details matter here:

  • The description says what the tool can't do. If the model knows the tool's limits, it won't overstate the result to your user.
  • The null-MX filter. A domain can publish an MX record of . (RFC 7505) to say "this domain never accepts mail". A naive "has any MX record" check treats that as valid. In our test below, 7 of 500 domains did exactly this.

Step 6: Build, test with the MCP Inspector, and connect Claude

Build and seed:

npm run build && npm run seed

Test before you connect anything using the official MCP Inspector:

npx @modelcontextprotocol/inspector node build/index.js

It opens a local web UI where you can list the tools, call them with test inputs and see the raw responses. This is much faster than debugging through a chat window.

Connect to Claude Desktop

Open Settings → Developer → Edit Config and add your server to claude_desktop_config.json:

{
  "mcpServers": {
    "mcp-starter": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/mcp-starter/build/index.js"]
    }
  }
}

Restart Claude Desktop. The server's tools will appear in the tools menu.

💡 The database path in index.ts is relative, and Claude Desktop may launch your server from a different working directory. In real projects, resolve paths from import.meta.url or pass them in through environment variables.

Connect to Claude Code

claude mcp add mcp-starter -- node /ABSOLUTE/PATH/TO/mcp-starter/build/index.js

Try it

You: What's the weather in Berlin, and is Ravi Shah a paying customer? Also check whether his email looks deliverable.

Claude will call get_weather, then lookup_customer, then check_email, and combine the three results into one answer. That's a working agent with three real capabilities.


What changes in production (the email example)

Your weather and DB tools are close to production quality already. The email tool isn't, and it shows the gap between a demo MCP server and one you'd let an agent rely on.

Our check_email confirms the domain accepts mail. It can't tell you whether this specific inbox exists. Run it on asdf@mailinator.com (from our seed data) and it passes, even though that's a throwaway address.

We measured how often the DIY check gets fooled

To put a number on the gap, we ran this tool's MX logic against a random sample of 500 domains from the open-source disposable-email-domains blocklist (9,189 domains, pulled on 1 Oct 2026).

Result Domains Share
Valid MX records, so the DIY check passes them 342 68.4%
No MX record (dead or parked) 151 30.2%
Null MX (.), meaning the domain explicitly refuses mail 7 1.4%

About 2 in 3 known throwaway domains pass a syntax + MX check. These include the big ones: mailinator.com, yopmail.com, guerrillamail.com and temp-mail.org all publish working MX records. A sign-up form or AI agent relying on DNS alone would accept them.

The same data points to a better way to catch these domains. The 342 domains that accept mail use only 131 distinct mail backends, and 9 backends handle 52% of them. Disposable services rotate domain names constantly, but they reuse their mail servers. So if you also track who hosts the mail, you can catch new throwaway domains that no blocklist has added yet.

There's a limit to that approach, though: about 7% of the sampled disposable domains route mail through mainstream providers (Cloudflare, Google, Zoho, Proton and similar). You can't block those mail servers without also blocking real users. For those domains you need a maintained domain list plus behavioral signals, which is exactly what makes production verification an ongoing data problem rather than a one-off script.

Method: a dig MX lookup per domain from a single host, run once on 1 Oct 2026. DNS changes daily, so treat these as a snapshot. Registrable domains were grouped by their last two labels.

Check DIY tool (above) Production verification
Syntax ✅ Basic regex ✅ RFC-aware parsing and typo hints
Domain has MX records ✅ ✅
Mailbox actually exists (SMTP) ❌ ✅
Catch-all domain detection ❌ ✅
Disposable / temp-mail detection ❌ ✅ (large, frequently updated domain lists)
Role-based addresses (info@, sales@) ❌ ✅
Rate limits, retries, greylisting ❌ ✅

The mailbox check is the hardest part. Confirming an inbox means opening an SMTP conversation on port 25, and the places most people deploy agents can't do that:

  • AWS blocks outbound port 25 on EC2 instances and Lambda functions by default. You have to request removal, which can take up to 48 hours (AWS re:Post).
  • Google Cloud blocks outbound port 25 to external destinations and documents no way to unblock it (overview by provider).

What this means for MCP servers: if you deploy your MCP server as a serverless function or a typical cloud VM, it cannot check whether a mailbox exists, however good your code is. Even on a host where port 25 is open, mail servers greylist and rate-limit unknown IPs, so you'd also have to manage IP reputation.

That's why production agent stacks call a dedicated service for this tool rather than rebuilding it. Here's how that looks with MailValid's MCP server, a hosted remote endpoint at https://mailvalid.io/mcp/ that runs on the Streamable HTTP transport (the remote counterpart to the stdio server you just built). It uses the same verification engine and credit system as the REST API, so billing is identical.

Connect it next to your own server

Claude Code connects directly over HTTP:

claude mcp add --transport http mailvalid https://mailvalid.io/mcp/ \
  --header "X-API-Key: mv_live_your_key_here"

Run /mcp to confirm the connection, and /context to see how many tokens each server's tools use.

Claude Desktop only supports local (stdio) servers, so it uses the @mailvalid/mcp launcher. The launcher reads your key from an environment variable and connects to the hosted server for you. Add it to the same mcpServers block you wrote in Step 6:

{
  "mcpServers": {
    "mcp-starter": {
      "command": "node",
      "args": ["/ABSOLUTE/PATH/TO/mcp-starter/build/index.js"]
    },
    "mailvalid": {
      "command": "npx",
      "args": ["-y", "@mailvalid/mcp"],
      "env": { "MAILVALID_API_KEY": "mv_live_your_key_here" }
    }
  }
}

Cursor, VS Code (GitHub Copilot), Windsurf and other clients connect directly with a URL and header. The key names differ slightly by client: VS Code uses servers with "type": "http", and Windsurf uses serverUrl. The MCP docs have copy-paste configs for each. The general shape is:

{
  "mcpServers": {
    "mailvalid": {
      "url": "https://mailvalid.io/mcp/",
      "headers": { "X-API-Key": "mv_live_your_key_here" }
    }
  }
}

💡 Keep the trailing slash. https://mailvalid.io/mcp (without the slash) returns a 307 redirect that some clients treat as a connection error.

The eight tools

Now your agent has both servers: your own tools for internal data, and MailValid's for verification.

Tool Auth Cost What it does
verify_email API key 1 credit* Verify one address: syntax, DNS/MX, SMTP mailbox, disposable/role/catch-all, confidence. Args: email
submit_bulk_verification API key Reserves N Queue a list for async verification. Args: emails, optional webhook_url
get_bulk_job API key Free Status, progress and (when complete) results of a bulk job. Args: job_id
list_bulk_jobs API key Free Recent bulk jobs, paginated. Args: page, page_size, status
cancel_bulk_job API key Free Cancel a pending/processing job and release reserved credits. Args: job_id
get_credit_balance API key Free Current balance and lifetime usage
verify_email_demo None Free Syntax, disposable, role and free-provider checks only (no SMTP). Rate-limited per IP. Args: email
get_api_discovery None Free Machine-readable discovery URLs (OpenAPI, docs, well-known metadata)

* verify_email is free when the result is unknown, the same fair-billing rule as the REST API.

Design choices worth copying into your own servers

MailValid's toolset puts several of the design rules below into practice:

  • Try it before you pay. verify_email_demo and get_api_discovery need no key, so an agent can use the service before anyone connects an account. Calling them first also separates "is the connection working?" from "is my API key wrong?" when you debug a 401.
  • Long jobs are async. Bulk verification doesn't block the conversation. The agent submits a job, then polls get_bulk_job or receives results at a webhook_url, the same way you'd design any long-running tool.
  • Spending is visible. get_credit_balance lets the agent check its budget before a big job, and cancel_bulk_job releases reserved credits. Any paid tool needs guardrails like these.
  • Agents can find it. Besides the get_api_discovery tool, the server publishes /.well-known/mcp.json and /.well-known/mcp/server-card.json, so an agent can discover the server and its tools without a human pasting in documentation.

Here is what that looks like in practice:

You: Here are 40 leads from yesterday's webinar. Verify them and tell me which are safe to add to the outreach sequence.

Claude: (calls get_credit_balance, then submit_bulk_verification, then get_bulk_job) Here's the breakdown: deliverable, invalid, disposable and catch-all (risky), each with its confidence score…

For lead research, CRM cleanup and sign-up abuse use cases, see the complete guide to MCP for email verification.

You can mix servers freely: your own MCP server for internal data, a specialist MCP server for capabilities you don't want to build and maintain yourself.


6 rules for designing good MCP tools

What you learn from building these three tools carries over to every MCP server you'll build:

  1. Treat descriptions as prompts. The model chooses tools almost entirely from description, so say when to use the tool and what it can't do.
  2. Keep inputs narrow. city: string beats query: any. Zod schemas double as documentation for the model.
  3. Return structured, compact output. JSON with stable keys and capped result sizes.
  4. Default to read-only. Add write tools later, behind explicit confirmation.
  5. Fail loudly. Return isError: true with a human-readable message so the model can recover or ask the user.
  6. Log to stderr, never stdout (for stdio servers).

FAQ

What is the Model Context Protocol (MCP)?

MCP is an open protocol, introduced by Anthropic in November 2024, that standardizes how AI applications connect to external tools and data. A client such as Claude Desktop connects to one or more MCP servers, discovers their tools, and calls them over JSON-RPC when a conversation needs them.

What's the difference between stdio and HTTP MCP servers?

A stdio server runs as a local process launched by the client. It's simple and private, which makes it ideal for personal tools like the one in this tutorial. A Streamable HTTP server runs remotely and serves many users, so it needs authentication. Hosted services usually ship remote servers.

Can I build an MCP server in Python instead?

Yes. The official Python SDK (mcp) includes FastMCP, where a decorated function becomes a tool. The concepts in this tutorial (tools, schemas, descriptions, transports) map across directly.

Can an AI agent verify an email address without sending an email?

Yes. Verification services check syntax, look up the domain's MX records, then open an SMTP conversation and ask the mail server whether the mailbox exists. They disconnect before any message is sent. Catch-all domains accept every address, so for those the result is "risky" rather than a clear yes or no.

Is checking MX records enough to block disposable emails?

No. In our sample of 500 known disposable domains, 68.4% had valid MX records, so a syntax + MX check accepted them. Blocking them reliably needs a maintained disposable-domain list, signals about who hosts the mail, and an SMTP-level mailbox check.

Can I run SMTP email verification from AWS Lambda or Google Cloud?

Not by default. AWS blocks outbound port 25 on EC2 and Lambda until you request removal, and Google Cloud blocks it for external destinations with no documented exception. That's why most production agents call a verification API or MCP server instead.

Can an AI agent discover an MCP server automatically?

Some servers publish discovery files. MailValid, for example, serves /.well-known/mcp.json and /.well-known/mcp/server-card.json and offers a no-auth get_api_discovery tool, so an agent can find the endpoint and tools without hand-written docs.

Is it safe to give Claude access to my database?

It's safe if you design for it: a read-only connection, parameterized queries, narrow tools instead of a "run any SQL" tool, and small result limits. Clients like Claude Desktop also ask for your approval before a tool runs.


Wrap-up

In about 30 minutes you've built an MCP server with three tools, tested it in the Inspector, and connected it to Claude. You've also seen where a demo tool ends and production infrastructure begins.

Next steps: - Add a write tool (for example create_ticket) behind a confirmation step - Turn your server into a remote Streamable HTTP server with authentication - Add production-grade email verification to your agent with MailValid's MCP server. You get 100 free credits with no card required, and you can try verify_email_demo without a key

Next in the Shipping AI Agents series: guardrails for autonomous agents (budgets, approvals and validation tools).

Last updated: 1 October 2026.

M

MailValid Team

Email verification experts

Share:

Join teams that verify before they send

Stop letting bad emails hurt your deliverability

100 free credits. From $0.00075/email after. Credits never expire. No credit card required.

More from MailValid

Verify 100 emails free Start Free