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 statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
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 a status of one of four values:
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; its credit is refunded, and it is not a judgment).
A flags array comes back beside the status: role (a shared function mailbox
such as info@), disposable (a throwaway provider) and free_provider. A flag is not a status.
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://app.zapbounce.com/api/v1/verify", {
method: "POST",
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({ emails: [email] }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
const err = await res.json(); // { statusCode, code, message, requestId }
return {
isError: true,
content: [{ type: "text", text: `${err.code}: ${err.message}` }],
};
}
const { results } = await res.json();
const r = results[0];
return {
content: [{
type: "text",
text: `${r.email}: ${r.status} (reason ${r.reasonCode}, smtp ${r.smtpCode ?? "none"}, ` +
`flags: ${r.flags.join(", ") || "none"})`,
}],
};
},
);
await server.connect(new StdioServerTransport());Verify a list
Submit a job 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 50,000 addresses for background verification. Returns a job id
immediately; call check_job to see progress. valid, invalid and catch_all
cost one credit each. The credit for an unknown result is refunded.`,
{
emails: z.array(z.string().email()).min(1).max(50_000),
},
async ({ emails }) => {
const res = await fetch("https://app.zapbounce.com/api/v1/jobs", {
method: "POST",
headers: {
"X-API-Key": KEY,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ emails, source: "api" }),
});
const job = await res.json();
return {
content: [{
type: "text",
text: `Job ${job.id} queued. ${job.totalEmails} addresses submitted.`,
}],
};
},
);
server.tool(
"check_job",
"Progress and verdict breakdown for a job. Report every count, not just the valid ones.",
{ job_id: z.string() },
async ({ job_id }) => {
const j = await fetch(`https://app.zapbounce.com/api/v1/jobs/${job_id}`, {
headers: { "X-API-Key": KEY },
}).then((r) => r.json());
// There is no webhook: the model asks again until the status is terminal.
if (!["completed", "partial", "failed", "cancelled"].includes(j.status)) {
return { content: [{ type: "text", text: `${j.status}. Ask again in a few seconds.` }] };
}
// Every count next to the total, every time. A summary with only the valid
// count invites the model to report it as though it described the whole list.
return {
content: [{
type: "text",
text: [
`${j.status}. ${j.totalEmails} addresses.`,
`valid ${j.validEmails}, invalid ${j.invalidEmails},`,
`catch-all ${j.catchAllEmails} (domain accepts everything),`,
`unknown ${j.otherEmails} (no verdict, credit refunded).`,
].join(" "),
}],
};
},
);Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT 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, use a key with only the scopes the
// tools call: verification.run, jobs.view and jobs.manage. Leave credits.view
// off unless you add a balance tool, so a mistake cannot reach further than it must.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_job 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 refunded.