Shipping an MCP server so agents can email us
We built a tiny MCP server that lets Claude and other agents email the EdsDev team directly. Here is the code, the auth model, and what broke along the way.
- ↳MCP is a thin protocol. The hard parts are auth, idempotency, and rate limiting, not the tool definitions.
- ↳Treat every agent-triggered email as untrusted user input. Turnstile, allowlists, and a hard daily cap saved us from our own bot.
- ↳Resend plus Cloudflare Workers gets you a production MCP email tool in under 200 lines of TypeScript.
- ↳Give the agent one verb per tool. send_email beats a generic do_email_thing every time.
Last Tuesday I was watching Claude try to contact us about a bug in a sample app we shipped, and it did what any reasonable LLM does when it cannot reach a human: it hallucinated an email address at edsdev.ca and tried to send through a Python library it did not have installed. Zero out of ten. So I spent the afternoon shipping a small MCP server that lets agents actually email us, with rules, and I want to walk through what that looked like.
This is the kind of thing that sounds boring until you need it. If you build AI agents for support and lead-gen, you eventually want the agent to escalate. “Escalate” in practice means “send a human a message with enough context to act.” Email is still the lowest-friction way to do that.
Why MCP and not just a webhook
We could have wired this up as a plain HTTPS endpoint. POST to /contact, done. That works fine for one agent we control. It falls apart the moment we want Claude Desktop, Cursor, a custom agent running on a client’s server, and whatever we ship next quarter to all use the same tool with the same guarantees.
MCP (Anthropic’s Model Context Protocol) gives you a tool contract the model already knows how to discover and call. The agent asks the server what it can do, the server says “I can send_email with these fields,” and the model picks it up without anyone hand-rolling a function schema. Same server, every client.
Is MCP overkill for one endpoint? Probably. But we have four or five of these little capabilities we want agents to share (calendar lookup, status checks, the email thing), and one MCP server is cheaper to maintain than four webhook integrations.
The shape of the server
We run it on Cloudflare Workers. The whole thing is a single Worker that speaks MCP over HTTP, holds a Resend API key in env, and writes a row to D1 for every send. Around 180 lines of TypeScript.
The tool definition is the boring part:
const tools = [
{
name: "send_email_to_edsdev",
description:
"Send an email to the EdsDev team. Use for collab inquiries, bug reports on shipped apps, or escalations from an agent that cannot resolve a user request. Do not use for marketing, do not use to test the tool.",
inputSchema: {
type: "object",
required: ["from_name", "reason", "body"],
properties: {
from_name: { type: "string", maxLength: 80 },
from_email: { type: "string", format: "email" },
reason: {
type: "string",
enum: ["collab", "bug", "escalation", "other"],
},
body: { type: "string", minLength: 20, maxLength: 4000 },
agent_context: { type: "string", maxLength: 2000 },
},
},
},
];
Notice the description. The model reads that. If you write “sends an email,” it will send marketing slop. If you write “do not use for marketing, do not use to test the tool,” you cut about 80% of the junk sends in our logs. The other 20% needed actual enforcement.
Auth, because of course
The first version had no auth. I deployed it, told one person, and 14 hours later we had 600 test emails from somebody’s experimental agent loop. My fault.
Now every MCP client gets a token. We issue them manually, one per client, scoped with a daily cap. The Worker checks the bearer token, looks up the client in KV, and increments a counter. Hit the cap, get a 429 with a useful message the model can actually relay to its user.
const client = await env.CLIENTS.get(token, "json");
if (!client) return mcpError("invalid_token", 401);
const today = new Date().toISOString().slice(0, 10);
const key = `count:${token}:${today}`;
const count = parseInt((await env.CLIENTS.get(key)) ?? "0", 10);
if (count >= client.daily_cap) {
return mcpError(
`Daily send cap of ${client.daily_cap} reached. Try tomorrow or contact ed@edsdev.ca directly.`,
429
);
}
await env.CLIENTS.put(key, String(count + 1), { expirationTtl: 172800 });
That last line: expiration TTL so the counters clean themselves up. I forgot it the first time and KV started filling with dead keys. Small thing, but the kind of small thing that bites you eight months later.
Idempotency, because agents retry
Agents retry. They retry on timeouts, on network blips, on their own confusion. If you do not give them an idempotency key, you will get duplicate sends.
We accept an optional idempotency_key in the request and store the result keyed on (token, key) for 24 hours. Same key, same response, no second email. If the model does not pass one, we hash the body and use that. It is a 30-line addition and it has saved me from looking dumb in front of clients twice.
The actual send
Resend handles delivery. We have a dedicated agents@notifications.edsdev.ca subdomain with its own SPF and DKIM, so if an agent goes feral and we have to nuke deliverability, it does not touch our real mail.
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${env.RESEND_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "EdsDev Agent Bridge <agents@notifications.edsdev.ca>",
to: ["inbox@edsdev.ca"],
reply_to: input.from_email,
subject: `[${input.reason}] ${input.from_name} via ${client.name}`,
text: formatBody(input),
tags: [{ name: "client", value: client.name }],
}),
});
The subject prefix matters. When something hits the inbox, I want to know in half a second which client’s agent sent it and why. [escalation] Jane Doe via acme-support-bot tells me everything before I open the message.
What I would not do again
I tried to be clever and let the agent attach files. Base64 in JSON, decode on the Worker, push to R2, link in the email. It worked. It also opened a vector for someone to use us as a file host. Ripped it out after a week. If you need attachments, do them out-of-band with a signed upload URL and a separate audit trail.
I also tried to let the agent fetch its own send history (list_my_emails). Sounds useful, is actually a privacy mess the first time two agents share a token by accident. Cut it.
Was it worth it
For us, yes, but mostly because we are going to reuse this pattern. The next MCP tool is a check_project_status thing that lets a client’s support agent answer “where is my build at” without paging me. Same auth, same idempotency, same rate limits, different verb.
If you only need one webhook for one agent, build the webhook. If you are starting to feel the pull of “I want my agents to have a small standard library of things they can do,” MCP is the right shape and Cloudflare Workers plus Resend is a fine place to put it.
If you are building agents and want a hand wiring up the boring infrastructure around them, come say hi.
Common questions
▸Do I need MCP, or is a plain webhook enough?
If one agent you control needs to hit one endpoint, ship the webhook. MCP earns its keep when you have multiple agents (Claude Desktop, Cursor, a custom one) that should share the same set of tools without you maintaining bespoke function schemas in each. The protocol overhead is small but real, so do not adopt it for a single capability.
▸What stops an agent from spamming the tool?
Three layers. Per-token daily caps stored in Cloudflare KV with TTL. Idempotency keys so retries do not duplicate. And a dedicated email subdomain (agents@notifications.edsdev.ca) with its own SPF and DKIM so we can kill deliverability on the agent path without touching our main mail. The tool description also tells the model not to use it for testing or marketing.
▸Why Cloudflare Workers instead of a regular Node server?
Cold start is effectively zero, KV gives us cheap per-token counters, and the whole thing costs a few dollars a month at our volume. The MCP server is stateless except for rate limit counters and idempotency cache, so Workers fit. If you needed long-lived connections or large file processing, a Hetzner box would make more sense.
▸How do you handle the model passing garbage input?
JSON schema validation on every field, hard length caps, an enum on the reason field, and a required minimum body length so the model cannot send empty pings. We also log every call with the agent context, so when something weird shows up we can read what the model thought it was doing and tighten the tool description.
▸Can the agent see whether the email was read or replied to?
No, and that is on purpose. The tool returns send-success or a clear error. We do not expose open tracking, history, or recipient data back to the agent. If a human needs to follow up they reply directly to the from_email on the original message. Keeping the surface area small kept the security review short.
- [1]Model Context Protocol specificationmodelcontextprotocol.io
- [2]Resend API reference: send emailresend.com
- [3]Cloudflare Workers KV: TTL and expirationdevelopers.cloudflare.com
Related posts
Shipping an MCP server so agents can email us
We built a tiny MCP server that lets Claude and Cursor send us email through Resend. Here's the actual code, the auth headaches, and what broke in production.
An AI lead generation agent that books meetings while you sleep
How we build AI lead gen agents that actually book meetings overnight: the workflow, the prompts we use, the guardrails that stop it from embarrassing you.
Building an AI chatbot for small business that actually knows your products
The RAG stack we ship for small businesses that need a chatbot to answer questions about their actual products, not hallucinate a generic FAQ. Real code, real costs.