技术
MCP 到底在传什么:从一次工具调用到一个可运行的 Server
关于 MCP,最常见的解释是“AI 世界的 USB-C”。这个比喻适合做开场,却经不起继续追问:插头两边分别是谁?模型是否直接连接 MCP Server?工具是谁挑的?授权又在哪里发生?
不把这些问题拆开,最后很容易得到一种错误印象:只要接入 MCP,模型就能自行访问外部系统。真实情况要克制得多。MCP 规定的是消息怎么表达、能力怎样被发现、调用结果如何返回。至于模型要不要调用、用户有没有权限、一次写操作是否需要确认,仍由承载模型的应用负责。
下面用一个工单系统贯穿全文。我们准备给 AI 应用接入两个能力:查询线上故障,以及读取某条故障的详情。后面再加写操作时,权限问题也会自然浮出来。
先把边界钉住:MCP 连接的不是模型和 API
MCP 的全称是 Model Context Protocol。按最新规范,它是一套建立在 JSON-RPC 2.0 之上的开放协议,用于 AI 应用与外部服务交换工具、资源和提示模板。这个定义里最重要的不是 “Model”,而是 “Protocol”。
一套 MCP 连接里有三个角色:
- Host 是用户正在使用的 AI 应用,例如桌面客户端、IDE 或 Agent 运行环境。它持有对话,调用模型,管理权限和用户确认。
- Client 是 Host 内部的一段协议实现。一个 Client 只连接一个 Server;一个 Host 可以同时管理多个 Client。
- Server 暴露某一组外部能力。它可能包装工单 API、数据库、文件系统,也可能只调用本机程序。
这里有个经常被省略的事实:模型通常不直接发送 MCP 报文。模型看到的是 Host 整理过的工具定义;它返回一次工具调用意图,Host 再通过 MCP Client 把这次意图翻译成 tools/call。Server 也不应默认得到整段对话,它只收到完成当前请求所需的参数和元数据。
因此,MCP 与 Function Call 并不是竞争方案。Function Call 发生在模型接口这一侧,表达“模型想调用哪个函数”;MCP 发生在应用集成这一侧,负责“应用怎样找到并调用外部提供的能力”。二者经常首尾相接。
Server 到底提供什么
Server 侧最常见的三类原语是 Tools、Resources 和 Prompts。它们不只是三种数据格式,还暗含了不同的控制方式。
Tools
可以执行的操作,例如 search_incidents、add_comment。通常由模型根据任务选择,但 Host 可以在执行前拦截和确认。
Resources
可读取的内容,以 URI 标识,例如 incident://1842。更像一层标准化的数据读取接口。
Prompts
Server 提供的参数化提示模板,通常由用户或产品界面选择。它表达的是一种可复用入口,不是隐藏的系统指令。
拿工单系统来说,搜索适合做 Tool,因为模型需要传入关键词和状态,并根据返回结果继续判断;单条工单详情也可以作为 Resource 暴露,方便客户端按 URI 读取;“生成事故复盘”则可以做成 Prompt,让用户明确选择模板并补充事故编号。
边界不是由名词决定的,而是由使用方式决定的。同一份工单详情既能作为 Tool 的结果返回,也能成为 Resource。真正要问的是:它是一次带副作用的动作,还是一份可以定位、缓存和重复读取的数据?如果只是为了“把三种原语都用上”而重复暴露接口,客户端只会更难理解。
跟完一次调用,交互关系就清楚了
假设用户问:“最近和支付超时有关的线上故障有哪些?” Host 已经连接工单 Server。下面这条链路里,只有第二步和第七步需要模型参与;协议通讯、参数校验和实际 API 请求都由普通程序完成。
tools/list,取得工具名、说明和 JSON Schema。search_incidents({query:"支付超时", status:"open"})。tools/call。远程连接走 HTTP,本地进程可以走 stdio。content 或 structuredContent,记录 trace,并控制返回模型的数据量。这也是 MCP 与 Agent 的关系。MCP 给 Agent 增加了可发现的外部能力,却不提供 Agent 的决策循环。循环何时继续、最多调用多少次、失败后是否换工具,由 Host 或外层 Harness 决定。同一个 MCP Server 可以被聊天应用调用,也可以被固定 Workflow 调用,Server 无需知道对面是不是 Agent。
线上真正传输的报文
省略每个请求都要携带的元数据后,一次工具调用的核心很朴素:
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "search_incidents",
"arguments": { "query": "支付超时", "status": "open" }
}
}
// Server → Client
{
"jsonrpc": "2.0",
"id": 12,
"result": {
"content": [{ "type": "text", "text": "找到 2 条故障" }],
"structuredContent": { "items": [/* ... */] },
"isError": false
}
}
content 适合直接进入对话上下文,structuredContent 适合客户端校验、渲染或继续处理。两者并不冲突。只返回一大段“看起来像 JSON 的文本”虽然能跑,却让客户端失去了可靠解析的机会;对稳定接口,最好同时定义输出 Schema。
传输层:stdio 与 Streamable HTTP 解决的是部署问题
| stdio | Streamable HTTP | |
|---|---|---|
| Server 在哪里 | 通常由 Host 在本机拉起一个子进程 | 独立部署的远程服务 |
| 消息怎么走 | stdin/stdout 上的 JSON-RPC | 向单一 MCP 端点发送 HTTP POST;响应可以是 JSON 或请求范围内的 SSE 流 |
| 认证 | 常从环境变量或本机配置取得凭据 | 需要按远程服务设计 OAuth、访问令牌和作用域 |
| 适合 | 个人工具、IDE、本地文件和开发环境 | 团队服务、SaaS、多实例部署和统一治理 |
| 最容易踩的坑 | 把日志写入 stdout,污染协议报文 | Origin 校验、令牌受众、跨用户缓存和超时处理做得不完整 |
stdio 不是“简化版 MCP”。它与 HTTP 承载的是同一类协议消息,只是进程管理和安全条件不同。stdio Server 的 stdout 是协议通道,调试日志必须写到 stderr;一行随手的 console.log 就可能让客户端收到无法解析的 JSON。
远程 Server 也不是把 stdio 程序套一层 Web 路由就结束了。它面对多用户、并发、网关、认证和数据隔离。最新 Streamable HTTP 要求校验 Origin,本地监听应绑定 127.0.0.1,而不是无条件监听 0.0.0.0。这些要求与模型无关,却决定了服务能否安全上线。
远程鉴权应该放在哪里
MCP Server 在 OAuth 体系中是 Resource Server:它验证访问令牌,但不应该顺手承担账号登录和令牌签发。下面只展示 HTTP 入口的鉴权落点,verifyAccessToken 应交给成熟的身份系统实现,并严格校验签名、issuer、audience、有效期和 scope。
npm install @modelcontextprotocol/express @modelcontextprotocol/node express
import type { OAuthTokenVerifier } from "@modelcontextprotocol/express";
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
requireBearerAuth
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
const endpoint = new URL("https://mcp.example.com/mcp");
const verifier: OAuthTokenVerifier = { verifyAccessToken };
const auth = requireBearerAuth({
verifier,
requiredScopes: ["incidents:read"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(endpoint)
});
const app = createMcpExpressApp({
host: "0.0.0.0",
allowedHosts: ["mcp.example.com"],
allowedOrigins: ["https://agent.example.com"]
});
const handler = toNodeHandler(createMcpHandler(createServer));
app.all("/mcp", auth, (req, res) => void handler(req, res, req.body));
这一层验证的是“请求能不能进入 MCP Server”。某个 Tool 是否允许执行,还要在处理函数里检查更细的 scope 和对象权限。入口通过,不等于调用者可以访问所有工单。
版本变化:为什么旧教程和新规范看起来不像同一种协议
本文以 2026-07-28 规范为准。这个版本对协议核心做了较大调整。如果你读过早期文章,熟悉的流程多半是:Client 先发送 initialize,Server 返回版本和能力,Client 再发 initialized;Streamable HTTP 还可能维持 Mcp-Session-Id。
2026-07-28 删除了这套握手和协议级 Session。每个请求都自带协议版本、Client 信息和能力元数据。Client 如果想事先了解 Server,可以调用 server/discover,但这不是每次业务调用的必经步骤。一个请求因此可以落到负载均衡器后面的任意实例。
旧版
initialize 建立协议上下文,后续请求依赖已经协商好的连接状态。
新版
请求自描述。协议版本与 Client 能力随请求携带,server/discover 用于按需发现。
没有消失的东西
购物车、浏览器页签、长任务仍需要状态,只是要用显式 ID 或 handle 放进业务数据,而不是藏在连接 Session 里。
“协议无状态”不等于“应用不能有状态”。假设 Tool 创建了一次数据导出,应返回 job_id;后续查询进度时,Client 把这个 ID 作为参数再次传入。状态归业务系统管理,调用关系也变得可追踪。兼容旧客户端时则应依赖 SDK 的版本协商能力,不要在业务处理函数里到处判断协议年代。
动手写一个 MCP Server
下面实现一个只读的故障查询 Server。代码使用官方 TypeScript Server SDK 的当前接口和 stdio 传输。为了让示例可以独立运行,后端数据暂时放在数组里;换成真实系统时,只需替换 searchIncidents,协议层不用改。
1. 建项目
mkdir incident-mcp
cd incident-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node
mkdir src
package.json 至少补上 ESM 类型和构建命令:
{
"type": "module",
"scripts": {
"build": "tsc"
}
}
2. 注册工具
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
type Incident = {
id: string;
title: string;
status: "open" | "resolved";
severity: "P1" | "P2" | "P3";
};
const incidents: Incident[] = [
{ id: "1842", title: "支付回调持续超时", status: "open", severity: "P1" },
{ id: "1837", title: "订单页偶发超时", status: "resolved", severity: "P2" }
];
function createServer() {
const server = new McpServer({
name: "incident-desk",
version: "1.0.0"
});
server.registerTool(
"search_incidents",
{
description: "按关键词和状态查询故障工单。只读取,不修改工单。",
inputSchema: z.object({
query: z.string().min(1).max(100)
.describe("标题关键词,不要传入整段用户问题"),
status: z.enum(["open", "resolved", "all"]).default("open"),
limit: z.number().int().min(1).max(20).default(10)
})
},
async ({ query, status, limit }) => {
const keyword = query.toLowerCase();
const items = incidents
.filter(item => item.title.toLowerCase().includes(keyword))
.filter(item => status === "all" || item.status === status)
.slice(0, limit);
return {
content: [{
type: "text",
text: items.length
? `找到 ${items.length} 条故障:${items.map(x => `${x.id} ${x.title}`).join(";")}`
: "没有找到匹配的故障"
}],
structuredContent: { items }
};
}
);
return server;
}
void serveStdio(createServer);
console.error("incident-desk MCP server is running");
这段代码不长,真正值得注意的是接口收口:
- Server 没有暴露“执行任意工单 API”,而是给出一个语义明确的只读操作。
status用枚举,limit有上限,避免模型制造无限结果集。- 工具说明明确写出“不修改工单”,这有助于模型选择,但它不是安全边界。后端凭据仍应只有读取权限。
- 返回内容同时照顾模型和程序:自然语言摘要放在
content,原始条目放在structuredContent。
3. 再补一份 Resource 和一个 Prompt
查询是一次动作;工单详情则有稳定 URI,更适合做 Resource。下面的模板让 Client 可以读取任意 incident://{id}。Prompt 只提供复盘提纲,不读取工单,也不会自行调用 Tool。
// 放在 createServer() 的 return server 之前
server.registerResource(
"incident-detail",
new ResourceTemplate("incident://{id}", { list: undefined }),
{
title: "故障工单详情",
description: "按编号读取一条故障工单",
mimeType: "application/json"
},
async (uri, { id }) => {
const incident = incidents.find(item => item.id === String(id));
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(incident ?? { error: "incident_not_found" })
}]
};
}
);
server.registerPrompt(
"incident-review",
{
title: "故障复盘",
description: "根据一条故障工单整理复盘提纲",
argsSchema: z.object({
incidentId: z.string().describe("故障工单编号")
})
},
({ incidentId }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `请读取 incident://${incidentId},按时间线、根因、影响、修复和后续行动整理复盘。缺少证据的部分不要猜。`
}
}]
})
);
list: undefined 表示这个 URI 模板可以读取,但 Server 不枚举全部实例。真实工单可能有几十万条,强行塞进 resources/list 没有意义。Client 已知编号时,直接构造 URI 即可。
4. 构建、配置与测试
npx tsc
npx @modelcontextprotocol/inspector node /绝对路径/incident-mcp/build/index.js
Inspector 可以查看工具列表、填写参数并直接调用。先在这里确认 Schema、空结果、异常和大结果集,再接入真实 Host。客户端配置的本质,是告诉 Host 用什么命令拉起 stdio Server:
{
"mcpServers": {
"incident-desk": {
"command": "node",
"args": ["/绝对路径/incident-mcp/build/index.js"]
}
}
}
配置文件的具体位置由 Host 决定,不属于 MCP 协议。Claude Desktop、IDE 和其他 Agent 产品可以采用不同界面,底下仍然是在创建一个 Client 并连接 Server。
从演示代码到可用服务,工作量主要在协议外
把一个 Tool 注册出来很快。决定它是否能长期使用的,反而是传统服务端工程。
错误应该让调用方知道下一步还能做什么
“Something went wrong” 对 Agent 几乎没有价值。一个可恢复的错误至少要说明类别:参数不合法、权限不足、下游限流、服务暂时不可用,还是对象不存在。是否允许重试、建议等待多久,也应尽量结构化。否则模型往往会换个说法重复调用,表面上在探索,实际只是在撞同一堵墙。
对写操作尤其要谨慎。HTTP 超时只表示 Client 没拿到结果,不表示下游没有创建成功。没有幂等键和查询接口时,自动重试可能重复创建工单、重复发信,甚至重复付款。
工具描述是输入,不是可信代码
Host 会把工具名、说明和 Schema 放进模型上下文,因此 Server 返回的描述本身也是外部输入。客户端不能因为它长得像配置就完全信任。连接陌生 Server 等于允许对方影响模型看到的工具说明和资源内容,提示注入由此进入调用链。
实际部署时至少要分三道关:
- Host 只连接用户或管理员认可的 Server,并控制哪些工具可见。
- 高影响调用在 Host 侧显示明确的确认界面,不能只展示一个模糊的“允许”。
- Server 和后端系统按真实身份再次授权。即使 Host 漏拦,低权限令牌也不应完成越权操作。
远程 Server 接第三方 API 时,不要把 Client 给 MCP Server 的令牌原样转发给下游。规范明确禁止 token passthrough:给 MCP Server 的 token 与访问第三方 API 的 token 应有不同受众和独立授权,否则容易形成 confused deputy,也很难建立可靠审计。
MCP 解决了什么,又故意没解决什么
MCP 的价值不在于让单次 API 调用变短。只接一个模型、三个内部函数时,直接 Function Call 往往更省事。它真正有用的地方,是让能力提供方和 AI 应用之间形成可复用边界:Server 可以声明自己有什么,Host 可以用同一套协议接入不同来源,Agent 也不必为每个系统学习一套私有连接方式。
但协议没有替你完成下面这些工作:
- 它不决定模型是否应该调用某个工具。
- 它不提供完整的 Agent loop、计划器、记忆或任务验收。
- 它不理解企业内部“关闭故障”和“解决故障”的业务差别。
- 它不自动把工具变安全,也不替后端做权限、审计和幂等。
- 它不保证不同 Host 会以完全相同的方式展示和审批调用。
理解这条边界后,选型会简单许多。如果能力只服务一个应用,普通函数已经稳定,不必为了追赶名词再加一层协议。如果同一组工具需要进入多个 AI 客户端,或者你正在做面向外部开发者的能力服务,MCP 才开始体现复用价值。
最后回到开头那条工单查询。模型负责从用户问题里判断“需要查故障”;Host 决定它能看到什么工具,并执行授权策略;MCP Client 把调用变成标准请求;Server 把请求落到真实工单系统;后端权限决定它究竟能读什么、改什么。五层责任都在,系统才完整。少画一个箭头不影响演示,少做一道边界检查,迟早会在生产环境补回来。
参考资料
Share