从零构建MCP:使用TypeScript打造生产就绪的服务器
高级14 分钟阅读企业AI

从零构建MCP:使用TypeScript打造生产就绪的服务器

构建生产级Model Context Protocol服务器,不能只是连接几个工具。本文介绍Schema设计、身份验证、错误处理、流式传输、可观测性的模式,以及让MCP服务器在规模化场景中真正发挥作用的生产现实。

您应该能够做到的事情

生产级MCP服务器是一项小而专注的服务:工具设计出色、Schema严谨、错误处理健壮、身份验证得当,并内置可观测性。协议本身很简单;真正的工程工作,是让服务器在真实AI工作流中切实可用。

AI Expert Team发布日期: 2026年5月15日
仅在此浏览器中保存。
本文内容

到2026年年中,MCP(Model Context Protocol,模型上下文协议)已经成为连接LLM与工具的事实标准。Anthropic推出了它;OpenAI、Google和更广泛的生态系统随后采用。Cursor、Claude Desktop、ChatGPT、自定义智能体——都支持MCP。

如果你希望LLM智能体与你的服务交互,就应该使用MCP服务器。构建过一两个之后,你会发现协议本身很精简。真正有意思的工程工作都在协议之外:Schema设计、错误处理、身份验证、流式传输、性能和可观测性。

本文深入介绍如何使用TypeScript构建生产级MCP服务器。我们关注的是经得起真实智能体使用的模式,而不只是协议机制。

MCP简介

MCP是一种客户端-服务器协议,其中:

  • 服务器公开工具、资源和提示词。
  • 客户端通常是使用这些能力的LLM智能体。

该协议使用JSON-RPC 2.0(官方规范篇幅不长,值得完整读一遍)。传输方式包括面向本地进程的stdio,以及面向远程服务器的Streamable HTTP——较旧的HTTP+SSE传输方式已在2025-03-26版规范中弃用,因此应将所有基于它的教程视为历史资料。身份验证和安全性属于协议关注范畴;主流实现支持OAuth、API密钥等方式。

服务器的职责是:以LLM能够发现和使用的方式公开实用能力。

基本结构

使用官方 @modelcontextprotocol/sdk 包,基于高级 McpServer API的最小服务器如下:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "my-server",
  version: "1.0.0",
});

server.registerTool(
  "echo",
  {
    description: "原样返回提供的文本。",
    inputSchema: { text: z.string() },
  },
  async ({ text }) => ({
    content: [{ type: "text", text }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);

(同一个SDK还公开了更底层的 Server 类,以及用于 ListToolsRequestSchema / CallToolRequestSchemasetRequestHandler。如果需要完全控制请求处理器,可以使用它们;但对大多数服务器而言,McpServer.registerTool 更简短,也更不容易出错。)

这只是骨架。真正的工作在于工具处理器中放什么,以及如何放。

模式1:工具设计理念

第一个决定是:公开哪些工具,以及采用什么粒度?

常见失败方式是直接把底层API暴露为工具。如果有200个REST端点,公开200个工具将是一场灾难。模型面对过多工具时表现会变差;工具描述难以管理;协议会变成迷宫。

更好的做法是:根据智能体的使用方式来设计工具。每个工具只做一件定义清晰的事,接收定义清晰的输入,并返回定义清晰的输出。

以下是一些原则:

每个工具只对应一个概念。 不要设计一个能做12件不同事情的 manage_customer 工具。应分别提供 search_customersget_customerupdate_customer_emailarchive_customer,各自专注于一项操作。

粒度适当。 粒度太细,智能体需要发起很多次调用;粒度太粗,又无法精确完成所需操作。目标应是“人类会给它起一个名字的操作”。

使用动作动词。 使用 search_documents,而不是 documents。工具名称应体现它所执行的动作。

区分读写。 读取工具更安全;写入工具有副作用。应通过命名加以区分(list_xcreate_x),并采用不同处理方式(要求明确确认、幂等键等)。

在有帮助时聚合。 一个调用就返回客户、近期订单和支持工单的 get_customer_profile,通常优于三个独立调用。智能体可以一次获得完整上下文。

例如,为客户支持系统提供服务时,8–15个工具是一组合理的数量。更多通常就太多了。

模式2:Schema设计

每个工具都有输入Schema(LLM必须提供的参数)和输出(工具返回的内容)。Schema不仅用于验证,也是提示工程。

使用Zod定义输入Schema:

const searchCustomersSchema = z.object({
  query: z.string().describe(
    "搜索词:姓名、电子邮件地址或公司。请尽量具体,以免匹配过多结果。"
  ),
  limit: z.number().int().min(1).max(50).default(10).describe(
    "返回结果的最大数量。默认为10,最大为50。"
  ),
  filters: z.object({
    tier: z.enum(["free", "pro", "enterprise"]).optional().describe(
      "筛选指定的客户层级"
    ),
    status: z.enum(["active", "trial", "churned"]).optional().describe(
      "按客户状态筛选"
    ),
  }).optional(),
});

请注意:

  • 每个字段都有 .describe()。其中的描述正是LLM会读取的内容。
  • 枚举值明确。应尽可能限制自由格式字符串。
  • 默认值合理。
  • 约束(最小值/最大值、长度)明确。
  • 必填与可选一目了然。

描述极其重要。“搜索词”没有帮助;“搜索词:姓名、电子邮件地址或公司。请尽量具体,以免匹配过多结果”才能为LLM提供有用指引。

模式3:输出结构

输出是LLM看到并据以行动的内容。良好的输出设计能显著改善LLM的行为。

结构化输出。

type SearchResult = {
  customers: Customer[];
  total_matches: number;
  truncated: boolean;
  next_page_cursor?: string;
};

包含上下文。

{
  customers: [...],
  total_matches: 47,
  truncated: true,
  next_page_cursor: "abc",
  message: "找到47个匹配项;当前显示前10个。使用next_page_cursor获取更多结果。"
}

message 字段提供人类可读的指引,LLM会利用它。

妥善处理错误。

{
  error: "ambiguous_query",
  message: "搜索词 'john' 匹配了247位客户。请提供更具体的信息。",
  suggestion: "尝试加入公司名称或电子邮件域名。",
  partial_results: [...]  // 按相关性排序的前3项,可选
}

错误是结构化的(机器可读),同时也包含消息和建议(LLM可读)。LLM可以调整策略——询问用户以澄清,或优化查询。

大小适当。

返回10,000条记录的工具无法使用。LLM的上下文容纳不下;即使能够容纳,LLM也无法很好地利用。始终进行分页、截断或摘要。只返回足以让LLM作出决定的信息,而不是返回所有现存数据。

模式4:错误语义

工具会失败。向LLM传达失败的方式,决定了LLM能否妥善恢复,还是会让错误进一步恶化。

错误类别。

type ToolError =
  | { type: "validation"; message: string; field?: string }
  | { type: "auth"; message: string }
  | { type: "not_found"; message: string; suggestion?: string }
  | { type: "conflict"; message: string; resolution?: string }
  | { type: "rate_limit"; message: string; retry_after_seconds: number }
  | { type: "service_unavailable"; message: string; retryable: boolean }
  | { type: "internal"; message: string; trace_id: string };

每个类别具有不同语义。LLM应采取不同响应:

  • validation:修正输入并重试。
  • not_found:告知用户,或尝试其他搜索。
  • conflict:请求解决冲突。
  • rate_limit:等待后重试。
  • service_unavailable:尝试备用方案或通知用户。
  • internal:停止尝试,并向用户说明。

在服务器中记录这些语义,可以让LLM更有能力。

错误格式。

以结构化数据形式返回错误,并提供清晰、可操作的消息:

{
  error: {
    type: "validation",
    message: "电子邮件地址格式无效。",
    field: "email",
    suggestion: "请提供类似 'name@example.com' 的有效电子邮件地址。"
  }
}

应避免:

{
  error: "输入无效"
}

第一种方式让LLM能够恢复,第二种方式则只能让它猜测。

模式5:身份验证与授权

生产级MCP服务器需要身份验证。任何能够访问服务器的人都可以使用工具,这几乎总会带来问题。

身份验证:调用者是谁?

常见模式包括:

  • API密钥。 简单、常见,适合服务到服务的调用。为每个使用方签发,并定期轮换。
  • OAuth。 适用于由最终用户授权智能体的多用户系统。更复杂,但对许多用例而言是正确选择。
  • mTLS。 适用于高安全性环境。双方都使用双向TLS证书。

实现取决于传输方式。通过 HTTP 时,应在请求进入MCP处理器之前(在Express/Hono/Fastify中间件中)完成身份验证,并将调用者存入请求:

// 位于MCP HTTP端点之前的Express风格中间件。
app.use("/mcp", async (req, res, next) => {
  const apiKey = req.header("x-api-key");
  const caller = await authenticate(apiKey);
  if (!caller) return res.status(401).send("未授权");
  (req as any).caller = caller;
  next();
});

然后在每个工具处理器内,从每次调用的上下文(extra)中获取调用者,而不是从原始请求头中获取。通过 stdio 时没有HTTP请求头;身份验证通常改为读取进程环境变量/配置文件。

授权:他们可以做什么?

身份验证完成后,还要确定调用者可以使用哪些工具,以及可以操作哪些数据。

function authorize(caller: Caller, tool: string, params: any): boolean {
  // 调用者级别:该调用者是否有权使用此工具?
  if (!caller.tools.includes(tool)) return false;

  // 数据级别:该调用者是否有权访问这些特定数据?
  if (params.tenant_id && params.tenant_id !== caller.tenant_id) return false;

  return true;
}

不要让LLM作出授权决策。LLM可能被诱骗。授权是服务器的职责;LLM只能看到它获准查看的数据。

对于多租户系统,每次工具调用都限定在一个租户内。租户由身份验证信息确定,而不是由LLM提供的参数确定。

模式6:幂等性

对于写入操作,幂等性至关重要。LLM可能重试,也可能在不同上下文中调用同一工具两次。没有幂等性,就会产生重复数据。

幂等键。

工具接收一个 idempotency_key 参数。服务器检查:是否已经见过这个键?如果见过,返回缓存结果;如果没有,则执行并缓存。

async function createInvoice(params: {
  amount: number;
  customer_id: string;
  idempotency_key: string;
}) {
  const cached = await idempotencyStore.get(params.idempotency_key);
  if (cached) return cached;

  const invoice = await actuallyCreateInvoice(params);
  await idempotencyStore.set(params.idempotency_key, invoice, { ttl: 86400 });
  return invoice;
}

应在工具描述中向LLM提示:

"每创建一张唯一发票,都要生成一个UUID并作为idempotency_key传入。如果需要重试该操作,请使用同一个UUID,以免重复创建。"

模式7:流式传输

对于会产生大量输出或需要较长时间的工具,流式传输可以提供更好的UX。MCP支持通过每次调用的 extra 参数,从工具处理器内部发送进度通知:

server.registerTool(
  "long_running_task",
  { description: "...", inputSchema: { ... } },
  async (input, extra) => {
    await extra.sendNotification({
      method: "notifications/progress",
      params: { progressToken: extra._meta?.progressToken, progress: 0, message: "正在启动..." },
    });

    for (const step of steps) {
      await doStep(step);
      await extra.sendNotification({
        method: "notifications/progress",
        params: {
          progressToken: extra._meta?.progressToken,
          progress: step.index / steps.length,
          message: step.name,
        },
      });
    }

    return { content: [{ type: "text", text: JSON.stringify({ result: finalResult }) }] };
  }
);

以下情况适合使用流式传输:

  • 长时间运行的操作(>5秒)。
  • 大型输出(使LLM能在输出仍在产生时开始处理)。
  • 中间结果值得展示的操作。

不要对快速、简单的操作使用流式传输——只会增加复杂度,并无实际价值。

模式8:缓存

许多工具调用会反复访问同一数据。缓存可以显著提升性能并减轻后端负载。

本地缓存。 对热点数据使用进程内缓存(例如LRU)。

分布式缓存。 使用Redis或类似系统,让多个服务器实例共享缓存。

缓存失效。 数据变化时清除相关条目。(这是困难的部分。)

TTL。 缓存条目在定义好的时间后过期。应按数据类型调整——客户资料可以缓存数小时,价格信息可能只能缓存数分钟。

要让缓存发挥作用,相同调用必须重复出现。许多MCP服务器确实如此——智能体经常在一次会话中反复引用相同实体。

一种模式如下:

async function getCustomerCached(id: string) {
  const cached = await cache.get(`customer:${id}`);
  if (cached) {
    metrics.increment("cache.hit");
    return cached;
  }
  metrics.increment("cache.miss");
  const customer = await db.getCustomer(id);
  await cache.set(`customer:${id}`, customer, { ttl: 300 });
  return customer;
}

模式9:速率限制

LLM智能体的行为可能出乎意料地激进——循环、重试、扇出。行为异常的智能体可能对后端造成DoS。

按调用者实施速率限制至关重要:

const limiter = new RateLimiter({
  windowMs: 60_000,
  max: 100  // 每位调用者每分钟100次调用
});

server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
  if (await limiter.exceeded(context.caller.id)) {
    return errorResponse("rate_limit", "请求过多");
  }
  // ...
});

除全局速率限制外,按工具限制也很重要——有些工具成本高,应严格限制。

对于影响重大的操作(创建记录、发送消息),应设置更严格的限制,或要求明确的确认流程。

模式10:资源

MCP提供“资源”——LLM可以浏览和引用的只读数据源。它与工具(会被主动调用)不同。

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    {
      uri: "doc://my-server/handbook",
      name: "员工手册",
      mimeType: "text/markdown",
      description: "公司员工手册"
    },
    // ...
  ]
}));

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const content = await loadResource(request.params.uri);
  return { contents: [{ uri: request.params.uri, mimeType: "text/markdown", text: content }] };
});

资源适用于:

  • LLM可能需要浏览的参考文档。
  • 配置或上下文数据。
  • LLM可能需要的查询表或Schema。

资源用于读取,工具用于执行操作。根据场景选择正确概念。

模式11:可观测性

与生产级AI的其他部分相同。应为MCP服务器插桩,以记录:

  • 每次工具调用:时间戳、调用者、工具、参数、结果、延迟、状态。
  • 按工具统计的指标:调用量、p50/p95延迟、错误率。
  • 按调用者统计的指标:谁在调用,调用频率如何。
  • 追踪上下文:将调用者的追踪ID一直传播到后端调用。

结构化日志如下:

logger.info("tool_call", {
  tool: request.params.name,
  caller_id: context.caller.id,
  trace_id: context.trace_id,
  params: redactPII(request.params.arguments),
  duration_ms: duration,
  status: "success"
});

将其输送到你的可观测性平台。

模式12:版本管理

MCP服务器会不断演进:工具会变化,会加入新工具,也会弃用旧工具。

服务器版本管理。 Server 构造函数接收版本号。发生变更时提升版本,客户端即可检测到。

工具版本管理。 工具签名发生不兼容变更时,应为其增加版本号:search_customers_v2。在弃用过渡期内继续提供旧版本。

Schema演进。 添加可选字段是安全的。删除字段或改变类型属于破坏性变更。

弃用。 弃用工具时,在描述中标明:“已弃用:请改用search_customers_v2。”

对于由多个客户端使用的生产级MCP服务器,版本管理至关重要。仅供内部使用的服务器可以更灵活。

模式13:测试

如何测试MCP服务器?

单元测试。 使用模拟依赖测试每个工具的逻辑,即标准TypeScript测试。

Schema测试。 确认Schema按预期验证,并正确处理边界情况(缺少字段、类型错误)。

集成测试。 启动服务器,发送真实MCP请求并验证响应。@modelcontextprotocol/sdk 包含测试工具。

使用真实LLM的端到端测试。 最困难,但也最有价值。让LLM使用你的MCP服务器完成现实任务,验证它能否正确使用工具,并发现工具描述中的问题。

端到端测试设置如下(伪代码;具体客户端连接方式取决于使用的LLM客户端——Anthropic的TypeScript SDK、OpenAI的SDK,或支持MCP的框架):

// 以子进程或内存传输方式启动MCP服务器。
const server = await startTestServer();

// 驱动已连接MCP工具的LLM。具体API取决于客户端。
const result = await runAgent({
  mcpServer: server,
  systemPrompt: "你是一名客户服务智能体...",
  userMessage: "找到客户Alice,并检查她未关闭的工单",
});

// 检查服务器在运行期间捕获的工具调用。
expect(server.callLog.map((c) => c.name)).toEqual([
  "search_customers",
  "list_tickets",
]);

端到端测试能发现单元测试无法发现的工具描述问题。

模式14:部署

MCP服务器应部署在哪里?

Stdio(本地)。 服务器作为进程运行,由客户端调用。最适合桌面应用(Claude Desktop、Cursor)和本地工具。

HTTP/SSE(远程)。 服务器作为网络服务运行。最适合托管服务、共享基础设施和多客户端访问。

对于生产级服务器:

  • 通常应选择Streamable HTTP。
  • 像部署其他Web服务一样部署:容器、负载均衡、自动扩缩容。
  • 必须使用TLS。
  • 为部署平台提供健康检查。
  • 对正在处理的请求进行优雅停机。

模式15:安全注意事项

MCP服务器向LLM公开能力,而LLM可能被操纵。这会带来以下安全影响:

通过工具输入进行提示词注入。 用户请求可能包含诱骗LLM以有害方式调用工具的文本。防御措施:

  • 工具描述清楚说明预期用途。
  • 在服务器端执行授权(独立于LLM决定的参数)。
  • 对影响重大的操作要求确认。

数据外泄。 返回数据的工具可能被滥用——LLM可能遭到诱骗,以不当方式返回敏感数据。防御措施:

  • 授权检查。
  • 记录谁访问了哪些数据。
  • 检测异常访问模式。

资源耗尽。 消耗后端资源的工具可能被滥用。防御措施:

  • 速率限制。
  • 限制每次工具调用的资源。
  • 后端性能下降时启用熔断器。

注入工具输出。 工具输出中可能包含一些文本,LLM读取后会受其操纵。防御措施:

  • 尽可能清理输出。
  • 对返回用户生成内容的工具保持警惕。

这些都是真实的攻击面。应像对待其他生产API一样对待MCP服务器:实施纵深防御。

完整示例:一个小而实用的MCP服务器

下面将上述内容组合起来,构建一个公开小型CRM的服务器:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { db, cache, logger, authenticate } from "./infra.js";

const server = new McpServer({
  name: "crm-server",
  version: "1.0.0",
});

// === 工具:search_customers ===

server.registerTool(
  "search_customers",
  {
    description: "按姓名、电子邮件地址或公司搜索客户。",
    inputSchema: {
      query: z.string().describe("姓名、电子邮件地址或公司"),
      limit: z.number().int().min(1).max(50).default(10),
    },
  },
  async ({ query, limit }, extra) => {
    const auth = await authenticate(extra);
    const cacheKey = `search:${auth.tenant_id}:${query}:${limit}`;

    const cached = await cache.get(cacheKey);
    if (cached) return cached;

    const customers = await db.searchCustomers({
      tenant_id: auth.tenant_id,
      query,
      limit,
    });

    const result = {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          customers,
          total_matches: customers.length,
          truncated: customers.length === limit,
          message:
            customers.length === limit
              ? `当前显示前 ${limit} 项;可能还有更多匹配项。`
              : `找到 ${customers.length} 位客户。`,
        }),
      }],
    };

    await cache.set(cacheKey, result, { ttl: 60 });
    logger.info("search_customers", { tenant: auth.tenant_id, query, results: customers.length });
    return result;
  }
);

// === 工具:get_customer ===

server.registerTool(
  "get_customer",
  {
    description: "按id获取单个客户。",
    inputSchema: { customer_id: z.string() },
  },
  async ({ customer_id }, extra) => {
    const auth = await authenticate(extra);
    const customer = await db.getCustomer(auth.tenant_id, customer_id);
    if (!customer) {
      return {
        isError: true,
        content: [{
          type: "text" as const,
          text: `未找到客户 ${customer_id}。请使用search_customers按姓名或电子邮件地址查找。`,
        }],
      };
    }
    return { content: [{ type: "text" as const, text: JSON.stringify({ customer }) }] };
  }
);

// === 工具:update_customer_email(带幂等性)===

server.registerTool(
  "update_customer_email",
  {
    description: "更新客户的电子邮件地址;重试时传入相同的idempotency_key。",
    inputSchema: {
      customer_id: z.string(),
      new_email: z.string().email(),
      idempotency_key: z
        .string()
        .describe("本次更新使用的UUID;重试时传入相同值,以防重复"),
    },
  },
  async (params, extra) => {
    const auth = await authenticate(extra);
    // ... 幂等性检查、验证、更新
    return { content: [{ type: "text" as const, text: "ok" }] };
  }
);

// ... 更多工具 ...

// 通过你选择的HTTP服务器,在指定端口连接远程传输(Streamable HTTP)。
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
await server.connect(transport);

这是一个起始结构。还需添加可观测性、速率限制、更多工具和更严谨的Schema——但骨架已经就位。

生产级服务器与演示有何不同

MCP是一项精简的协议,但构建生产级服务器需要真正的工程投入。回报是:你的服务可以由任何LLM智能体使用,而且采用标准化集成,无需与每种模型分别耦合。

真正重要的模式包括:专注的工具设计、考虑提示词的Schema、结构化错误语义、健壮的身份验证、幂等性、可观测性和安全性。跳过其中任何一项,都会导致MCP服务器在生产环境中失效。

应从一开始就内置这些能力。使用真实LLM测试,不断改进工具描述。最终得到的服务,LLM可以像人类一样熟练使用,并能伴随快速增长的AI智能体生态一同扩展。

继续阅读

通过下一篇文章继续沿着相同的学习路径进行学习。