MCP 服务器样板:Resend + Supabase
一个最小的 Model Context Protocol 服务器,向 Claude Code 暴露两个真实工具:通过 Resend 发送事务性邮件、在 Supabase 中查询与插入行。大约 120 行 TypeScript。复制、重命名、上线。
我在 Mac mini 上跑着五个 MCP 服务器。我用它们让 Claude Code 通过我的 Resend 账号发邮件、查询我的 Supabase 项目、以我的身份调 GitHub API,还有一些别的事情。真正的服务器代码很小。比你想象的要小。
这是我每次起新服务器时都会克隆的样板。它暴露两个工具:用 Resend 的 send_email,以及用于读写的 query_supabase。不需要的那个直接删掉就行。
为什么要做这个
短暂温习一下 MCP。Model Context Protocol 是 Claude(以及其他说同一协议的工具)调用你环境的方式。服务器发布一份工具清单,每个工具配 JSON Schema 描述输入。客户端选择一个工具、填入输入,服务器执行后返回结果。没有模型状态。没有 webhook。只用 stdio 或 HTTP。
对独立开发者来说,最大的好处是不必再为“发这封邮件”、“查这行数据”、“把这条信息推到 GitHub”写一堆一次性脚本。你把它们作为 MCP 工具发布一次,每个 Claude 会话都能用。包括自我编排的 agent。
项目结构
mcp-resend-supabase/
├── package.json
├── tsconfig.json
└── src/
└── server.ts就这一个树。除了 tsc 没有 build 流水线,没有路由器,没有装饰性的框架。
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);这就是整个服务器。启动时用 Zod 校验 env(缺密钥就快速失败),发布三个 tool,按调用名路由到对应的 SDK。
接入 Claude Code
构建一次,然后把服务器注册到 ~/.claude/config.json(或你的客户端读取的位置):
{
"mcpServers": {
"resend-supabase": {
"command": "node",
"args": ["/绝对路径/到/mcp-resend-supabase/dist/server.js"],
"env": {
"RESEND_API_KEY": "re_...",
"RESEND_FROM": "hello@your-domain.com",
"SUPABASE_URL": "https://YOUR_PROJECT.supabase.co",
"SUPABASE_SERVICE_ROLE_KEY": "..."
}
}
}
}重启 Claude Code 会话,问一句“你现在有哪些可用工具”。send_email、query_supabase 和 insert_supabase 会与内置工具并列出现。
在生产里跑这些学到的事
输入校验做两次。 一次是你发布给客户端的 JSON Schema,一次是 handler 开头的 Zod。客户端绝大多数时间表现良好,但偶尔会送过来稍微跑偏的参数,频率足够高,让 Zod 这张安全网值那几行代码。
永远用 service role key,不要用 anon。 如果你给服务器用 anon key,每次查询都会继承 Supabase 的 RLS。多数情况下你要的是服务器以 admin 身份运行。env 变量起个清晰的名字,让未来的自己知道波及范围。
Resend 的 “from” 必须强制。 把 RESEND_FROM 设在 env 层级,避免模型把发件域名搞错。这种错误一旦掉进别人的垃圾邮件文件夹就难收拾了。
个人服务器留在 stdio。 HTTP 传输适合共享部署,stdio 更好调试。你运行服务器,客户端拉起它,通信就是 stdin/stdout 上的 JSON。
一个服务器一件事。 别把所有 tool 塞进同一个二进制。我同时跑着五个小服务器:github、resend、supabase、vercel,还有一个处理 filesystem 类的开发任务。每个二三十行加一次 SDK 调用。一个挂了,不会拖垮其他几个。
接下来可以加什么
形状一致、每个十分钟就能搞定的有用 tool:
send_broadcast,对应 Resend 的 audiencesupsert_supabase,带 conflict targetslist_supabase_tables,让客户端不靠提示就能发现 schemasubscribe_to_broadcast,给联系表单的 opt-in 流程用- 在
send_email上加dry_run: true旗标,返回渲染好的 HTML 而不发送
每加一个,就是多一条 schema 条目和多一段 handler 分支。样板是线性扩展的。
我会随着清理把它们推到 GitHub。在 X 上关注,看新的 MCP 服务器与 Shopify Plus 笔记:@jaimesolis。如果你想在自己的 Claude Code 栈里接一个,又不想自己照看 secret,联系表单点两下就到。