Boilerplate de servidor MCP: Resend + Supabase
Un servidor Model Context Protocol mínimo que expone dos herramientas reales a Claude Code: enviar correo transaccional vía Resend y consultar o insertar filas en Supabase. Alrededor de 120 líneas de TypeScript. Copia, renombra, deploya.
Tengo cinco servidores MCP corriendo en mi Mac mini. Son la forma en que dejo a Claude Code mandar correos por mis cuentas de Resend, consultar mis proyectos de Supabase, golpear la API de GitHub como yo, y un par de cosas más. El código real del servidor es chico. Más chico de lo que pensarías.
Este es el boilerplate que clono cuando arranco uno nuevo. Expone dos herramientas: send_email por Resend y query_supabase para lecturas e inserts. Saca lo que no necesites.
Para qué sirve
Refresco corto de MCP. Model Context Protocol es la manera en que Claude (y otras herramientas que lo hablen) llama hacia tu entorno. Un servidor publica una lista de tools con su JSON Schema de input. El cliente elige una, llena el input, el servidor lo ejecuta, devuelve un resultado. Sin estado del modelo. Sin web hooks. Solo stdio o HTTP.
La gracia para un desarrollador solo es que dejas de escribir scripts de un solo uso para “mandar este correo”, “buscar esta fila”, “postear esto en GitHub”. Los expones una vez como tools de MCP y cada sesión de Claude puede usarlos. Incluyendo agentes que se orquestan a sí mismos.
Estructura del proyecto
mcp-resend-supabase/
├── package.json
├── tsconfig.json
└── src/
└── server.tsEse es el árbol completo. Sin build pipeline más allá de tsc, sin router, sin framework decorativo.
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);Ese es el servidor entero. Valida el env al arranque con Zod (falla rápido si falta un secret), publica tres tools, y rutea cada llamada al SDK correcto.
Engancharlo a Claude Code
Compila una vez y registra el servidor en ~/.claude/config.json (o donde mire tu cliente):
{
"mcpServers": {
"resend-supabase": {
"command": "node",
"args": ["/ruta/absoluta/a/mcp-resend-supabase/dist/server.js"],
"env": {
"RESEND_API_KEY": "re_...",
"RESEND_FROM": "hello@tudominio.com",
"SUPABASE_URL": "https://TU_PROYECTO.supabase.co",
"SUPABASE_SERVICE_ROLE_KEY": "..."
}
}
}
}Reinicia la sesión de Claude Code y pregunta “qué tools tienes disponibles”. Deberían aparecer send_email, query_supabase e insert_supabase junto con los built-ins.
Cosas que aprendí corriendo estos en producción
Valida input dos veces. Una vez en el JSON Schema que publicas al cliente, otra con Zod al inicio del handler. El cliente se porta bien casi siempre, pero manda argumentos ligeramente fuera de forma con la frecuencia suficiente como para que la red de seguridad de Zod valga las pocas líneas.
Service role key, nunca anon. Si le das al servidor la key anon, cada query hereda las RLS de Supabase. La mayoría de las veces quieres que el servidor actúe como admin. Mantén el nombre del env var claro para que el futuro tú sepa el blast radius.
El “from” de Resend es requisito duro. Define RESEND_FROM a nivel de env para que el modelo no pueda mandar accidentalmente desde el dominio equivocado. Ese error es más difícil de arreglar cuando ya cayó en la carpeta de spam de alguien.
Quédate en stdio para servidores personales. El transporte HTTP está bien para deploys compartidos, pero stdio es más simple de debuggear. Corres el servidor, el cliente lo lanza, la comunicación es solo JSON sobre stdin/stdout.
Un servidor por preocupación. No metas todas las tools en un binario. Tengo cinco servidores pequeños corriendo al lado: github, resend, supabase, vercel, y uno para tareas tipo filesystem de dev. Cada uno son veinte o treinta líneas más una llamada al SDK. Cuando uno se rompe no se lleva a los otros.
Qué agregar después
Tools útiles que entran en la misma forma y toman diez minutos cada una:
send_broadcastcontra audiences de Resendupsert_supabasecon conflict targetslist_supabase_tablespara que el cliente descubra schema sin promptearsubscribe_to_broadcastpara un flow de opt-in desde un formulario de contacto- Un flag
dry_run: trueensend_emailque devuelva el HTML renderizado en lugar de mandar
Cada uno es otra entrada de schema y otra rama del handler. El boilerplate escala lineal.
Voy subiendo estos a GitHub a medida que los limpio. Sígueme en X para nuevos servidores MCP y notas de Shopify Plus: @jaimesolis. Si quieres uno conectado en tu propio stack de Claude Code y prefieres no cuidar secrets, el formulario de contacto está a dos clicks.