Email verification in MCP

Model Context Protocol lets an assistant call a tool directly, so someone can paste a list into a chat and get it verified without an export. Building the server is straightforward. Designing the tool schema so the model behaves sensibly is the part that takes thought.

The mistake to avoid is collapsing the verdict into a boolean. A tool that returns { deliverable: true } gives the model no way to distinguish a confirmed mailbox from a catch-all domain that accepts everything, and it will confidently tell your user that a guessed address is fine.

@modelcontextprotocol/sdk over stdio, with zod schemas on the tool inputs and the full verdict in the output.

Install

Shell
npm install @modelcontextprotocol/sdk zod

Verify one address

You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.

TypeScript
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: "zapbounce", version: "1.0.0" });
const KEY = process.env.ZAPBOUNCE_KEY!;

server.tool(
  "verify_email",
  // The description is a prompt. Tell the model what each verdict means, or it
  // will treat catch-all as valid and tell your user the address is fine.
  `Verify one email address. Returns one of four results:
   valid (the mailbox accepted it), invalid (permanently rejected),
   catch_all (the DOMAIN accepts every address, so the mailbox is unconfirmable),
   unknown (the server refused to answer; not billed, and not a judgment).
   Two boolean flags come back beside the result: role (a shared function mailbox
   such as info@) and disposable (a throwaway provider). A flag is not a result.
   Never report catch_all or unknown to the user as confirmed.`,
  { email: z.string().email().describe("The address to verify") },
  async ({ email }) => {
    const res = await fetch("https://api.zapbounce.com/v1/verify", {
      method: "POST",
      headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(15_000),
    });

    if (!res.ok) {
      const { error } = await res.json();
      return {
        isError: true,
        content: [{ type: "text", text: `${error.code}: ${error.message}` }],
      };
    }

    const r = await res.json();
    return {
      content: [{
        type: "text",
        text: `${r.email}: ${r.result} (reason ${r.reason}, smtp ${r.smtp_code ?? "none"}, ` +
          `role: ${r.role}, disposable: ${r.disposable}, billed: ${r.billed})`,
      }],
    };
  },
);

await server.connect(new StdioServerTransport());

Verify a list

Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

TypeScript
server.tool(
  "verify_list",
  `Submit up to 100,000 addresses for background verification. Returns a batch id
   immediately; call check_batch to see progress. Duplicates are collapsed and
   billed once. valid, invalid and catch_all cost one credit each. Unknown results
   are never billed.`,
  {
    emails: z.array(z.string().email()).min(1).max(100_000),
    name: z.string().describe("A label you will recognize later"),
  },
  async ({ emails, name }) => {
    const res = await fetch("https://api.zapbounce.com/v1/batches", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": crypto.randomUUID(),
      },
      body: JSON.stringify({ name, emails }),
    });

    const batch = await res.json();
    return {
      content: [{
        type: "text",
        text: `Batch ${batch.batch_id} queued. ${batch.submitted} submitted, ` +
              `${batch.unique} unique after deduplication.`,
      }],
    };
  },
);

server.tool(
  "check_batch",
  "Progress and verdict breakdown for a batch. Report coverage alongside the counts.",
  { batch_id: z.string() },
  async ({ batch_id }) => {
    const s = await fetch(`https://api.zapbounce.com/v1/batches/${batch_id}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    }).then((r) => r.json());

    if (s.status !== "complete") {
      return { content: [{ type: "text", text: `${s.status}: ${s.processed}/${s.unique}` }] };
    }

    // Coverage next to the counts, every time. A summary without it invites the
    // model to report a valid count as though it described the whole list.
    return {
      content: [{
        type: "text",
        text: [
          `Complete. Coverage ${(s.coverage * 100).toFixed(1)}% of ${s.unique} unique addresses.`,
          `valid ${s.summary.valid}, invalid ${s.summary.invalid},`,
          `catch-all ${s.summary.catch_all} (domain accepts everything),`,
          `unknown ${s.summary.unknown} (no verdict, not billed).`,
          `Flagged role ${s.summary.role}, disposable ${s.summary.disposable}. Duplicates ${s.summary.duplicates}.`,
          `Credits spent: ${s.summary.billed}.`,
        ].join(" "),
      }],
    };
  },
);

Errors and retries

A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.

JSON
// claude_desktop_config.json — the key stays in the server process, never in
// the conversation. An assistant that can read the key can leak it in a reply.
{
  "mcpServers": {
    "zapbounce": {
      "command": "node",
      "args": ["/absolute/path/to/zapbounce-mcp/index.js"],
      "env": {
        "ZAPBOUNCE_KEY": "zb_live_..."
      }
    }
  }
}

// For anything a model triggers on its own, point it at a sandbox key first:
//   "ZAPBOUNCE_KEY": "zb_test_..."
// Scripted verdicts, no SMTP connections, no credits, and the unknown branch
// available on demand through unknown@sandbox.zapbounce.com.

MCP: common questions

Which clients work?

Anything speaking MCP. Claude Desktop over stdio is the common case, and the same server runs over HTTP transport for a hosted client.

Should the key be in the config file?

In the server's environment, which is what the config block sets. Never pass it as a tool argument, where it would sit in the conversation.

Can a model verify a whole list?

It can submit one. Give it verify_list and check_batch rather than letting it loop verify_email a thousand times, which will hit the rate limit and take an hour.

Run this MCP code today

100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.