构建MCP服务器的正确方法

一篇关于为你的基础设施构建 MCP 服务器的实战指南——在你花太多时间在 Claude 和内部工具之间来回复制粘贴之后。

构建MCP服务器的正确方法
微信 ezpoda免费咨询:AI编程 | AI模型微调| AI私有化部署
AI模型价格对比 | AI工具导航 | ONNX模型库 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo

上周我处理了一件客户支持的事。用户写信来说他们的账单有问题,附带了一个订单 ID,让我们查一下。

要回答他们,我需要三样东西:数据库中的用户记录、订单详情及其当前状态、以及快速查看他们最近的活动,以防这个 bug 影响范围更大。这些信息都不在同一个地方。所以工作流程是:打开一个 Mongo 客户端,按邮箱查询用户,复制 ID。切换到我们的管理工具,查找订单。切换到我们的日志仪表板,滚动查看他们的会话。把所有这些粘贴到 Claude 中,问我真正的问题,再把答案粘贴回支持工单。

这样的事我每周大概做十次。不是同一类问题——每次形状不同,但结构总是相同的。从几个地方拉取数据,给 Claude 足够的上下文来思考,把结果粘贴到某个地方。在那个循环中,真正属于我的工作只有提问和阅读答案。其他一切都是搬运。

这就是 MCP 服务器要解决的问题。

1、用一句话解释 MCP 服务器是什么

它是一个小程序,向 Claude 暴露一组工具列表——每个工具有名称、描述和执行工作的函数。你编写工具。Claude 决定何时调用它们。

就是这样。去掉协议细节,这就是你要构建的东西。Claude 和你现有系统之间的一段管道,由你决定暴露什么。

它之所以重要,是因为你不再需要做那些以前不得不做的事情了。不用再把查询结果粘贴到提示词中。不用再在标签页之间切换来组装上下文。Claude 可以调用你的东西,获取结构化数据,对其进行推理。我刚才描述的客户支持工作变成了一条消息:"邮箱为 alice@example.com 的用户说订单 47291 的账单有问题,查一下告诉我你看到了什么。"搞定。

2、你已经在使用哪些公司的 MCP 服务器了

如果你曾将 Claude 连接到 Linear 并问过类似"这个 sprint 中最高优先级的 issue 是什么"——那就是一个 MCP 服务器。Linear 团队构建了它,作为他们的官方集成发布,你用一行代码就能连接:

claude mcp add --transport http linear https://mcp.linear.app/sse

Notion 有一个。GitHub 也有一个。这个模式现在已经足够标准化,任何面向开发者的 SaaS 公司如果没有 MCP 服务器,很快就会显得过时。

值得注意的是这些服务器没有暴露什么。GitHub 的官方 MCP 服务器没有暴露 delete_repository。Notion 的没有暴露数据库删除功能。构建它们的团队对其平台上 AI 代理应该被允许做的事情做出了深思熟虑的选择,答案并不是"我们有 API 的所有功能"。

这是我开始为自己的技术栈构建 MCP 服务器时花了一些时间才内化的部分。MCP 服务器不是 API 镜像。它是一组经过策划的操作,是你认为 Claude 可以安全且有用自主执行的操作。暴露面的形状才是价值所在。

3、到底什么值得暴露

我最初列出了大约十五个我认为团队需要的工具。构建了五个。实际使用了三个。另外两个我一直想删。

那些证明了自己价值的工具:

find_user_by_email — 封装了我以前手动执行的用户查找查询。只返回我实际上会粘贴到提示词中的字段:ID、姓名、计划、状态、注册日期。不是整个文档。

recent_orders_for_customer — 给定用户 ID 的最近 10 个订单,包含状态和总额。这是我查找用户后总需要的后续操作。

send_email — 封装了我们的 Resend 集成。接收收件人、主题和正文。用于类似"给这个客户起草一封跟进邮件并发送"这样的事情。

我过度构建的两个:

run_mongo_query — 接受自由格式的过滤对象。当时的想法是,如果我给 Claude 灵活性,它就能回答任何问题。实际上,Claude 生成了一个查询,在我们的订单集合上做了全集合扫描,搜索一个不存在的状态值,只读副本的 CPU 攀升了大约九十秒查询才完成。虽然没出什么问题,但这是一个明确的信号。当天我就删了这个工具。特定工具每次都胜过通用工具,不仅仅是为了安全——Claude 实际上更可靠地使用特定工具,因为描述告诉了它工具的用途。

bulk_update_users — 从未使用过。在规划服务器时听起来很有用。结果证明这是一件我永远不会想让 Claude 在不审查每个操作的情况下做的事情。教训是"Claude 能不能做这件事"和"我想不想让 Claude 做这件事"是不同的问题。

几个月后我得出的比例是:我最初想暴露的大部分东西,我不应该暴露。实际有用的大部分东西比我猜的要窄。保守构建,看看什么被调用了,当真实的工作流需要时再添加工具。

4、用 Node.js 构建一个

MCP TypeScript SDK 处理协议。你的工作是定义工具和编写处理程序。这是一个可运行的服务器,按照我实际构建生产环境的方式组织。

4.1 项目设置

mkdir my-team-mcp && cd my-team-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod mongodb resend
npm install --save-dev typescript @types/node tsx
npx tsc --init

tsconfig.json 中设置 "target": "ES2022", "module": "Node16", "moduleResolution": "Node16"

4.2 服务器骨架

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 { z } from "zod";
const server = new Server(
  { name: "team-internal", version: "0.1.0" },
  { capabilities: { tools: {} } }
);

这是一个没有工具的服务器。空骨架。现在把工具加进去。

4.3 定义工具

const tools = [
  {
    name: "find_user_by_email",
    description:
      "Look up a user by email. Returns ID, name, plan, status, and signup date. Use this when you need to identify a specific user before doing anything else with their account.",
    inputSchema: {
      type: "object",
      properties: {
        email: { type: "string", description: "Email address." },
      },
      required: ["email"],
    },
  },
  {
    name: "recent_orders_for_customer",
    description:
      "Get the last 10 orders for a given user ID, including status and total. Use this after find_user_by_email when you need to investigate a customer's recent activity.",
    inputSchema: {
      type: "object",
      properties: {
        user_id: { type: "string", description: "Mongo ObjectId of the user." },
      },
      required: ["user_id"],
    },
  },
  {
    name: "send_email",
    description:
      "Send a transactional email through Resend. Use for support replies, summaries, and follow-ups. Do NOT use for marketing emails or anything that needs legal review.",
    inputSchema: {
      type: "object",
      properties: {
        to: { type: "string", description: "Recipient email." },
        subject: { type: "string", description: "Subject line." },
        body: { type: "string", description: "Body. Plain text is fine." },
      },
      required: ["to", "subject", "body"],
    },
  },
];

工具描述做的工作比看起来要多。Claude 在决定使用哪个工具时会阅读它们。我发现有两件特定的事情很重要:

每个描述中的"在……时使用此工具"句子将工具锚定到一个上下文。没有它,Claude 会在奇怪的情况下尝试使用工具。

send_email 中的"不要用于"这行,就是阻止 Claude 在有人说"起草一份营销公告"时拿起这个工具的原因。单这一行就帮你避免了一类你否则必须在代码中捕获的误用。

4.4 路由

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  try {
    switch (name) {
      case "find_user_by_email":
        return await handleFindUserByEmail(args);
      case "recent_orders_for_customer":
        return await handleRecentOrders(args);
      case "send_email":
        return await handleSendEmail(args);
      default:
        return {
          content: [{ type: "text", text: `Unknown tool: ${name}` }],
          isError: true,
        };
    }
  } catch (error) {
    return {
      content: [
        {
          type: "text",
          text: `Error in ${name}: ${error instanceof Error ? error.message : String(error)}`,
        },
      ],
      isError: true,
    };
  }
});

包装器中的错误处理不是可选的。如果处理程序抛出异常而你没有捕获它,协议流会被损坏,Claude 完全不知道发生了什么。**始终返回结构化的错误结果。**Claude 可以阅读它、推理它、决定接下来尝试什么。

4.5 处理程序

import { MongoClient, ObjectId } from "mongodb";
const mongo = new MongoClient(process.env.MONGO_URI!);
await mongo.connect();
const db = mongo.db("production");
async function handleFindUserByEmail(args: any) {
  const { email } = z.object({ email: z.string().email() }).parse(args);
  const user = await db.collection("users").findOne(
    { email },
    {
      projection: {
        _id: 1, name: 1, email: 1, plan: 1, status: 1, createdAt: 1,
      },
    }
  );
  if (!user) {
    return {
      content: [{ type: "text", text: `No user found with email ${email}` }],
    };
  }
  return {
    content: [
      {
        type: "text",
        text: JSON.stringify(
          {
            id: user._id.toString(),
            name: user.name,
            email: user.email,
            plan: user.plan,
            status: user.status,
            signed_up: user.createdAt,
          },
          null,
          2
        ),
      },
    ],
  };
}

两件我用无聊的方式学到、现在本能地做的事情。始终使用 projection(投影)——永远不要返回整个用户文档。哈希密码、内部标志、审计字段不属于 Claude 的上下文。而且始终用 Zod 验证输入,即使你觉得多此一举。Claude 偶尔会传递错误的形状,你需要一个清晰的错误消息,而不是一个让连接断掉的运行时崩溃。

async function handleRecentOrders(args: any) {
  const { user_id } = z.object({ user_id: z.string() }).parse(args);
  const orders = await db
    .collection("orders")
    .find({ user_id: new ObjectId(user_id) })
    .sort({ createdAt: -1 })
    .limit(10)
    .project({ _id: 1, status: 1, total: 1, createdAt: 1, items: 1 })
    .toArray();
  return {
    content: [
      { type: "text", text: JSON.stringify(orders, null, 2) },
    ],
  };
}
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY!);
async function handleSendEmail(args: any) {
  const { to, subject, body } = z
    .object({
      to: z.string().email(),
      subject: z.string().min(1).max(200),
      body: z.string().min(1),
    })
    .parse(args);
  const { data, error } = await resend.emails.send({
    from: process.env.FROM_EMAIL!,
    to,
    subject,
    text: body,
  });
  if (error) {
    return {
      content: [
        { type: "text", text: `Failed to send: ${error.message}` },
      ],
      isError: true,
    };
  }
  return {
    content: [
      { type: "text", text: `Sent. ID: ${data?.id}` },
    ],
  };
}

4.6 启动

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");

console.error 而不是 console.log 是我在第一次尝试时踩的坑。**Stdio 是协议流。**你写入 stdout 的任何内容都会损坏它。日志写到 stderr。我花了令人尴尬的很长时间才弄清楚为什么我的服务器连接正常但每次工具调用都失败。

4.7 连接 Claude

在你的项目中,添加 .mcp.json

{
  "mcpServers": {
    "team-internal": {
      "command": "node",
      "args": ["/absolute/path/to/my-team-mcp/dist/server.js"],
      "env": {
        "MONGO_URI": "mongodb://localhost:27017/production",
        "RESEND_API_KEY": "re_xxx",
        "FROM_EMAIL": "support@yourcompany.com"
      }
    }
  }
}

npx tsc 构建,重启 Claude Code,输入 /mcp 确认服务器已连接。然后试试引发这一切的客户支持流程:

邮箱为 alice@example.com 的用户说他们最近一笔订单的账单有问题。查找用户,检查他们最近的订单,告诉我你看到了什么。

Claude 调用 find_user_by_email,然后调用 recent_orders_for_customer,然后总结。一条消息替代了四个标签页的来回切换。

5、上线之后才会明白的事情

使用自己的服务器一段时间后,我总结出的一些模式。

**记录每次工具调用能拯救你的周末。**在每个处理程序的开头添加一行 console.error,捕获工具名称和参数。当 Claude 做了出乎意料的事情时——它会的——日志是唯一能弄清楚实际发生了什么的方式。我的日志写入一个 JSONL 文件,方便以后 grep 搜索。

宁可十个窄工具,不要三个灵活工具。recent_orders_for_customerfind_user_by_emailquery_database(接受集合名称和过滤器)更容易让 Claude 正确使用。窄工具的名称和描述告诉 Claude 它何时适用。灵活工具每次都把这个决定交给 Claude,成功率明显下降。

**先做读工具,写工具晚很多再加。**花前几周的时间只做一个只能读的服务器。观察什么被调用了。正确的写工具会从使用模式中变得明显。提前添加写工具只会给你一个满是没人实际使用的功能的服务器,再加上每次 Claude 连接到你的服务器时都要读取的、被工具定义膨胀的上下文窗口。

**凭据永远不要放在工具输入中。**始终使用环境变量。Claude 可以看到参数。你不想让它看到——或者不小心在回复中暴露——你的数据库密码。

**审计未使用的工具。**每个工具定义在每个连接到你的服务器的 Claude 会话中都会消耗 token。如果一个工具一个月没被调用过,删除它。

6、拥有 MCP 服务器后会发生什么变化

我没有预料到的是,一个好的 MCP 服务器消除了多少以前看起来不像摩擦的摩擦。

我开头描述的客户支持工作流就是一个好例子。我一直知道它很烦人,但我永远不会构建一个工具来修复它——太小、太多变、不值得一个 sprint。有了 MCP 服务器,我没有专门为那个工作流构建工具。我构建了三个小工具,它们恰好能组合起来解决问题。然后它们又组合到了下一个问题,再下一个。

这就是转变。你不再编写一次性的脚本来把你的工具粘合在一起,因为 Claude 现在就是胶水,而你的 MCP 服务器就是它必须操作的界面。你团队已有的工具——你的数据库、你的 API、你的邮件服务——变成了一个 Claude 可以推理的连贯整体。

大型 SaaS 公司构建他们的 MCP 服务器是因为他们想让产品对 AI 代理可用。同样的逻辑也适用于你自己的技术栈。没有人会替你构建它,但 SDK 很好,协议是开放的,一个有用的第一个版本真的是一个下午就能完成的工作。困难的部分是诚实地面对应该暴露什么。从解决你实际在做的工作流的三个工具开始。当使用模式告诉你缺什么时再添加更多。


原文链接:Most MCP Server Tutorials Skip the Part That Actually Matters

汇智网翻译整理,转载请标明出处