Jack N @ GitHub

Full stack AI Engineer, focus on: React, Next.js, node.js and .Net

本系列完结篇。前四篇覆盖了协议规范、核心原语、传输层和进阶能力,本篇从”协议是什么”转向”怎么在生产环境稳定跑起来”:SDK 上手、安全模型、网关架构、可观测性、限流熔断,最后横向对比 Function Calling、Agent 框架、A2A,给出选型决策与反模式清单。

一、SDK 选型与基础代码骨架

MCP 本身只是协议,手写 JSON-RPC、流分割、重连非常容易出错。生产优先使用官方/社区 SDK,不要裸写协议层。

1.1 主流 SDK

  • TypeScript / Node.js:@modelcontextprotocol/sdk(官方,生态最好,更新最快)
  • Python:mcp(官方),适合数据、脚本、AI 工具快速封装
  • Go:github.com/modelcontextprotocol/go-sdk,适合高性能网关、常驻服务
  • Rust:官方 Rust SDK,适合资源占用低、安全敏感的独立 Server
  • C# / Java:社区第三方实现,版本跟进略慢,适合传统后端集成

原则:业务语言优先官方 SDK;网关、代理、高吞吐组件优先 Go / Rust。

1.2 Node.js 最简 stdio Server 骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
{ name: "demo-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "echo",
description: "回显文本",
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
},
],
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
const text = req.params.arguments.text as string;
return { content: [{ type: "text", text }] };
});

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

要点:

  • SDK 自动处理 JSONL 分割、JSON-RPC id 路由、握手、capability 协商;
  • 业务只需要注册各类请求 handler;
  • 如果开启 sampling、roots、resources,在初始化 capabilities 里声明,再注册对应 handler。

1.3 Streamable HTTP 服务骨架要点

HTTP 版本拆成两块:SSE 下行流管理(维护每个 sessionId 对应的 SSE 连接、消息入队)和 POST 上行接收(校验 session、鉴权、投递消息到对应会话)。SDK 一般提供独立的 StreamableHttpTransport,内置会话存储、SSE 推送。

自建 HTTP MCP 最大坑:SSE 断开后消息队列堆积,必须设置单会话消息上限、过期丢弃。

二、完整安全模型

MCP 的安全不是单点,是分层防御,很多人只依赖 Roots,这远远不够。

2.1 分层安全框架

  1. 传输层安全

    • stdio:本机信任边界;不要用 stdio 加载来源不明的第三方 Server;
    • Streamable HTTP:必须 HTTPS,禁止裸 HTTP;使用 Bearer / mTLS,不要把密钥放 query;
    • 代理侧:禁止开放公网无鉴权 MCP 端点。
  2. 身份与鉴权层

    • 每个用户独立会话;鉴权信息和 sessionId 解耦;
    • 多租户场景:所有后端操作带上用户身份上下文,不能共用一个数据库账号;
    • 权限最小化:同一个 MCP Server,不同用户看到不同工具、不同 roots。
  3. Roots(资源沙箱)

    • 只管控 resources/read;它是白名单,不是系统沙箱;
    • 校验逻辑:严格 URI 前缀校验,不能简单字符串匹配(规避 file:///work../etc/ 这类逃逸);
    • 推荐 Host 侧也做一层同样的 URI 校验,双重防御。
  4. 工具权限隔离(最容易缺失)
    Roots 管读资源;工具的写、删除、执行、SQL、接口调用需要单独权限系统:

    • 工具白名单:用户 A 允许 read_file,不允许 delete_file;
    • 参数校验:高危工具增加参数正则、黑名单;
    • 人工审批策略:匹配高危工具 + 敏感参数,强制弹窗确认;
    • 审批记录永久留存。
  5. Sampling 安全边界

    • Server 提交的 sampling 请求属于不可信输入,统一经过 Host 的内容安全审核;
    • 可配置策略:某些 Server 禁止 sampling,或必须人工审批;
    • Token 配额按用户隔离,防止某个 Server 无限消耗模型额度。
  6. 提示注入防护

    • 外部资源、工具返回内容在送入 LLM 时,做边界封装;
    • 不把外部原始文本直接和系统提示拼接;
    • 高安全场景使用分隔标记、独立消息块、内容摘要替代原文。

2.2 高危操作黑名单(生产建议)

任何下面这类工具,默认不开放,开启必须强审批:

  • 文件删除、覆盖、批量修改;
  • 任意 shell 执行;
  • 无限制 SQL 执行;
  • 发送邮件、短信、企业消息;
  • 访问内部凭证、密钥仓库;
  • 公网出网扫描。

2.3 供应链风险

MCP 生态是”Host 加载外部 Server”,这是核心攻击面:

  • 第三方 MCP Server 拥有你下发的 roots、工具权限;
  • 恶意 Server 可以通过 sampling 诱导 Host 模型泄露信息;
  • 企业内部:维护 MCP Server 准入清单,禁止用户随意加载外部 Server。

三、网关架构(企业主流落地形态)

3.1 网关职责

  • 统一接入:对外只暴露一个 Streamable HTTP 端点;
  • 鉴权 & SSO:对接 OIDC、内部账号;
  • 路由:按请求、用户配置转发到对应的后端 MCP Server(stdio 子进程或远程 HTTP MCP);
  • 能力聚合:合并多个后端的 tools / resources / prompts,对外统一返回;
  • 权限裁剪:根据用户权限过滤工具、裁剪 roots;
  • 审计、限流、熔断、日志集中采集;
  • 协议适配:后端是 stdio,对外暴露成 Streamable HTTP。

3.2 两种网关模式

  1. 透明代理模式:网关只转发消息,不解析业务原语。实现简单,但无法做工具粒度权限、聚合;
  2. 控制平面网关(智能网关):网关完整解析 MCP 消息,可以在 tools/list 返回前过滤工具、在 tools/call 之前拦截审批、修改 roots 下发列表、合并多个后端能力集。企业生产首选,复杂度更高。

3.3 网关会话管理

网关侧持有 sessionId,一个网关会话可以绑定多个后端 MCP 会话,负责生命周期管理:

  • 用户登出:批量销毁所有后端会话、杀掉 stdio 子进程;
  • 闲置超时:回收连接、释放文件句柄、数据库连接;
  • 后端 Server 崩溃:网关感知,向上返回错误,可选自动重建后端会话。

3.4 部署拓扑示例

1
2
3
4
5
6
用户 Host(Claude Desktop / 自研 Agent)
↓ HTTPS(Streamable HTTP)
MCP Gateway(鉴权、审计、路由、限流)
├─→ 内部 stdio MCP Server(文件、Git)
├─→ 远程 HTTP MCP Server(工单、知识库)
└─→ 内部服务封装 MCP(运维、发布平台)

四、可观测性

MCP 是异步双向协议,链路追踪比普通 REST 更复杂。可观测分三块:结构化日志、指标、链路追踪。

4.1 结构化日志规范

每条日志至少包含:sessionId、user_id、requestId(JSON-RPC id)、method、耗时、状态、错误码、工具名称、URI(脱敏)。

区分几类日志:

  • 审计日志(不可篡改):所有 tools/call、roots 变更、sampling 请求、审批结果;
  • 运行日志:info / warn / error,用于排障;
  • 访问日志:连接建立、断开、重连、鉴权失败。

不要直接把完整参数明文入库:密钥、敏感内容脱敏。

4.2 核心指标(Prometheus)

  • 连接指标:活跃会话数、新建会话、会话销毁、SSE 重连次数;
  • 请求指标:每个 method 的 QPS、成功/失败计数、P50/P95/P99 耗时;
  • 工具粒度:每个工具调用次数、失败率、平均耗时;
  • Sampling 指标:反向模型调用次数、token 消耗、拒绝次数;
  • 资源指标:resources/read 读取量、订阅数量;
  • 资源水位:子进程数量、消息队列堆积、内存。

告警重点:工具错误率突增、SSE 频繁重连、会话堆积不释放、sampling token 消耗突增、鉴权失败风暴。

4.3 链路追踪

MCP 是异步双向,一条链路可能跨 Host → Gateway → MCP Server → 下游服务,还可能反向 sampling 再回到 Host。方案:

  • 用自定义 header(traceparent,W3C trace)在 Streamable HTTP 上透传;
  • stdio 场景:通过环境变量注入 trace id;
  • 一条 tools/call 生成一条 trace;里面触发 sampling 作为子 span;
  • 进度通知、资源更新通知打上同一个 trace id,串联整个任务。

五、限流、熔断、背压、重试

MCP 双向异步,盲目重试很容易放大故障。

5.1 限流维度

  • 用户级:每个用户总 QPS、sampling token 上限、并发工具调用数;
  • 会话级:单会话消息速率,防止单会话打满服务;
  • 工具级:高危工具单独限流;
  • 网关全局限流。

5.2 熔断

针对后端 MCP Server:连续报错超过阈值就熔断、快速失败;熔断状态下不再转发请求,返回友好错误;配合熔断窗口、半开探测。适合场景:后端数据库挂掉、文件服务宕机、第三方接口超时。

5.3 背压(非常关键)

异步通道天然存在背压问题:发送速度 > 处理速度,消息队列无限堆积,OOM。策略:

  • 每个会话设置消息队列最大长度;
  • 队列满时:拒绝新消息或丢弃旧消息;
  • 长任务配合 progress 通知,同时控制并发;
  • stdio:注意进程管道缓冲区阻塞,Node / Python 尤其容易卡住。

5.4 重试策略

  • JSON-RPC 请求:只有幂等操作才允许重试;删除、发送消息这类非幂等禁止自动重试;
  • 网络断开重连:SSE 按服务端 retry 间隔指数退避;
  • 业务失败(权限不足、参数错误)不重试;只有连接类、临时超时可以重试;
  • 重试必须带最大次数,防止风暴。

六、部署与测试

6.1 部署要点

stdio:进程托管(systemd / supervisor);监听子进程退出回收僵尸进程;版本升级重启进程即重启会话;用 cgroup 限制 CPU、内存。

Streamable HTTP:容器化部署依赖外部会话存储(Redis)保存会话上下文;不要存在容器本地内存,否则扩缩容、滚动发布会话丢失;如果会话在 Redis,负载均衡不需要粘性会话;如果会话在进程内存,必须会话亲和,生产不推荐。

6.2 测试体系(四层)

  1. 单元测试:单独测试每个 tool、resource、prompt handler;用内存 transport 隔离;
  2. 协议一致性测试:校验握手、capability 协商、消息格式、错误码、通知语义;官方提供部分 conformance test 套件,可接入 CI;
  3. 集成测试:启动真实 transport,覆盖正常调用、参数错误、越界 roots、sampling 往返、SSE 断开重连、工具列表动态变更;
  4. 混沌测试(生产前):随机断开 SSE、杀掉子进程、注入网络延迟丢包、超限大报文,验证不雪崩。

不要只测”成功路径”;MCP 生产故障大多出在异步、断连、反向调用、权限逃逸。

6.3 排障清单(高频故障)

  1. 握手卡住、JSON parse error:stdio 场景 Server 在 stdout 打印日志;改成 stderr 或协议内 logging/message;
  2. SSE 连上但收不到返回:检查 POST 是否携带正确 sessionId;SSE 是否被代理截断;消息队列是否满了;
  3. roots 下发了仍可读取越界文件:URI 校验实现用了简单 startsWith,存在逃逸;改用 URI 标准库解析后校验;
  4. sampling 请求发出去没有响应:Host 没在 initialize 声明 sampling;或 Host 拦截拒绝没有返回;
  5. 工具列表不更新:Host 没监听 tools/list_changed、没有主动刷新 tools/list;
  6. 会话越积越多,内存泄漏:没有闲置超时;SSE 断开会话没清理;
  7. 重连之后订阅失效:订阅是会话级,重连后需重新 resources/subscribe;
  8. 高并发下请求卡死:背压缺失、无队列上限、同步阻塞 handler 吃掉事件循环。

七、选型对比:MCP 在 AI 生态中的定位

7.1 MCP vs OpenAI Function Calling

二者不是替代关系,而是层级完全不同:

  • Function Calling 是 LLM 厂商的请求参数格式,嵌入在模型请求 payload 内部,绑定模型调用链路;
  • MCP 是独立于模型的双向会话协议,有独立的会话、订阅、反向采样、权限、生命周期。
维度 Function Calling MCP
绑定关系 和模型强绑定 和模型完全解耦
会话状态 无原生会话 原生长会话、订阅、动态能力变更
双向性 单向:LLM → 工具 双向:支持 Sampling
能力发现 每次请求携带或静态配置 运行时动态发现、动态刷新
权限沙箱 没有内置规范 内置 Roots
跨客户端复用 不同客户端需各自封装 同一个 Server 可被多个 Host 接入

选型:简单、一次性、短流程的工具调用优先 Function Calling;工具服务要被多个客户端复用、需要长期资源订阅、需要反向采样、需要统一权限审计 → MCP。

最常见组合:MCP 做工具服务层,Function Calling 做模型调用层——Host 把 MCP tools/list 转换为 function schema 发给兼容 OpenAI 接口的模型。

7.2 MCP vs Agent 框架(LangGraph、AutoGen、CrewAI)

Agent 框架解决编排、记忆、规划、多轮推理;MCP 解决标准化工具接入层。

  • 只用框架、不用 MCP:上手快,但工具和编排代码耦合,新增工具要改代码发版本,很难被其他 Agent 复用;
  • 框架 + MCP:编排逻辑稳定,工具动态插拔,同一工具集可同时服务网页 Agent、桌面客户端、后台批处理;代价是多一层协议和运维体系。

反模式:把复杂 Agent 编排逻辑写在 MCP Server 内部。MCP Server 应该是能力提供者,不是规划器。

7.3 MCP vs A2A(Agent2Agent)

一句话记忆:

  • A2A:Agent 找另一个 Agent 干活(任务委派、状态同步);
  • MCP:Agent 找工具/系统干活(能力暴露)。

组合架构(联邦 Agent):

1
2
3
4
主 Agent(Host)
├─ MCP → 文件、数据库、业务系统(工具)
└─ A2A → 子 Agent(另一个独立 Agent)
└─ 子 Agent 内部再通过 MCP 调用它自己的工具

二者没有竞争关系,大型多 Agent 系统一般同时存在。如果只是让 Agent 读写文件、查内部系统,没有跨 Agent 委派,只用 MCP 就足够。

八、选型决策树与反模式

8.1 决策树

  1. 只是给单一模型网关提供简单工具?→ 优先 Function Calling,轻量够用;
  2. 工具需要被多个客户端复用?→ 倾向 MCP;
  3. 有长生命周期资源(文件仓库、订阅变更)?→ 倾向 MCP;
  4. 需要工具服务反向请求模型做子推理?→ MCP 的 Sampling 是核心卖点;
  5. 是 Agent 和另一个独立 Agent 委派任务?→ 用 A2A,而不是把 Agent 封装成 MCP 工具;
  6. 只在一个编排框架内部使用、不对外暴露工具?→ 可以不引入 MCP,直接本地函数;
  7. 决定上 MCP 后:个人桌面、本地开发 → stdio;内网共享、多用户 → Streamable HTTP + 网关;跨部门联邦 → 多层网关 + 能力注册表。

核心原则:MCP 不是默认答案,它是为「能力可独立部署、可共享、有会话和资源生命周期」而设计的方案。

8.2 经典权衡点

  1. 能力聚合 vs 透明转发网关:透明转发简单但做不到工具粒度权限;智能聚合网关能力强,但要完整解析 MCP 语义,兼容性风险更高;
  2. 本地 stdio 隔离 vs 远程 HTTP 共享:stdio 隔离性好但不能跨机器;HTTP 共享方便但必须处理鉴权、会话存储、网络故障;
  3. Server 内做业务逻辑,还是推到 Host:MCP Server 适合封装外部系统;复杂规划、多轮判断、记忆放在 Host/编排层。Server 逻辑太重会变成隐藏的 Agent,难以审计追踪。

8.3 高频设计反模式

反模式 问题 正解
把完整 Agent 封装成一个 MCP Tool 两层会话状态叠加、生命周期冲突、审计颗粒度变粗 Agent 作为 Host,MCP 调用底层系统;跨 Agent 委派走 A2A
用 MCP 替代普通同步 API 协议开销、会话管理全是额外负担 简单无状态查询直接用 HTTP API
Roots 当成万能安全边界 工具可以绕过 Roots 写文件、执行命令 Roots 只保护 resources/read;工具权限独立管控
在 MCP Server 里硬编码模型 Key 密钥分散、配额审核难统一 走 Sampling,模型收敛在 Host
无限缓存工具列表不刷新 动态变更后客户端看不到最新工具 监听 tools/list_changed 通知并刷新
自定义扩展不用 x- 前缀 未来官方升级直接冲突 私有接口必须 x- 前缀
业务状态存在 Server 本地内存 多实例部署负载均衡后状态丢失 会话外部持久化(Redis)

九、系列总结:MCP 的核心思想

回顾整个系列:

  1. 协议基础:角色、JSON-RPC、会话生命周期、initialize、capability 协商;
  2. 三大原语:Tools(执行动作)、Resources(只读资源)、Prompts(可复用提示模板);
  3. 传输层:stdio(本地)、Streamable HTTP(远程,POST + SSE);
  4. 进阶能力:Sampling(反向模型调用)、Roots(资源沙箱)、通知体系、错误、扩展规范;
  5. 生产工程:SDK、分层安全、网关、可观测、限流熔断、部署测试排障;
  6. 选型生态:对比 Function Calling、Agent 框架、A2A;决策树、反模式。

MCP 的核心思想一句话概括:

将外部系统的能力,封装成一套独立于模型、独立于 Agent 编排、可共享、带会话与权限语义的标准化服务。

它不是一个做 Agent 的框架,而是让 Agent 安全、标准地接入真实世界系统的一层基础设施——AI 能力接口的”USB-C”。


系列文章导航

  1. 深入浅出 MCP 系列教程(一):协议基础与架构设计
  2. 深入浅出 MCP 系列教程(二):三大核心原语实战
  3. 深入浅出 MCP 系列教程(三):传输层实战
  4. 深入浅出 MCP 系列教程(四):进阶能力
  5. 深入浅出 MCP 系列教程(五):工程落地与选型(本篇)

1. 题目

给你两个按非递减顺序排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n,分别表示 nums1 和 nums2 中的元素数目。

请你合并 nums2 到 nums1 中,使合并后的数组同样按非递减顺序排列。

注意:最终合并后数组不由函数返回,而是存储在数组 nums1 中。nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0,应忽略。nums2 的长度为 n。

示例:

1
2
输入: nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出: [1,2,2,3,5,6]

2. 解题思路

2.1 为什么不能从前往后双指针?

若从下标 0 开始比较写入 nums1,会覆盖掉 nums1 尚未读取的有效元素。除非先拷贝一份 nums1(额外 O(m) 空间)。

2.2 逆向双指针(从头填充 → 从尾填充,最优)

从后往前填:nums1 的尾部 [m+n-1] 是空的(全是 0 占位),把较大元素放到尾部就不会覆盖未处理数据。

  • 三指针:p1 = m-1、p2 = n-1、tail = m+n-1。

  • 每次比较 nums1[p1] 与 nums2[p2],把较大者放到 tail,对应指针前移。

  • 当 p1 < 0 时,把 nums2 剩余元素拷进 nums1 头部;当 p2 < 0 时 nums1 剩余部分本就在正确位置,无需处理。

  • 时间复杂度:O(m + n),空间复杂度:O(1)。

3. TypeScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function merge(
nums1: number[],
m: number,
nums2: number[],
n: number
): void {
let p1 = m - 1;
let p2 = n - 1;
let tail = m + n - 1;

while (p1 >= 0 && p2 >= 0) {
if (nums1[p1] >= nums2[p2]) {
nums1[tail--] = nums1[p1--];
} else {
nums1[tail--] = nums2[p2--];
}
}

// nums2 还有剩余,必须拷进 nums1 前段
while (p2 >= 0) {
nums1[tail--] = nums2[p2--];
}
// nums1 有剩余则已在正确位置,无需处理
}

const a = [1, 2, 3, 0, 0, 0];
merge(a, 3, [2, 5, 6], 3);
console.log(a); // [1,2,2,3,5,6]

4. 面试延伸

  • 核心技巧「从后往前」:同适用于「字符串原地替换 / 删除元素」等避免覆盖的场景,是原地合并的通用思路。
  • 对比:合并两个有序链表 用 dummy head 顺序拼接;合并区间(Merge Intervals)则先排序再线性扫描。三者都是「有序归并」家族。
  • 追问:为什么相等时先放 nums1?任意皆可,不影响正确性,只影响稳定性表述。
  • 归并排序 merge 步骤正是此逻辑,可顺势聊排序稳定性。

难度:简单 | LeetCode 88 | 逆向双指针 / 归并

本系列共 5 篇。前三篇覆盖了协议基础、三大原语和传输层,已构成一个最简可用的 MCP 服务。本篇讲解协议里用于双向协作、安全约束、异步反馈、扩展的高阶能力:Sampling、Roots、通知体系、精细化错误模型与扩展机制。这些能力全部是可选 capability,在 initialize 阶段协商开启,不是必选。

一、Sampling —— Server 反向调用大模型

Sampling 是 MCP 最有特色、也最容易被低估的能力。常规流程是 Host 调用 Server;Sampling 反过来,由 MCP Server 发起请求,请 Host 调用它持有的大模型,再把模型返回结果交还给 Server。

1.1 适用场景

普通 function calling 是:LLM → 调用工具。Sampling 是:工具服务 → 请求 LLM 帮忙做推理。典型场景:

  • 代码解析 Server:拿到一段代码,请求模型做摘要、风险识别;
  • 文档 Server:读取文档后,让模型抽取要点;
  • 工单 Server:拿到工单上下文,调用模型生成初步回复;
  • 多 Agent 编排:一个 MCP Server 本身作为子 Agent,需要上层 Host 的模型能力完成子任务。

关键约束:Sampling 不是”Server 自己持有模型 Key”。模型密钥、模型选择、上下文配额、用户权限全部在 Host 一侧。Server 只负责描述:我想要什么样的模型输出。

1.2 开启前提

必须同时满足两个条件:

  1. Client(Host)在 initialize 的 capabilities 里声明 sampling: {};
  2. Server 在响应的 capabilities 里声明支持接收采样请求。

只有两边都声明,会话内才允许使用 sampling/createMessage。如果 Host 不支持 sampling,Server 绝对不能发起这个调用,否则收到 -32601 方法不存在错误。

1.3 请求报文(Server → Host)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"jsonrpc": "2.0",
"id": "sample-001",
"method": "sampling/createMessage",
"params": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "请总结下面这段日志,只列出错误:\nConnection refused at port 8080"
}
}
],
"modelPreferences": {
"hints": [{"name": "gpt-4o-mini"}],
"intelligencePriority": 0.7,
"speedPriority": 0.2,
"costPriority": 0.1
},
"systemPrompt": "你是日志分析助手,输出简洁。",
"maxTokens": 512,
"temperature": 0.3,
"stopSequences": ["---"]
}
}

字段说明:

  • messages:消息数组,支持 text / image / resource 内容;
  • modelPreferences:偏好提示,不是强制。Host 可以忽略,按照用户配置、配额、可用模型自行选择;
  • maxTokens、temperature、stopSequences:推理参数,Host 有权限做上限拦截;
  • 没有 stream 参数:标准 sampling 接口默认返回完整消息,不强制流式。

1.4 Host 返回报文

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"jsonrpc": "2.0",
"id": "sample-001",
"result": {
"role": "assistant",
"content": {
"type": "text",
"text": "错误:8080 端口连接被拒绝"
},
"model": "gpt-4o-mini",
"stopReason": "endTurn"
}
}

stopReason 可选枚举:endTurn、maxTokens、stopSequence。

1.5 安全与审批流(非常重要的工程点)

  1. Host 拥有最终否决权:收到 sampling/createMessage,Host 可以直接调用模型返回结果、弹出审批让用户确认、或直接拒绝返回错误;
  2. 计费、token 消耗归属 Host;多租户 Host 必须按用户隔离统计;
  3. 提示注入风险:Server 传入的 messages 属于外部不可信输入,Host 需要做边界隔离;
  4. 禁止 Server 通过 sampling 绕过 Host 的安全策略,例如内容审核必须统一经过。

1.6 典型误区

误区 正解
Sampling 代表 Server 可以绕过 Host 随便调用模型 Host 可以拦截、拒绝、替换模型、覆盖参数
modelPreferences 是硬性要求 只是建议,Host 可以无视
Sampling 等同于把模型 Key 交给 Server Server 永远拿不到密钥

二、Roots —— 资源访问边界控制

Roots 是 MCP 内置的沙箱机制,专门约束 Resources 的读取范围,解决一个核心风险:如果 filesystem MCP Server 可以读取本机任意文件,一旦被 LLM 诱导,很容易泄露密钥、配置文件。

Roots 是 Host 下发给 Server 的一组允许访问的根 URI 列表。

2.1 开启前提

握手时 Client(Host)声明 roots: {} 能力,Server 识别之后就必须遵守 Host 下发的根列表。Roots 属于 Host → Server 的单向配置,由 Host 主动推送通知,不是一次性握手参数。

2.2 下发通知

这是一条通知(无 id),Host 在握手完成之后随时可以推送,包括后续动态更新权限:

1
2
3
4
5
6
7
8
9
10
11
12
{
"jsonrpc": "2.0",
"method": "notifications/roots/list_changed",
"params": {
"roots": [
{
"uri": "file:///home/user/work",
"name": "工作目录"
}
]
}
}
  • uri:根地址;name:人类可读备注;
  • 可以下发多个 roots;空数组代表:不允许访问任何资源。

Server 收到通知后,更新自己的访问白名单。

2.3 校验规则

Server 需要判断:目标 URI 是否以其中一个 root URI 为前缀(按 URI 语义,不是简单字符串 startsWith)。

允许 root:file:///home/user/work

  • ✅ file:///home/user/work/readme.md
  • ✅ file:///home/user/work/src/index.js
  • ❌ file:///home/user/.ssh/id_rsa(不在根下)
  • ❌ file:///etc/passwd

校验是 Server 的责任,协议约定 Server 主动拒绝越界访问;Host 也可以叠加一层校验作为第二层防线。

2.4 动态变更

Host 在会话存活期间可以多次推送 roots/list_changed:增加目录、删除目录、替换成全新列表、设置为空冻结所有资源读取。

典型场景:用户在客户端 UI 里勾选/取消允许访问的文件夹,Host 立刻推送更新给 filesystem server。

2.5 适用范围与局限

  • Roots 主要面向 file://,但语义上是通用 URI,postgres://、git:// 等 scheme 也可以实现自己的 root 校验;
  • Roots 只约束 Resources,协议层面不强制约束 Tools;工具的权限校验需要 Server / Host 单独实现;
  • Roots 是语义约定,不是操作系统级沙箱;恶意实现的 Server 可以无视,所以 Host 仍需要不信任不受控的第三方 MCP Server。

三、完整通知体系

MCP 大量异步事件依靠 Notification(不带 id,不需要回复)。通知分为两类:

  • 标准通知:协议规范内定义,所有兼容实现语义统一;
  • 自定义通知:以 x- 前缀,用于业务扩展。

3.1 会话基础通知

  1. initialized:Client → Server,握手完成;
  2. notifications/roots/list_changed:Host → Server,根目录权限更新。

3.2 Resources 相关通知

notifications/resources/updated:Server → Host,某个已订阅的资源发生变更。

再次强调:通知只代表”变了”,不会携带新内容;Host 需要最新内容时主动调用 resources/read。

3.3 Tools / Prompts 列表变更通知

notifications/tools/list_changed:Server → Host,可用工具列表动态变化。适用场景:Server 的工具根据登录用户、配置、插件加载动态增减。Host 收到通知后主动调用 tools/list 刷新缓存。同理有 notifications/prompts/list_changed。

3.4 运行时反馈通知(日志 + 进度)

日志通知 notifications/logging/message:Server 把结构化日志推送给 Host,在 UI 展示日志面板:

1
2
3
4
5
6
7
8
9
10
{
"jsonrpc": "2.0",
"method": "notifications/logging/message",
"params": {
"level": "info",
"logger": "git-server",
"data": "开始拉取远程分支",
"timestamp": "2026-11-22T10:00:00Z"
}
}

level 取值:debug / info / warning / error。stdio 模式下 stderr 日志和协议内 logging 通知可以共存。

进度通知 notifications/progress:用于长时间任务(大文件扫描、批量导入、数据库备份),给前端进度条:

1
2
3
4
5
6
7
8
9
10
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "job-001",
"progress": 0.4,
"total": 100,
"message": "已扫描 40/100 文件"
}
}
  • progressToken:任务令牌,由发起请求的一方生成,绑定这个任务;
  • progress:0~1 比例;total:可选总量。

进度通知是非常重要的用户体验能力:没有它,Host 只能傻傻等待,不知道工具是不是卡死。

3.5 通知的通用约束

  1. 通知不保证送达、不保证顺序(网络代理、重连会丢);
  2. 通知不要求应答,接收方可以选择忽略;
  3. 通知不能用来传递核心返回结果,结果必须放在 Response;
  4. 会话断开之后,所有未送达通知全部丢弃,重连不会补发历史通知。

四、精细化错误模型

4.1 三层错误

  1. 传输层错误:连接断开、解析失败、缓冲区溢出、HTTP 401/404,由传输层向上抛出;
  2. JSON-RPC 标准错误:协议方法层面错误;
  3. 业务语义错误:方法存在、参数合法,但业务执行失败(数据库查询失败、文件不存在、权限不足)。

标准 JSON-RPC 错误码:

code 含义
-32600 Invalid Request:消息格式非法
-32601 Method not found:方法不存在,或能力没有协商开启
-32602 Invalid params:参数不符合 schema / 必填缺失
-32603 Internal error:服务内部异常
-32700 Parse error:JSON 解析失败

业务自定义错误:官方建议从 -32000 开始分配,不要占用预留段。

4.2 错误携带附加数据

error 对象可以增加 data 字段,携带结构化附加信息:

1
2
3
4
5
6
7
8
9
10
11
12
{
"jsonrpc": "2.0",
"id": "res-read-01",
"error": {
"code": -32001,
"message": "访问超出根目录范围",
"data": {
"requestedUri": "file:///etc/passwd",
"allowedRoots": ["file:///home/user/work"]
}
}
}

Host 可以读取 data,给用户展示更友好的报错、告警。

4.3 错误处理策略(工程最佳实践)

  • Parse error:直接丢弃这条消息,打日志,会话继续;
  • Method not found:代表能力协商不一致,Host 应当停止调用该系列接口;
  • 业务执行失败:把错误文本原样送入 LLM,让模型感知失败,进行重试或调整策略;
  • 致命内部错误:Server 可以主动关闭会话;stdio 模式一般直接退出进程。

一个坑:不要把业务异常做成通知;调用失败必须返回 error response,不能只发一条日志通知了事。发起方需要知道调用结果。

五、协议版本与扩展机制

MCP 的扩展哲学:优先能力协商,而不是版本号强绑定;标准之外允许自定义扩展,但有严格命名规范。

5.1 protocolVersion

initialize 里的 protocolVersion,当前稳定版本 0.1.0。版本兼容规则:

  • 主版本变更代表不兼容;次版本、补丁版本向后兼容;
  • Server 收到不支持的主版本,可以拒绝握手;
  • 新增能力走 capabilities,而不是靠升级版本号强制开启。

同一个版本号内,可以出现”有的 Server 支持 sampling,有的不支持”,靠能力交集区分,而不是版本。

5.2 扩展命名规范(强制)

所有不属于官方标准的 method、通知,必须加前缀 x-:

  • ✅ x-query-metrics(自定义请求)
  • ✅ notifications/x-job-finished(自定义通知)
  • ❌ query-metrics(无前缀,未来官方如果新增同名方法,直接冲突)

5.3 自定义方法的限制

  • 自定义方法同样走 JSON-RPC request / response;
  • 自定义能力不能在顶层 capabilities 里随便新增;如果需要协商是否支持某个扩展,两种方案:在标准 tools 里封装成工具;或用一个自定义 x-capabilities 通知交换扩展支持列表;
  • 自定义扩展只在自己的 Host / Server 生态互通;通用客户端(Claude Desktop)会忽略不认识的 x-* 接口。

5.4 什么时候适合扩展

适合:企业内部专属运维指令、任务状态查询;专属流式传输、大文件分片;内部审计、特权接口。

不适合:需求具备通用性时应优先提官方规范提案,而不是自己造私有 method;也不要用自定义 method 重新实现已经存在的原语。

六、进阶能力组合实战

场景:一个带采样、roots、进度、动态工具列表的文件分析 MCP Server。

  1. Host 和 Server 握手,双方同时开启 tools、resources(subscribe)、sampling、roots;
  2. Host 下发 roots:只允许访问 file:///work;
  3. Host 调用 tools/list,拿到扫描工具;Server 插件加载完成后推送 notifications/tools/list_changed;
  4. Host 调用 tools/call 启动批量日志扫描;
  5. Server 持续推送 notifications/progress 进度;
  6. Server 在扫描中发现一段日志,发起 sampling/createMessage,请求 Host 的模型做风险识别;
  7. Host 返回模型结果,Server 继续处理;
  8. 某个日志文件变更,Server 推送 notifications/resources/updated;
  9. 用户取消文件夹权限,Host 推送 roots/list_changed,Server 立刻拒绝后续越界读取。

这一套组合,就是企业落地里真正有生产力的 MCP 形态,而不只是简单调用工具。

七、第四篇小结与高频误区

核心复盘

  1. Sampling:反向推理,Server 请求 Host 的 LLM;Host 掌握审批、模型选择、配额;
  2. Roots:Host 下发资源白名单,由 Server 执行 URI 前缀校验,作为资源沙箱;
  3. 通知体系:资源更新、工具列表变更、结构化日志、进度条,全部是异步无应答事件;通知只做提醒,不携带返回数据;
  4. 错误分层:传输错误、标准 JSON-RPC 错误、业务错误;业务错误可以附带结构化 data;
  5. 扩展原则:依靠 capabilities 做兼容;私有接口必须 x- 前缀。

高频误区

误区 正解
Roots 可以限制工具的文件写入 Roots 只约定 Resources;工具权限需要单独管控
sampling 请求过来,Host 必须调用模型 Host 有权拒绝、审批、替换参数
收到 resources/updated 就自动拿到新内容 通知只是提醒,必须主动 resources/read
自定义方法可以不加前缀 非标准 method / notification 必须使用 x- 前缀
进度通知可以作为任务完成标记 进度只是反馈;任务成功/失败仍依靠 tools/call 的 response 或 error

下一篇(系列完结篇):《深入浅出 MCP 系列教程(五):工程落地与选型》,将讲解 SDK 上手、安全模型、网关架构、可观测性,以及 MCP 与 Function Calling / Agent 框架 / A2A 的对比与选型决策。

本系列共 5 篇。前两篇讲解了协议基础与三大核心原语,本篇讲解传输层:协议报文如何在进程之间、跨机器网络上传递。覆盖 stdio 与 Streamable HTTP 两种官方传输的完整规范、握手流程、鉴权、会话管理与高频坑点。

一、传输层与协议层的边界

先建立一条最重要的分界线:

  • 协议层:规定消息结构、method、字段、会话顺序、capability 协商;报文本身和”怎么走”无关;
  • 传输层:只负责字节流的打包、分割、投递、保序、连接维持;传输层不理解 tools/call、resources/read 的业务含义。

一条核心推论:只要能把 JSON-RPC 消息可靠、有序地双向投递,理论上就可以实现自定义传输(WebSocket、Unix Socket、串口等),只是官方只标准化 stdio 和 Streamable HTTP。

传输层必须保证的最小契约

  1. 消息边界可识别:接收方可以精准切分出一条条独立的 JSON-RPC 对象;
  2. 顺序:同一连接上发送顺序和接收顺序一致(流保序);
  3. 双向:Client ↔ Server 都可以主动发消息(通知、反向 sampling 请求);
  4. 连接断开感知:上层协议收到断开事件,自动销毁会话、订阅、状态。

传输层不负责

版本兼容、capability 校验、业务权限过滤、报文 Schema 校验、消息重试(重试是上层业务逻辑)。

二、stdio 传输(本地子进程传输)

stdio 是本地场景默认、最简单、目前生态最成熟的传输方式。典型场景:Claude Desktop、本地 IDE 插件在本机启动一个独立子进程作为 MCP Server。

2.1 整体模型

Host(Client)使用进程创建 API(spawn / fork)启动 Server 可执行文件,绑定三个标准流:

  • stdin:Client → Server,发给 Server 的所有 JSON-RPC 请求、通知;
  • stdout:Server → Client,返回的响应、推送通知;
  • stderr:独立日志流,不属于 MCP 协议通道。

2.2 消息分割规则:JSON Lines(JSONL)

stdio 是无边界字节流,约定:每一条独立的 JSON-RPC 消息单独占一行,以换行符 \n 作为消息分隔符。每一行 = 一条完整 JSON 对象,不能把一条消息拆成多行,也不能一行塞多条消息。

1
2
3
{"jsonrpc":"2.0","id":"init-001","method":"initialize","params":{...}}\n
{"jsonrpc":"2.0","id":"init-001","result":{...}}\n
{"jsonrpc":"2.0","method":"initialized","params":{}}\n

接收方逻辑:持续读流,按 \n 切分行;对每一行 trim 后尝试 JSON parse;解析失败视为传输坏消息,上报日志、丢弃该行,不直接杀死整个会话。

2.3 stderr 的严格约定(最经典的坑)

Server 绝对不可以把协议消息输出到 stderr;同时,Server 不能随便往 stdout 打印调试日志。

  • ✅ stdout:只输出合法、单行 JSON-RPC
  • ✅ stderr:日志、调试、报错、堆栈
  • ❌ 错误写法:在 Server 代码里随便 console.log("开始启动"),这条文本会混进 stdout,破坏 JSONL 流,导致 Client 解析报错、握手直接失败

新手最常见故障:启动 Server 后 Client 一直卡在初始化,报 JSON parse error,根源就是 Server 在 stdout 打了非 JSON 的调试文本。

2.4 进程生命周期与会话绑定

stdio 模式:一个进程实例对应唯一一条 MCP 会话。

  • Client 启动进程 → 开启会话;进程退出(正常 exit、崩溃、被 kill),流直接关闭,会话销毁,所有订阅、状态全部丢失;
  • 想重新会话必须重新 spawn 全新进程,从头走完整握手;
  • Client 一般通过 SIGTERM 请求 Server 优雅退出;如果 Client 先退出,子进程可能变成孤儿进程,好的实现要做进程组托管。

2.5 配置传递

启动子进程时,Client 通过命令行参数 + 环境变量给 Server 传递配置(数据库连接串、文件根目录、密钥、配置路径),而不是通过 MCP 报文。敏感密钥放环境变量比命令行稍微安全一点(命令行在进程列表更容易被看到),但仍然不是安全远程方案,stdio 仅限本机。

1
2
3
4
5
6
const cp = spawn("mcp-fileserver", [], {
env: { ROOT: "/home/user/docs" }
});
// cp.stdin -> 发消息
// cp.stdout -> 读消息
// cp.stderr -> 单独收集日志

2.6 优缺点与适用边界

优点:

  • 零网络配置、不需要端口、不需要鉴权;
  • 生态成熟,绝大多数开源 MCP Server 默认支持;
  • 隔离天然:每个 Server 独立进程,崩溃不会直接搞崩 Host。

缺点:

  • 只能本机使用,不能跨机器;
  • 进程启动有冷启动开销;进程管理复杂(僵尸进程、卡死进程);
  • 大流量场景下流阻塞风险(缓冲区满,互相卡住)。

2.7 stdio 高频坑清单

  1. 平台换行符:Windows \r\n,解析时必须同时兼容;
  2. 缓冲区:部分语言标准库会做行缓冲/块缓冲,stdout 没有自动 flush,消息会卡在缓冲区不发出去(Python / C++ 实现常踩);
  3. 二进制内容:blob base64 放在 JSON 内部,而不是直接裸流;
  4. 不要在初始化之前就输出任何 stdout;
  5. 超长单行 JSON 注意解析内存上限,防止超大报文 OOM。

三、Streamable HTTP(远程网络传输)

当 MCP Server 不在本机,跑在内网另一台机器、云端容器,就需要 Streamable HTTP。它是官方标准化的远程传输方案,核心是:HTTP POST 上行 + SSE(Server-Sent Events)下行。

为什么不用 WebSocket?官方设计选择:复用 HTTP 基础设施、更容易穿过反向代理、防火墙、鉴权中间件,同时保持双向消息通道。

3.1 架构总览

两条独立逻辑通道,复用同一个 HTTP 服务:

  1. 上行通道(Client → Server):POST 请求
    Client 把 JSON-RPC 消息放在 POST body 发给服务端点;服务返回 202 Accepted(接收成功)或错误码。

  2. 下行通道(Server → Client):SSE 长连接
    Client 发起一个 GET 请求,请求头 Accept: text/event-stream,建立 SSE;之后 Server 通过这个流持续推送响应、通知、sampling 请求。

关键点:两条通道解耦,靠一个会话标识符绑定在一起。

3.2 会话 ID(sessionId)

Streamable HTTP 的核心是 sessionId:

  • Client 在握手阶段和服务协商得到一个 sessionId;
  • 后续所有 POST 上行请求、SSE 下行连接都必须带上它;
  • Server 用 sessionId 把不同 HTTP 请求归到同一个 MCP 会话;
  • sessionId 是服务端生成的不透明字符串,通常放在 HTTP header。

对比 stdio:stdio 天然一个进程 = 一个会话;HTTP 传输是无状态协议,必须用 sessionId 绑定会话上下文、订阅、能力协商结果。

3.3 完整握手流程

  1. Client 发起初始化请求,请求建立会话;
  2. Server 生成唯一 sessionId 返回;
  3. Client 使用这个 sessionId,打开 SSE GET 长连接,用于接收下行消息;
  4. Client 在同一会话内,通过 POST 发送第一条 initialize JSON-RPC 请求;
  5. Server 在 SSE 流返回 initialize 响应;
  6. Client 收到响应后,继续 POST 发送 initialized 通知;
  7. 握手完成,进入正常交互:Client 所有往外发的消息走 POST;Server 所有往外推送走 SSE;
  8. SSE 断开:Client 携带同一个 sessionId 重连;服务端会话可配置超时保留,避免每次断连全部重建。

MCP 协议层的握手顺序(initialize → 等待响应 → initialized)完全不变,只是报文承载在 HTTP/SSE 上。

3.4 SSE 事件格式

每一条 MCP JSON-RPC 消息封装成一个 event,event name 一般为 message,data 字段存放 JSON 字符串:

1
2
3
4
5
event: message
data: {"jsonrpc":"2.0","id":"init-001","result":{"protocolVersion":"0.1.0",...}}

event: message
data: {"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"file:///demo.txt"}}

SSE 自带 retry 字段,通知 Client 断线之后等待多久重试连接,刚好适配重连逻辑。

3.5 上行 POST 请求规范

  • Content-Type:application/json
  • Body:单条 JSON-RPC 对象(官方不鼓励 batch)
  • Header:携带 Mcp-Session-Id: xxx
  • 成功接收:返回 202 Accepted,不把响应放在这个 POST 的 body;响应一定从 SSE 返回
  • 参数非法、session 不存在:返回 400 / 401 / 404 / 429 等 HTTP 状态码

这是很多人理解偏差的地方:POST 只是”投递消息”,不是 RPC 同步等待返回;返回走 SSE。

3.6 鉴权方案

官方没有内置一套 auth,而是复用 HTTP 标准鉴权:

  1. Bearer Token(最常用):Authorization: Bearer xxx;
  2. API Key:header / query 参数;
  3. OAuth2 / OIDC:适合企业 SSO;
  4. mTLS:机器间可信通信,高安全内网。

安全提醒:

  • 绝对不要用 URL query 放长期密钥,日志容易泄露;优先 Authorization header;
  • sessionId ≠ 身份凭证:sessionId 只是会话句柄,鉴权独立于会话;
  • 服务端要做:会话超时、闲置回收、主动失效(登出)、限流。

3.7 重连、心跳、会话超时

网络传输一定会断:WiFi 切换、代理重启、负载均衡切流。标准实现策略:

  1. SSE 客户端监听 error / close 事件,按服务给出的 retry 间隔重试;
  2. 会话还在有效期:重连后继续接收消息,不需要重新走 MCP initialize;
  3. 会话已超时销毁:重连失败,必须全新创建 session、完整重新握手;
  4. 心跳:可用自定义 notification 双向心跳,探测对端是否存活;
  5. 服务端策略:闲置 N 分钟销毁会话,释放订阅、文件句柄、数据库连接。

3.8 优缺点

优点:

  • 支持跨机器、跨网络、云端部署;可放在反向代理、负载均衡后面;
  • 天然支持标准 HTTP 鉴权、限流、日志、审计;
  • Host 和 Server 可以独立扩缩容。

缺点:

  • 架构复杂很多,需要管理 session、SSE、超时、重连;
  • 网络延迟高于本地 stdio;
  • 防火墙、代理可能截断长 SSE 连接(很多代理默认有长连接超时);
  • 状态托管在服务端,内存、连接资源成本更高。

四、两种传输选型与网关模式

4.1 选型对照表

维度 stdio Streamable HTTP
部署位置 本机子进程 内网/云端远程
鉴权 不需要(本机信任) 必须设计鉴权
会话绑定 进程绑定 sessionId 绑定
双向通道 stdin/stdout 流 POST + SSE
重连 断了就销毁,重启进程 支持会话重连
适合场景 桌面客户端、本地 IDE、单机工具 企业共享 MCP 服务、云 MCP、多客户端共用

简单选型口诀:

  • 只给自己本机用 → stdio;
  • 多个用户、多台机器共享一套 MCP 能力 → Streamable HTTP;
  • 混合架构:本地 Host 通过 Streamable HTTP 接入内网中心 MCP 网关,网关再内部调用多个 stdio 本地服务。

4.2 MCP 网关模式(企业常用)

企业落地经常不会让 Host 直连各个后端 MCP Server,而是加一层网关:

  • Host 只对接网关(Streamable HTTP);
  • 网关负责鉴权、路由、限流、审计;
  • 网关背后可以同时对接 stdio 本地服务、远端 HTTP MCP 服务;
  • 网关做能力聚合:把多个后端 Server 的 tools / resources 合并,对外统一入口;
  • 网关做权限裁剪:同一个后端 Server,不同用户看到不同工具白名单。

网关是传输层和安全层的组合,协议本身没有网关规范,属于上层工程模式。

五、传输层通用异常处理

不管哪种传输,都要处理这些共性问题:

  1. 消息超限:单条 JSON 过大,应拒绝并断开或截断,防止内存耗尽;
  2. 乱序、丢消息:stdio 流保序;HTTP+SSE 在代理异常场景可能丢推送,应用层可增加 sequence 序号做确认(协议层不强制);
  3. 背压:Server 处理慢、Client 持续高速发消息,缓冲区堆积;实现需要背压控制,暂停读/写流;
  4. 消息解析错误:一般策略是丢弃这条消息、打日志、不直接杀死整个会话;
  5. 通道半开:一边认为连接正常,另一边已经断开;心跳机制解决。

限流位置:stdio 一般在进程内部做;Streamable HTTP 可以在反向代理、网关、服务端多级限流,按 token、session、用户维度。

六、第三篇小结与高频误区

核心复盘

  1. 传输层只负责投递字节,不感知业务 method;协议报文可以在 stdio 和 Streamable HTTP 之间无缝迁移;
  2. stdio 依靠进程 + JSONL,stderr 隔离日志;最大坑点是 stdout 不能随便打印;会话和进程生命周期绑定;
  3. Streamable HTTP 使用 POST 上行、SSE 下行,依靠 sessionId 维持会话;鉴权复用 HTTP 体系;
  4. 远程场景天然带来会话超时、重连、鉴权、代理长连接超时等工程问题。

高频误区

误区 正解
POST 请求会直接返回 tool 结果 POST 只是投递消息;响应、推送全部从 SSE 返回
sessionId 是鉴权令牌 sessionId 只是会话句柄;鉴权独立
stdio 可以跨机器远程调用 stdio 是本机子进程机制,不适合远程
SSE 是双向通道 SSE 只能下行;上行依赖独立 POST
切换传输需要修改 tools/resources 报文结构 协议报文不变,只改投递方式

下一篇:《深入浅出 MCP 系列教程(四):进阶能力》,将详解 Sampling 反向模型调用、Roots 资源沙箱、完整通知体系、错误模型与协议扩展机制。

本系列共 5 篇。上一篇讲解了协议基础与架构设计,本篇深入 MCP 的三大核心原语:Resources(资源)、Tools(工具)、Prompts(提示模板),覆盖完整规范、字段、调用链路、校验规则与典型踩坑点。

一、为什么拆成三类,而不是全部打包成”函数”

MCP 刻意把 Agent 和外部交互拆成三类,这是区别于普通 function calling 的核心:

  • Resources:只读上下文源——把外部世界的信息送入模型,不产生副作用;支持 URI 寻址、变更订阅;
  • Tools:可执行动作——调用后可能修改外部状态;带输入 JSON Schema,高危动作需要 Host 侧人工审批;
  • Prompts:预编排任务模板——封装 system prompt、参数占位、子任务编排,提供可复用的任务入口。

一个 MCP Server 可以只实现其中任意一个或多个,在 initialize 阶段通过 capabilities 声明:

1
2
3
4
5
"capabilities": {
"tools": {},
"resources": { "subscribe": true },
"prompts": {}
}

只要能力没有在双方协商交集里存在,Host 就绝对不能发起对应系列的请求。

二、Resources(资源)—— 只读上下文

2.1 核心概念:Resource URI

Resources 的设计思想:任何一条可被模型消费的信息,都拥有一个全局可定位的 URI,类似浏览器的 URL,但不局限于 HTTP。

官方推荐的常见 scheme:

  • file://:本地文件
  • postgres://:数据库表/记录
  • git://:仓库提交、分支、diff
  • notion://:Notion 页面
  • slack://:会话消息

URI 的所有权属于 Server:只有暴露这个资源的 Server 知道如何解析它。Host 只把 URI 当成不透明字符串,不会自己去解析、访问。Host 拿着 URI 调用 resources/read,由 Server 去读取对应内容。

2.2 Resource 对象完整字段

字段 类型 是否必填 说明
uri string ✅ 资源唯一标识 URI
name string ✅ 人类可读名称,给用户/模型识别
description string ❌ 资源描述,推荐填写,辅助 LLM 判断要不要读取
mimeType string ❌ MIME 类型,如 text/markdown,方便 Host 做渲染、截断

2.3 完整调用链路

资源相关标准 method:

  1. resources/list:列出所有可访问资源
  2. resources/read:根据 URI 读取资源内容
  3. resources/subscribe:订阅某个 URI 的变更(需要 resources.subscribe: true)
  4. resources/unsubscribe:取消订阅
  5. notifications/resources/updated:服务端推送变更通知(无 id)

读取示例:

1
2
3
4
5
6
{
"jsonrpc": "2.0",
"id": "res-read-01",
"method": "resources/read",
"params": { "uri": "file:///project/README.md" }
}

返回的核心是 contents 数组,一条资源可以返回多个内容块:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"jsonrpc": "2.0",
"id": "res-read-01",
"result": {
"contents": [
{
"uri": "file:///project/README.md",
"mimeType": "text/markdown",
"text": "# Demo Project\nThis is a test project."
}
]
}
}

内容类型分两种:text(文本)、blob(base64 二进制,图片、PDF)。同一个内容块不能同时存在 text 和 blob。

订阅成功之后,文件发生修改时 Server 主动推送一条通知,不需要 Client 轮询:

1
2
3
4
5
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": { "uri": "file:///project/README.md" }
}

Host 收到通知后,按需再次调用 resources/read 拉取最新版本。

2.4 约束与安全边界

  1. 只读语义约定:协议层面约定 Resources 不应产生副作用;这是语义约束,不是强制技术锁,靠实现遵守;
  2. Roots 边界:如果协商开启 roots,Server 必须拒绝根以外的 URI 读取;
  3. 超大内容处理:几十 MB 的文件直接返回会瞬间耗尽上下文窗口;通常策略是截断 + 摘要 + 分页;
  4. 不可信数据:资源返回的文本属于外部不可信输入,存在提示注入风险,Host 需要做隔离、标记边界;
  5. 订阅是会话级:会话断开订阅自动失效,重连后需要重新订阅。

2.5 典型踩坑

  • 不要把动态查询塞进工具里,能用资源读就优先资源;但高频变更资源(实时日志)持续推送会压垮通道,适合分页拉取而不是订阅;
  • URI 不要硬编码、不要让 LLM 自由构造不受控的 URI。

三、Tools(工具)—— 带副作用的动作

Tools 是大家最熟悉、面试最高频的原语。工具代表”执行动作”:增删改、发送消息、创建工单、执行 SQL、部署服务。

3.1 Tool 对象完整字段

字段 类型 必填 说明
name string ✅ 唯一名称,LLM 依靠这个名字调用
description string ✅ 给 LLM 看的自然语言描述,写得差模型就不会正确调用
inputSchema object ✅ JSON Schema,严格定义入参结构、类型、必填项

注意:没有 outputSchema。协议不强制约束返回结构,返回内容由业务决定。

示例 Tool 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "create_ticket",
"description": "创建 IT 工单,用于提交故障申请,需要标题和描述,可选优先级",
"inputSchema": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "工单标题" },
"content": { "type": "string", "description": "故障详细描述" },
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"default": "medium"
}
},
"required": ["title", "content"]
}
}

3.2 调用链路

标准 method:tools/list(列出工具)、tools/call(执行工具)、notifications/tools/list_changed(工具列表动态变更推送)。

调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"jsonrpc": "2.0",
"id": "tool-call-01",
"method": "tools/call",
"params": {
"name": "create_ticket",
"arguments": {
"title": "服务无法启动",
"content": "后端服务启动时报端口占用",
"priority": "high"
}
}
}

返回的 content 数组支持多种类型:text、image、resource(内嵌一条资源)。失败返回走标准 JSON-RPC error。

3.3 工具审批流(非常关键)

MCP 没有内置”审批弹窗”报文,但规范定义了协作模式:

  1. Server 收到 tools/call,识别这是高危操作(删除、写库、发邮件);
  2. Server 不能直接弹出窗口,它只能正常返回结果或通过语义提示;
  3. 真正的审批权在 Host:Host 在发起 tools/call 之前,根据工具白名单、工具名称、参数内容决定是否展示确认窗口;
  4. 用户拒绝 → Host 直接不发起调用;用户同意 → 才发送 tools/call。

很多新手误区:以为 Server 可以通知 Host”请你弹确认框”。协议没有这个指令;审批策略是 Host 的业务安全层。

3.4 设计规范与最佳实践

  1. 工具粒度不要过大:拆成原子操作(创建工单、关闭工单、添加备注),方便审批、审计、权限控制;
  2. description 面向 LLM 写:写清楚什么时候适合调用、参数含义、限制条件;它不是给人类看的文档;
  3. 入参用 JSON Schema 严格约束:枚举、长度、正则,减少模型传错参数;
  4. 幂等优先:写类工具尽量设计成幂等,避免网络重试造成重复创建、重复发送;
  5. 审计埋点:所有 tools/call 都应记录调用时间、工具名、入参、返回、用户身份、是否人工确认。

3.5 Tools vs Resources 判定口诀

  • 只拿信息、查询、读取,不改变外部状态 → Resources(优先)
  • 新增、修改、删除、发送、触发任务 → Tools
  • 查询有复杂过滤、聚合、计算,参数动态、不方便映射成静态 URI → 放到 Tool

举例:读取单条工单详情用 resources/read;按关键词、时间、状态批量搜索工单用 search_ticket tool;创建工单用 tool;读取固定配置文件用 resource。

四、Prompts(提示模板)—— 预编排任务入口

Prompts 是最容易被忽略,但在企业落地里价值很高的原语。它不是发给 LLM 的一次性 prompt,而是可被 Host 拉取的任务模板,带参数占位。

它解决的问题:很多固定工作流(代码评审、周报生成、故障复盘、安全扫描)的 system prompt 很长、编排复杂,不适合每次让 LLM 自己从头生成。把模板托管在 MCP Server,所有接入的 Agent 都可以复用。

4.1 Prompt 对象字段

字段 类型 必填 说明
name string ✅ 模板唯一名称
description string ❌ 模板用途描述
arguments array ❌ 参数定义数组(name / description / required)

4.2 调用链路

标准 method:prompts/list、prompts/get。

1
2
3
4
5
6
7
8
9
10
11
12
{
"jsonrpc": "2.0",
"id": "prompt-get-01",
"method": "prompts/get",
"params": {
"name": "code_review",
"arguments": {
"code": "const x = eval(userInput);",
"language": "javascript"
}
}
}

返回结果里的 messages 是一组标准化对话消息(system / user / assistant),Host 可以直接送入 LLM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"jsonrpc": "2.0",
"id": "prompt-get-01",
"result": {
"description": "代码评审模板",
"messages": [
{
"role": "system",
"content": {
"type": "text",
"text": "你是安全代码评审专家,重点检查注入、权限、资源泄露。"
}
},
{
"role": "user",
"content": {
"type": "text",
"text": "评审下面 JS 代码:const x = eval(userInput);"
}
}
]
}
}

4.3 特点和边界

  1. 模板托管在 Server:更新评审模板只改 MCP Server,所有接入的 Host 自动拿到新版本;
  2. 返回的是消息序列,不是单纯字符串:支持多轮预设对话;
  3. Prompt 本身不执行推理:prompts/get 只是把模板渲染出来;真正调用 LLM 是 Host 的工作;
  4. 可以嵌入资源引用:模板内部可引用 Server 的 Resources,拉取参考文档作为上下文;
  5. 适合标准化团队规范:企业把审批、巡检、复盘模板收敛在内部 MCP Server。

4.4 什么时候不要用 Prompt 原语

  • 一次性、临时、用户随口输入的指令;
  • 动态性极强、每次结构完全不一样的任务;
  • 模板只是一行简短指令,收益很低。

五、原语组合实战:一个完整业务流程

场景:Agent 做工单复盘。

  1. Host 和工单 MCP Server 完成握手,协商:tools、resources、prompts、subscribe;
  2. Host 调用 prompts/list,找到 incident_summary 复盘模板;
  3. Host 调用 prompts/get,传入工单 id,拿到预定义 system prompt;
  4. Host 调用 tools/call,执行 search_ticket,拿到工单列表;
  5. Host 通过 resources/read,读取指定工单详情资源;
  6. 如果开启订阅,后续工单更新会收到 resources/updated 通知;
  7. Host 把模板 + 工单上下文一起送入 LLM,生成复盘报告;
  8. 需要保存复盘结果时,调用 create_note tool 写回工单。

这就是 MCP 区别于普通 function calling 的核心:读上下文、执行动作、任务模板三者有独立标准,可以自由编排组合。

六、校验规则与错误模型

除了标准 JSON-RPC 错误码,三大原语有固定的业务错误场景:

  • 调用 tools/call,但 Server capabilities 没有 tools → 非法请求;
  • inputSchema 校验失败(参数缺失、类型不对、枚举值非法)→ 参数错误;
  • resources/read 访问不在 roots 范围内的 URI → 拒绝;
  • prompts/get 缺失必填 argument → 返回错误;
  • 订阅一个不支持 subscribe 的资源 → 返回错误。

常见 JSON-RPC 标准错误码:

code 含义
-32601 Method not found:调用了不存在的方法
-32602 Invalid params:参数不符合 schema
-32600 Invalid Request:消息格式非法

业务自定义错误码建议从 -32000 开始分配,避免和 JSON-RPC 预留冲突。

七、第二篇小结与高频误区

核心复盘

  1. Resources:URI 寻址的只读上下文,支持订阅推送;Host 不解析 URI;
  2. Tools:带副作用动作,附带 JSON Schema;审批策略由 Host 控制;
  3. Prompts:可拉取、带参数的任务模板;模板渲染和模型推理分离;
  4. 三者是独立可插拔能力,靠握手协商开启。

高频误区

误区 正解
所有查询都做成 Tool 优先用 Resources 承载静态、可寻址的只读数据
Server 主动弹出确认窗口 高危操作确认权在 Host
Prompt 原语会直接调用模型 它只返回渲染后的消息,Host 负责送模型
订阅之后自动拿到新内容 订阅只发变更通知,读内容仍需手动 resources/read
inputSchema 只是文档 Server 有义务按 schema 校验入参,不能直接透传给业务

下一篇:《深入浅出 MCP 系列教程(三):传输层实战》,将详解 stdio 与 Streamable HTTP 两种传输的完整握手流程、鉴权与会话管理。

本系列共 5 篇,系统拆解 Model Context Protocol(MCP):从协议基础、三大原语、传输层、进阶能力到生产工程与选型。本篇是第一篇,讲清楚 MCP 是什么、为什么诞生、底层消息模型和会话全生命周期。

一、为什么诞生 MCP:N × M 的集成困境

在 MCP 出现之前,Agent 和外部系统集成是典型的 N × M 复杂度问题。

假设市面上有一批 AI Host(运行 Agent 的上层应用):Claude Desktop、Claude Code、Cursor、各类自研企业 Agent 平台。同时有一批外部系统:Git、本地文件系统、PostgreSQL、Slack、Jira、Notion、内部 OA、监控平台。

如果没有统一标准,每一个 Host 对接一个外部系统都要单独写一套连接器。Host 数量 = N,外部系统数量 = M,总集成工作量 ≈ N × M。举个数字例子:5 个 Agent 客户端、8 个外部系统,需要 40 套独立对接代码。

并且每套连接器各自定义规范:工具描述有的放 YAML、有的硬编码;没有统一的资源读取方式、变更推送、用户授权确认机制、错误码、会话握手和权限边界。

Function Calling 的天然局限

很多集成只是简单封装了 LLM 的 Function Calling,但 Function Calling 本身只解决一件事:让模型输出结构化 JSON 参数调用函数。它不规定:

  1. 工具如何被发现、列表如何同步;
  2. 外部只读数据如何作为上下文送入模型;
  3. 数据变更如何主动推送;
  4. 用户如何统一授权、审批高危操作;
  5. 服务端反过来请求模型做推理;
  6. 不同 Agent 客户端复用同一套外部能力。

LSP 的启发:从 N × M 到 N + M

MCP 的思路借鉴了 LSP(Language Server Protocol)。LSP 解决了不同编辑器(VS Code、Neovim、JetBrains)对接不同语言服务的问题。MCP 把这个范式搬到 AI Agent 领域:外部能力提供者只实现一次 MCP Server;所有 AI Host 只要内置 MCP Client 就可以直接接入。复杂度从 N × M → N + M。

关键认知:MCP 是”有线协议”

MCP 不是框架,不是 SDK,不是编程语言库,它是有线协议。”有线协议”的含义:规定两个进程之间通过通道传递什么格式的消息、消息字段含义、交互顺序、握手规则。任何语言,只要按报文规范实现,就可以互通。SDK 只是降低实现成本的封装。

MCP 由 Anthropic 在 2024 年 11 月发布,目标是标准化 AI Agent 和外部系统之间的上下文交换、工具调用、资源读取、提示模板加载。

顶层设计原则

  1. 模型中立:不绑定任何大模型,Host 可以接入任意后端 LLM;
  2. 传输中立:可以跑在子进程 stdio,也可以跑在 HTTP / SSE;
  3. 默认安全,显式授权:每个 Server 连接 Host 都需要用户主动确认授权,权限遵循最小可用;
  4. 可组合:同一个 Host 可以同时挂载多个互相独立的 MCP Server;
  5. 向后兼容、版本化演进:通过能力协商而不是强制版本号锁死;
  6. 区分读、写、模板三类能力:原生区分只读资源、带副作用工具、提示模板。

二、三大核心角色:Host、MCP Client、MCP Server

这是整个 MCP 模型的骨架,也是所有报文、会话逻辑的依托。很多初学者容易混淆 Client 和 Host,这里严格区分。

Host(主机)

Host 是面向用户的顶层 AI 应用,是整个会话的拥有者和决策者。典型例子:Claude Desktop、Claude Code、自研企业 Agent 平台、集成 MCP 的 IDE。

Host 的职责清单:

  1. 持有用户会话、身份、信任边界:所有授权弹窗、高危操作确认都由 Host 展示给用户。Server 无权直接弹出确认窗口;
  2. 管理多个 MCP Client 实例:连接 3 个 MCP Server 就启动 3 个独立 Client,一一对应;
  3. 持有 LLM 推理入口:采样(sampling)请求由 Server 发起,Host 调用大模型完成推理再回传,Host 掌握模型密钥与配额;
  4. 安全策略总控:黑名单、白名单、资源读取大小限制、超长返回截断;
  5. 聚合上下文:把各 Server 的 Resources、工具返回结果整理成上下文送入 LLM。

一句话概括:Host = 用户侧的 AI 应用,拥有最终决策权。

MCP Client(协议客户端)

Client 不是独立软件,它是 Host 内部的一个协议实例,一对一绑定一个 MCP Server。这个 1:1 隔离是强制设计。

Client 职责:建立底层传输通道;发起 initialize 握手完成能力协商;转发双向消息;维护订阅关系和会话状态;隔离会话——A Server 的消息、凭据、状态完全不会混入 B Server。

❌ 错误理解:一个 Client 可以同时连接多个 Server。
✅ 正确理解:一个 Client ↔ 一个 Server;多服务接入需要多个 Client。这个隔离设计是为了防止权限逃逸、消息串扰。

MCP Server(能力服务端)

Server 是独立的能力提供者,可以是本地子进程,也可以是云端远程服务。它向外暴露 Tools、Resources、Prompts,负责声明自身能力、执行业务逻辑、推送变更通知、发起反向采样请求,并严格遵守 Host 下发的权限边界(roots)。

典型 Server:filesystem(文件读写)、postgres(数据库查询)、git(提交/diff)、jira(工单管理)。

角色关系图

1
2
3
4
[ Host(AI应用) ]
├─ Client 1 ↔ Server A(文件)
├─ Client 2 ↔ Server B(数据库)
└─ Client 3 ↔ Server C(工单)

三、底层基础:JSON-RPC 2.0 消息模型

MCP 所有通信报文都基于 JSON-RPC 2.0,完全沿用其三类消息分类:

  1. Request(请求):需要对方返回响应;
  2. Response(响应):对应一个请求,成功或失败;
  3. Notification(通知):单向消息,不需要应答,没有 id。

Request 报文

1
2
3
4
5
6
{
"jsonrpc": "2.0",
"id": "req-001",
"method": "tools/list",
"params": {}
}
  • id:唯一标识,后续 Response 必须带上完全一样的 id 用来配对;
  • method:MCP 预定义标准方法,如 initialize、tools/list、tools/call。

Response 报文

成功用 result,失败用 error:

1
2
3
4
5
6
7
8
{
"jsonrpc": "2.0",
"id": "req-001",
"error": {
"code": -32601,
"message": "Method not found"
}
}

Notification 报文

通知没有 id,接收方不需要回复。典型例子:握手完成的 initialized 通知、资源变更通知、日志推送。

MCP 在 JSON-RPC 之上追加的约束

  1. 所有消息是独立 JSON 对象;stdio 传输时每条消息单独一行(JSONL);
  2. method 有命名空间:工具 tools/*,资源 resources/*,提示 prompts/*;
  3. 不允许批量消息(JSON-RPC 原生支持 batch,但 MCP 规范不鼓励);
  4. 同一通道消息按发送顺序处理,但响应返回顺序不一定严格匹配。

新手坑:凡是需要等待返回结果的,一定带 id;事件推送、状态告知,不带 id。

四、会话完整生命周期(核心流程)

MCP 是有状态会话,不是无状态的单次 HTTP 请求。流程是协议强制顺序,不能颠倒:

  1. 建立传输通道(stdio spawn 子进程 / HTTP 连接);
  2. Client → Server:initialize 请求(交换协议版本、能力、客户端信息);
  3. Server 返回 initialize 响应,公布自身能力;
  4. Client 发送 initialized 通知,握手完成;
  5. 正常交互阶段(工具调用、资源读取、订阅、采样);
  6. 会话关闭(优雅 shutdown 或异常断开)。

握手报文示例

Client 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"jsonrpc": "2.0",
"id": "init-001",
"method": "initialize",
"params": {
"protocolVersion": "0.1.0",
"capabilities": {
"sampling": {},
"roots": {}
},
"clientInfo": {
"name": "my-agent-host",
"version": "1.0.0"
}
}
}

Server 响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"jsonrpc": "2.0",
"id": "init-001",
"result": {
"protocolVersion": "0.1.0",
"capabilities": {
"tools": {},
"resources": { "subscribe": true },
"prompts": {}
},
"serverInfo": {
"name": "demo-file-server",
"version": "0.1.0"
}
}
}

关键点

  • 协商之后,交集才是可用能力:Client 支持 sampling、Server 不支持 → 整个会话不能使用 sampling;
  • initialized 通知不能忘:Server 收到之前不能主动推送通知、不能处理业务调用。很多 Demo 握手后忘记发送 initialized,导致 Server 不响应后续调用;
  • 会话断开,协商结果全部作废:重新通信必须完整重新握手,不能复用上次协商结果;
  • 权限边界一直生效:例如 roots 约束 Server 任何资源读取不能越界。

五、Capability(能力集)的设计思想

协议把能力分成两大类:

  1. 强制基础能力:JSON-RPC 消息、initialize 握手;
  2. 可选扩展能力:tools、resources、prompts、sampling、roots、订阅、日志等,靠握手协商开启。

为什么这样设计?

  • 轻量化:极简 Server 可以只暴露资源,完全不实现工具;
  • 向前向后兼容:协议新增能力时,老客户端不会崩掉;对方没声明支持,直接不调用;
  • 职责分离:纯只读知识库 Server 不需要实现 tools。

常见 capability 含义:

Capability 含义
tools 支持工具调用
resources 支持资源读取,subscribe: true 表示支持变更订阅
prompts 支持提示模板
sampling Server 可以请求 Host 的模型
roots Host 可以下发访问根路径限制
logging 接收日志推送通知

重要:capabilities 只是”声明我具备这个能力”,不是权限开关。就算 Server 声明支持 tools,Host 依然可以在应用层策略上拒绝调用其中某些工具。协商只是协议层面的互通可能性,最终放行权在 Host。

六、第一篇小结与高频误区

核心要点

  1. MCP 是有线通信协议,在 Function Calling 之上标准化 Agent 和外部系统对接;
  2. 三层角色:Host(决策者)、Client(一对一协议通道)、Server(能力提供者);
  3. 底层报文基于 JSON-RPC 2.0,分为 Request / Response / Notification;
  4. 会话固定顺序:建通道 → initialize 交换能力 → initialized 通知 → 业务交互 → 关闭;
  5. 能力协商是版本兼容与轻量化扩展的核心,只有双方都声明支持的能力才能使用;
  6. 隔离约束:一个 Client 只绑定一个 Server。

高频误区(面试常考)

误区 正解
MCP = Function Calling Function Calling 只是模型输出结构化参数;MCP 是完整的服务发现、上下文、权限、双向通信标准
一个 Client 连多个 Server 强制一对一
建立连接后直接调用工具 必须走完 initialize + initialized 握手
stdout 可以随便打印日志 stdio 模式下 stdout 只能输出 JSON-RPC,日志走 stderr
capability 协商 = 授权 协商只代表协议上可互通,用户授权、工具审批是 Host 上层安全策略

下一篇:《深入浅出 MCP 系列教程(二):三大核心原语实战》,将详解 Resources / Tools / Prompts 的完整规范、调用链路与踩坑点。

浏览器里经常遇到这四个关键词,但它们的区别很容易混淆。一句话版先记住:

  • preload:当前页马上要用,高优先级先下载。
  • prefetch:下一页可能要用,低优先级闲时下载。
  • defer:JS 下载不阻塞解析,DOM 完成后按顺序执行。
  • async:JS 下载不阻塞解析,下载完立刻执行,顺序不可控。

下面分别展开。


资源提示类:preload / prefetch

这俩都是 <link> 标签上的 rel 属性,告诉浏览器「提前搞个资源」。

preload

1
2
3
<link rel="preload" href="font.woff2" as="font" crossorigin>
<link rel="preload" href="hero.jpg" as="image">
<link rel="preload" href="critical.js" as="script">
  • 高优先级:浏览器会把它当成「当前页面马上要用」的关键资源,在渲染早期就下载。
  • 下载完不执行,只是放进缓存。真正用到的时候(比如 <img src="hero.jpg">)直接从缓存取。
  • 适合:关键字体、首屏大图、核心 JS/CSS。

⚠️ 用错会浪费带宽——不要 preload 那些当前页根本用不上的东西。

prefetch

1
2
<link rel="prefetch" href="/next-page.html">
<link rel="prefetch" href="next-page.css">
  • 低优先级:浏览器在网络空闲时才下载。
  • 同样下载完不执行,只是预热缓存,给下一个页面准备的。
  • 适合:用户大概率会点的下一跳页面资源。

一图对比

1
2
3
           优先级    时机            用途           执行时机
preload 高 当前页渲染早期 当前页关键资源 不执行,仅缓存
prefetch 低 网络空闲时 下一页候选资源 不执行,仅缓存

Script 类:defer / async

这俩是 <script> 标签的属性,解决的是「JS 会阻塞 HTML 解析」的问题。

没有属性时

1
<script src="blocking.js"></script>

浏览器解析到这行 → 暂停 DOM 解析 → 下载 JS → 执行 JS → 继续解析。页面明显会卡。

async

1
2
<script src="a.js" async></script>
<script src="b.js" async></script>
  • 下载阶段不阻塞 DOM 解析。
  • 下载完成那一刻,不管 DOM 解析到哪了,立刻暂停解析,执行脚本。
  • 多个 async 脚本谁先下载完谁先执行,顺序完全不可控。
  • 适合:独立的、不依赖其他脚本的第三方脚本(统计、广告等)。

defer

1
2
<script src="a.js" defer></script>
<script src="b.js" defer></script>
  • 下载阶段不阻塞 DOM 解析。
  • 下载完不立即执行,等到 HTML 解析完成、DOMContentLoaded 之前,按书写顺序依次执行。
  • 适合:有依赖关系的脚本、需要操作 DOM 的脚本。

时序对比

1
2
3
4
5
HTML 解析:  ──|──────────────────────|── DOMContentLoaded
│ │
无属性: │ 下载 → 执行 (阻塞) │
async: │ 下载... (并行) → 下载完立刻执行
defer: │ 下载... (并行) │ ← 此时按顺序执行

一张表收齐

关键字 载体 谁用? 阻塞? 执行/优先级 典型场景
preload <link rel> 当前页 — 高优先级下载,只缓存不执行 关键字体、首屏图
prefetch <link rel> 下一页候选 — 低优先级闲时下载,只缓存不执行 用户大概率跳转的页面
defer <script> 当前页 JS 下载不阻塞 DOM 完成后按顺序执行 有依赖链的业务脚本
async <script> 当前页 JS 下载不阻塞 下载完立刻执行,顺序随机 独立第三方脚本(GA等)

选型口诀

  1. 当前页马上要用 → preload,下一页才要用 → prefetch。
  2. JS 有顺序依赖 → defer,独立无依赖 → async。
  3. 字体记得加 crossorigin,否则 preload 了也白预载。
  4. 不要滥用 preload——它抢带宽,多了反而拖慢真正的关键资源。

记住这四句话,下次面试或做性能优化就不会再混了。

一、Promise 基础概念

Promise 是 JavaScript 中处理异步操作的核心对象,代表一个异步操作的最终完成或失败及其结果值。它解决了传统回调函数(Callback)带来的”回调地狱”问题,使异步代码更加可读、可维护。

三种状态

状态 说明
pending 初始状态,既未成功也未失败
fulfilled 操作成功完成
rejected 操作失败

状态一旦从 pending 转变为 fulfilled 或 rejected,就不可逆转。

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const promise = new Promise((resolve, reject) => {
// 异步操作
setTimeout(() => {
const success = true;
if (success) {
resolve('操作成功');
} else {
reject(new Error('操作失败'));
}
}, 1000);
});

promise
.then(result => console.log(result))
.catch(err => console.error(err.message));

二、Promise 核心方法

1. .then() 链式调用

1
2
3
4
fetchUser(1)
.then(user => fetchOrders(user.id))
.then(orders => fetchOrderDetail(orders[0].id))
.then(detail => console.log(detail));

每个 .then() 返回一个新的 Promise,支持链式调用。

2. .catch() 错误处理

1
2
3
4
5
6
7
8
asyncOperation()
.then(result => {
// 处理结果
})
.catch(error => {
// 捕获上面任意环节抛出的错误
console.error('出错了:', error);
});

3. .finally() 最终执行

1
2
3
4
showLoading()
.then(data => render(data))
.catch(err => showError(err))
.finally(() => hideLoading()); // 无论成功失败都执行

三、Promise 静态方法

Promise.all — 全部成功才算成功

1
2
3
4
5
6
const [user, orders, products] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchProducts(),
]);
// 任一 Promise reject,整体立即 reject

Promise.allSettled — 等待所有结果(ES2020)

1
2
3
4
5
const results = await Promise.allSettled([
fetchWithRetry(),
fetchBackup(),
]);
// results: [{status:'fulfilled', value:...}, {status:'rejected', reason:...}]

Promise.race — 取第一个完成的结果

1
2
3
4
5
6
const winner = await Promise.race([
fetchFromCDN(),
fetchFromOrigin(),
timeout(5000),
]);
// 第一个 resolve 或 reject 的结果

Promise.any — 取第一个成功的结果(ES2021)

1
2
3
4
5
6
7
const first = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);
// 忽略所有失败,只要有一个成功就 resolve
// 全部失败则 reject AggregateError

四、实用技巧

1. 串行执行数组中的异步任务

1
2
3
4
5
6
7
async function sequentialTasks(tasks) {
const results = [];
for (const task of tasks) {
results.push(await task());
}
return results;
}

2. 并发限制(控制同时执行的 Promise 数量)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function promisePool(tasks, concurrency) {
const results = new Array(tasks.length);
let index = 0;

async function worker() {
while (index < tasks.length) {
const i = index++;
results[i] = await tasks[i]();
}
}

const workers = Array.from({ length: concurrency }, worker);
await Promise.all(workers);
return results;
}

// 使用:最多同时执行 3 个请求
const files = list.map(file => () => uploadFile(file));
await promisePool(files, 3);

3. 带超时的 Promise

1
2
3
4
5
6
7
8
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}

const data = await withTimeout(fetchUser(1), 3000);

4. 重试机制

1
2
3
4
5
6
7
8
9
10
11
12
async function retry(fn, times = 3, delay = 1000) {
for (let i = 0; i < times; i++) {
try {
return await fn();
} catch (err) {
if (i === times - 1) throw err;
await new Promise(r => setTimeout(r, delay * (i + 1)));
}
}
}

const result = await retry(() => unstableAPI(), 3, 500);

5. Promise 化回调函数(promisify)

1
2
3
4
5
const { promisify } = require('util');
const fs = require('fs');

const readFileAsync = promisify(fs.readFile);
const content = await readFileAsync('./data.txt', 'utf-8');

五、async/await 最佳实践

1. 错误处理:try/catch

1
2
3
4
5
6
7
8
9
async function getUser() {
try {
const res = await fetch('/api/user');
const data = await res.json();
return data;
} catch (err) {
console.error('获取用户失败:', err);
}
}

2. 避免不必要的串行等待

1
2
3
4
5
6
7
8
9
// ❌ 慢:串行执行
const user = await fetchUser();
const posts = await fetchPosts();

// ✅ 快:并行执行
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts(),
]);

3. 顶层 await(Node.js 14.8+ ESM)

1
2
3
// module.mjs
const config = await loadConfig();
export default config;

六、面试常见问题

Q1:Promise 和 Callback 有什么区别?

对比项 Callback Promise
可读性 嵌套深,回调地狱 链式调用,扁平化
错误处理 每个回调单独处理 .catch() 统一捕获
组合能力 难以组合多个异步操作 all/race/any 等原生支持
状态管理 无法保证只调用一次 settle 后不可变

Q2:.then() 和 .catch() 的执行顺序是怎样的?

1
2
3
4
Promise.reject('err')
.then(() => console.log('A')) // 跳过
.catch(() => console.log('B')) // 输出 B
.then(() => console.log('C')); // 输出 C

.catch() 相当于 .then(null, onRejected),捕获前面的错误后,后续 .then() 正常执行。

Q3:如何手动实现一个 Promise?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];

const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn());
}
};

const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
}
};

try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}

then(onFulfilled, onRejected) {
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

const promise2 = new MyPromise((resolve, reject) => {
if (this.state === 'fulfilled') {
setTimeout(() => {
try {
const x = onFulfilled(this.value);
resolvePromise(promise2, x, resolve, reject);
} catch (e) { reject(e); }
});
} else if (this.state === 'rejected') {
setTimeout(() => {
try {
const x = onRejected(this.reason);
resolvePromise(promise2, x, resolve, reject);
} catch (e) { reject(e); }
});
} else {
this.onFulfilledCallbacks.push(() => { /* 同上 */ });
this.onRejectedCallbacks.push(() => { /* 同上 */ });
}
});

return promise2;
}
}

function resolvePromise(promise2, x, resolve, reject) {
if (x === promise2) return reject(new TypeError('Chaining cycle detected'));
if (x && (typeof x === 'object' || typeof x === 'function')) {
let called = false;
try {
const then = x.then;
if (typeof then === 'function') {
then.call(x, y => { if (!called) { called = true; resolve(y); } },
r => { if (!called) { called = true; reject(r); } });
} else { resolve(x); }
} catch (e) { if (!called) reject(e); }
} else { resolve(x); }
}

Q4:Promise.all 和 Promise.allSettled 的区别?

  • Promise.all:快速失败,任意一个 reject 则整体立即 reject。
  • Promise.allSettled:全部等待,不论成功失败都返回完整结果数组。

适用场景:all 用于必须全部成功的并行任务;allSettled 用于希望获取所有结果的场景(如批量上报)。

Q5:async 函数返回值是什么?

async 函数始终返回一个 Promise。

  • 如果函数 return value,返回的 Promise 以 value resolve。
  • 如果函数 throw error,返回的 Promise 以 error reject。
  • 如果 return 一个 Promise,会等待该 Promise settle。
1
2
async function foo() { return 42; }
foo().then(v => console.log(v)); // 42

Q6:如何捕获 async 函数中未处理的异常?

1
2
3
4
5
6
7
8
// 方式一:try/catch
// 方式二:.catch()
asyncFn().catch(err => log(err));

// 方式三:全局兜底
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});

Q7:Promise 的微任务(Microtask)机制

1
2
3
4
5
6
7
8
9
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 输出顺序:1 → 4 → 3 → 2

规则:同步代码 > 微任务(Promise.then) > 宏任务(setTimeout)。


七、常见陷阱

1. 循环中使用 await

1
2
3
4
5
6
7
// ❌ 串行,慢
for (const url of urls) {
const res = await fetch(url);
}

// ✅ 并行
const results = await Promise.all(urls.map(url => fetch(url)));

2. 忘记 return 或 await

1
2
3
4
5
6
7
8
9
// ❌ 错误:.then 链中断
function getData() {
fetch('/api').then(res => res.json()); // 没有 return
}

// ✅ 正确
function getData() {
return fetch('/api').then(res => res.json());
}

3. resolve 一个 Promise

1
2
3
4
// resolve 另一个 Promise 时,会等待它 settle,状态透传
new Promise(resolve => {
resolve(Promise.reject('err')); // 外层变成 rejected
});

总结

场景 推荐方案
单个异步操作 async/await + try/catch
多个并行独立操作 Promise.all
需要全部结果(含失败) Promise.allSettled
只要最快成功 Promise.any
超时控制 Promise.race + timeout
并发限制 自实现 promisePool

掌握 Promise 是 Node.js 异步编程的基石,无论是日常开发还是面试,理解其底层机制(状态机、微任务队列、链式调用原理)都能让你游刃有余。

1. 题目

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。

示例:

1
2
3
4
5
6
7
输入: [1,2,3,1]
输出: 4
解释: 偷 1 号(=1) 和 3 号(=3) 号房屋,1 + 3 = 4。

输入: [2,7,9,3,1]
输出: 12
解释: 偷 1、3、5 号房屋,2 + 9 + 1 = 12。

2. 解题思路

经典「选/不选」线性 DP。

2.1 状态定义

dp[i] = 考虑到第 i 间房时的最高金额。对第 i 间房有两种选择:

  • 不偷 i:dp[i] = dp[i-1](沿用前一间的最优)。
  • 偷 i:则不能偷 i-1,dp[i] = dp[i-2] + nums[i]。

转移:dp[i] = max(dp[i-1], dp[i-2] + nums[i])。

  • 时间复杂度:O(n),空间复杂度:O(n) → 优化到 O(1)。

2.2 滚动变量

只用两个变量 prev(dp[i-2]) 与 curr(dp[i-1]) 交替更新,无需数组:每步 ([prev, curr] = [curr, max(curr, prev + num)])。

3. TypeScript 实现

3.1 O(1) 空间(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function rob(nums: number[]): number {
let prev = 0; // dp[i-2]
let curr = 0; // dp[i-1]

for (const num of nums) {
const next = Math.max(curr, prev + num);
prev = curr;
curr = next;
}

return curr;
}

console.log(rob([2, 7, 9, 3, 1])); // 12

3.2 显式 DP 数组(便于理解)

1
2
3
4
5
6
7
8
9
10
function robDP(nums: number[]): number {
const n = nums.length;
if (n === 0) return 0;
const dp = new Array(n).fill(0);
dp[0] = nums[0];
for (let i = 1; i < n; i++) {
dp[i] = Math.max(dp[i - 1], (dp[i - 2] ?? 0) + nums[i]);
}
return dp[n - 1];
}

4. 面试延伸

  • 进阶 II:环形街道(House Robber II)——首尾相邻不能同时偷,拆成「不偷首」和「不偷尾」两个线性子问题取 max,max(rob(nums[0..n-2]), rob(nums[1..n-1]))。
  • 进阶 III:树形 DP(House Robber III)——在二叉树上做「偷当前根 vs 偷左右子」的决策,后序 DFS 返回 [不偷, 偷] 一对值,是树形 DP 模板。
  • 本题是「最大子序列和 / 股票买卖」等同族「每步二选一」DP 的原型,掌握状态定义 + 滚动优化即可通吃。

难度:中等 | LeetCode 198 | 线性 DP

做网站这件事,很多人的认知还停留在十年前。

要么花几百块用凡科、建站之星套模板,页面千篇一律,一眼就是廉价流水线作品;要么找人定制开发,前端后端从头写起,改个新闻都要找程序员改代码,费时又费钱。

尤其是设计公司、品牌工作室这类靠颜值吃饭的行业,模板网站的土味质感,完全撑不起自身的高端定位。

今天给大家聊的 Strapi,就是目前开发者圈公认的「现代建站神器」。它完美解决了传统建站的所有痛点,也是现在高端定制企业官网的主流后台方案。

很多人听不懂「无头CMS」「前后端解耦」这些专业词,没关系,我用大白话带你彻底搞懂它。

Strapi 到底是什么?

简单说,Strapi 就是一个免费开源、自己掌控、零代码搭后台的内容管理系统。

区别于凡科、有赞这类租即用的SaaS建站平台,Strapi 不是成品网站,而是一个「万能后台容器」。你可以把它理解成一个全自动的后端工人:不用手写代码,就能帮你建数据库、存内容、管权限、出接口。

它最核心的特点就是无头(Headless)。通俗解释就是:它只负责管好所有数据内容,不捆绑任何前端页面样式。

传统CMS是“捆绑一体机”,后台数据和前台页面焊死,想改设计、换风格处处受限。而Strapi 只做后端本职工作,前端页面你想用Next.js、Vue随便选,自由度直接拉满。

不用写代码,普通人也能搭专业后台

这是Strapi 最圈粉的优势,也是它吊打传统建站工具的关键。

以往做企业官网后台,想要实现「项目案例、新闻动态、公司介绍、留言表单」的管理功能,后端开发者要手动建数据表、写增删改查接口、配置权限,至少大半天起步。

但用Strapi,全程可视化拖拽操作。

你在后台点点鼠标,就能创建各类内容模型:单页的官网配置、多条目的项目案例、可双语切换的新闻资讯、存储留言的表单库,全部一键生成。

同时自带完整的权限体系,你可以设置管理员、普通编辑账号,区分谁能改内容、谁只能查看,完美适配企业运营场景。

更贴心的是原生支持中英双语,不用自己折腾代码适配,做外贸、高端双语官网简直是量身定制。

专属现代架构,速度和颜值双在线

现在的高端企业官网,都追求静态渲染、CDN加速、极速打开,这也是模板网站做不到的。

Strapi 搭配Next.js 是目前行业顶配组合。前端用Next.js 写出高颜值、自适应的静态页面,通过CDN全网加速,打开速度飞快,SEO表现也远超模板站。

后端交给Strapi 全权托管,所有项目、新闻、文案、图片都存在自己的PostgreSQL数据库里。

这里必须提一句核心优势:数据完全归自己。

凡科、建站之星这类SaaS平台,数据都存在厂商服务器里,年费一停,网站和数据直接清零。而Strapi 是自托管部署,服务器、数据库、所有内容全权掌控,安全感拉满。

轻量二次开发,按需定制不臃肿

很多人担心,零代码工具会功能受限,满足不了定制需求。Strapi 完美避开了这个坑。

日常90%的建站需求,纯后台配置就能搞定,完全不用写代码。

遇到特殊需求也不怕,它支持轻量化二次开发。比如表单提交后自动发邮件通知、留言数据统计、自动导出表格、IP防刷限流,简单写几行代码就能实现。

而且扩展开发不会改动系统内核,后续升级版本、维护迭代都很方便,不会出现越改越乱的情况。

适配中小企业,性价比拉满

论成本,Strapi 优势也十分明显。

开源免费无版权费用,只需要一台便宜的轻量服务器部署即可,不用每年花几千块续费SaaS模板。一次搭建,长期使用,自由迭代改版。

不管是设计公司、小微企业、个人工作室,想要一个高颜值、可自主管控、支持定制的官网,这套架构都是最优解。

不用将就千篇一律的模板,不用承担SaaS平台的数据绑架,用最低的成本,拿下高端定制网站的所有能力。

0%