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
npm install @modelcontextprotocol/sdk zodVerify 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.
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.
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.
// 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.