MCP server boilerplate: Resend + Supabase
A minimal Model Context Protocol server that exposes two real tools to Claude Code: send transactional email through Resend and query / insert rows in Supabase. About 120 lines of TypeScript. Copy, rename, ship.
I run five MCP servers on my Mac mini. They are how I let Claude Code send email through my Resend accounts, query my Supabase projects, ping the GitHub API as me, and a couple of other things. The actual server code is small. Smaller than you would think.
This is the boilerplate I keep cloning for new ones. It exposes two tools: send_email over Resend and query_supabase for reads + inserts. Strip whichever you do not need.
Why bother
A short MCP refresher. Model Context Protocol is the way Claude (and other tools that speak it) call out to your environment. A server publishes a list of tools with JSON Schema for each input. The client picks one, fills in the input, the server runs it, returns a result. No model state. No web hooks. Just stdio or HTTP.
The win for a solo developer is that you stop building one-off scripts for “send this email”, “look up this row”, “post this to GitHub”. You expose them once as MCP tools and every Claude session can use them. Including agents that orchestrate themselves.
Project layout
mcp-resend-supabase/
├── package.json
├── tsconfig.json
└── src/
└── server.tsThat is the whole tree. No build pipeline beyond tsc, no router, no fancy framework.
package.json
{
"name": "mcp-resend-supabase",
"version": "0.1.0",
"type": "module",
"bin": { "mcp-resend-supabase": "./dist/server.js" },
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.0",
"@supabase/supabase-js": "^2.49.0",
"resend": "^4.4.0",
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^6.0.3",
"@types/node": "^26.0.1"
}
}tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false
},
"include": ["src/**/*"]
}src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { Resend } from 'resend';
import { createClient } from '@supabase/supabase-js';
import { z } from 'zod';
const env = z.object({
RESEND_API_KEY: z.string().min(1),
RESEND_FROM: z.string().email(),
SUPABASE_URL: z.string().url(),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
}).parse(process.env);
const resend = new Resend(env.RESEND_API_KEY);
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
const SendEmailInput = z.object({
to: z.string().email().or(z.array(z.string().email())),
subject: z.string().min(1),
body: z.string().min(1),
cc: z.array(z.string().email()).optional(),
});
const QuerySupabaseInput = z.object({
table: z.string().min(1),
select: z.string().default('*'),
match: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
limit: z.number().int().positive().max(200).default(20),
});
const InsertSupabaseInput = z.object({
table: z.string().min(1),
row: z.record(z.string(), z.unknown()),
});
const server = new Server(
{ name: 'mcp-resend-supabase', version: '0.1.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'send_email',
description: 'Send a transactional email via Resend.',
inputSchema: {
type: 'object',
properties: {
to: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
subject: { type: 'string' },
body: { type: 'string' },
cc: { type: 'array', items: { type: 'string' } },
},
required: ['to', 'subject', 'body'],
},
},
{
name: 'query_supabase',
description: 'Run a select on a Supabase table with optional column match and limit.',
inputSchema: {
type: 'object',
properties: {
table: { type: 'string' },
select: { type: 'string' },
match: { type: 'object', additionalProperties: true },
limit: { type: 'integer', minimum: 1, maximum: 200 },
},
required: ['table'],
},
},
{
name: 'insert_supabase',
description: 'Insert one row into a Supabase table and return it.',
inputSchema: {
type: 'object',
properties: {
table: { type: 'string' },
row: { type: 'object', additionalProperties: true },
},
required: ['table', 'row'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const name = req.params.name;
const args = req.params.arguments ?? {};
if (name === 'send_email') {
const { to, subject, body, cc } = SendEmailInput.parse(args);
const r = await resend.emails.send({ from: env.RESEND_FROM, to, subject, html: body, cc });
if (r.error) throw new Error(r.error.message);
return { content: [{ type: 'text', text: JSON.stringify({ id: r.data?.id }, null, 2) }] };
}
if (name === 'query_supabase') {
const { table, select, match, limit } = QuerySupabaseInput.parse(args);
let q = supabase.from(table).select(select).limit(limit);
if (match) for (const [k, v] of Object.entries(match)) q = q.eq(k, v);
const { data, error } = await q;
if (error) throw new Error(error.message);
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
if (name === 'insert_supabase') {
const { table, row } = InsertSupabaseInput.parse(args);
const { data, error } = await supabase.from(table).insert(row).select().single();
if (error) throw new Error(error.message);
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
throw new Error(`Unknown tool: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);That is the whole server. It validates env at startup with Zod (fails fast if a secret is missing), publishes three tools, and routes each call to the right SDK.
Hook it into Claude Code
Build once, then register the server in ~/.claude/config.json (or wherever your client looks):
{
"mcpServers": {
"resend-supabase": {
"command": "node",
"args": ["/absolute/path/to/mcp-resend-supabase/dist/server.js"],
"env": {
"RESEND_API_KEY": "re_...",
"RESEND_FROM": "hello@yourdomain.com",
"SUPABASE_URL": "https://YOUR_PROJECT.supabase.co",
"SUPABASE_SERVICE_ROLE_KEY": "..."
}
}
}
}Restart the Claude Code session and ask “what tools do you have available”. You should see send_email, query_supabase and insert_supabase show up alongside the built-ins.
Things I learned running these in production
Validate input twice. Once in the JSON Schema you publish to the client, once with Zod at the top of the handler. The client mostly behaves, but it does send slightly off-shape arguments often enough that the Zod safety net is worth the few lines.
Service role key, never anon. If you give the server the anon key, every query inherits Supabase RLS. Most of the time you want the server to act as an admin. Just keep the env var clearly named so future-you knows the blast radius.
Resend “from” is a hard requirement. Set RESEND_FROM at the env level so the model cannot accidentally send from the wrong domain. The mistake is harder to fix once it lands in someone’s spam folder.
Stay on stdio for personal servers. HTTP transport is fine for shared deployments but stdio is simpler to debug. You run the server, the client launches it, communication is just JSON over stdin/stdout.
One server per concern. Do not stuff every tool into one binary. I have five tiny servers running side by side: github, resend, supabase, vercel, and one for filesystem-y dev tasks. Each is twenty or thirty lines plus an SDK call. When one breaks it does not take the others down.
What to add next
Useful tools that fit the same shape and take ten minutes each:
send_broadcastagainst Resend audiencesupsert_supabasewith conflict targetslist_supabase_tablesso the client can discover schema without promptingsubscribe_to_broadcastfor a contact form opt-in flow- A
dry_run: trueflag onsend_emailthat returns the rendered HTML instead of sending
Each one is another schema entry and another handler branch. The boilerplate scales linearly.
I push these to GitHub as I clean them up. Follow me on X for new MCP servers and Shopify Plus notes: @jaimesolis. If you want one wired into your own Claude Code stack and do not want to babysit secrets, the contact form is two clicks away.