到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 / CallToolRequestSchema 的 setRequestHandler。如果需要完全控制请求处理器,可以使用它们;但对大多数服务器而言,McpServer.registerTool 更简短,也更不容易出错。)
这只是骨架。真正的工作在于工具处理器中放什么,以及如何放。
模式1:工具设计理念
第一个决定是:公开哪些工具,以及采用什么粒度?
常见失败方式是直接把底层API暴露为工具。如果有200个REST端点,公开200个工具将是一场灾难。模型面对过多工具时表现会变差;工具描述难以管理;协议会变成迷宫。
更好的做法是:根据智能体的使用方式来设计工具。每个工具只做一件定义清晰的事,接收定义清晰的输入,并返回定义清晰的输出。
以下是一些原则:
每个工具只对应一个概念。 不要设计一个能做12件不同事情的 manage_customer 工具。应分别提供 search_customers、get_customer、update_customer_email、archive_customer,各自专注于一项操作。
粒度适当。 粒度太细,智能体需要发起很多次调用;粒度太粗,又无法精确完成所需操作。目标应是“人类会给它起一个名字的操作”。
使用动作动词。 使用 search_documents,而不是 documents。工具名称应体现它所执行的动作。
区分读写。 读取工具更安全;写入工具有副作用。应通过命名加以区分(list_x 与 create_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智能体生态一同扩展。



