Skip to content

MCP 服务器样板:Resend + Supabase

一个最小的 Model Context Protocol 服务器,向 Claude Code 暴露两个真实工具:通过 Resend 发送事务性邮件、在 Supabase 中查询与插入行。大约 120 行 TypeScript。复制、重命名、上线。

MCPTypeScriptSupabaseResendNode.js

我在 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_emailquery_supabaseinsert_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 的 audiences
  • upsert_supabase,带 conflict targets
  • list_supabase_tables,让客户端不靠提示就能发现 schema
  • subscribe_to_broadcast,给联系表单的 opt-in 流程用
  • send_email 上加 dry_run: true 旗标,返回渲染好的 HTML 而不发送

每加一个,就是多一条 schema 条目和多一段 handler 分支。样板是线性扩展的。


我会随着清理把它们推到 GitHub。在 X 上关注,看新的 MCP 服务器与 Shopify Plus 笔记:@jaimesolis。如果你想在自己的 Claude Code 栈里接一个,又不想自己照看 secret,联系表单点两下就到。