# Step 49: MCP 资源与提示 —— resources + prompts
一句话导读:MCP server 除了工具(tools),还能暴露两类东西——resources(只读上下文数据,像文件)和 prompts(预设指令模板,像斜杠命令)。这一步补上这两条协议,让 demo server 三种能力齐全。关键在于理解:这三者的区别不是"功能",而是"谁主动、有没有副作用、所有权归谁"。
# 一、这一步做了什么(What)
在 step48 基础上,给 MCP 补齐另外两种能力(对应真实源码 services/mcp/ 的 resources / prompts):
| 能力 | 是什么 | 协议方法 | 类比 |
|---|---|---|---|
| tools | 可执行的动作 | tools/list, tools/call | 函数(47/48 已做) |
| resources | 只读上下文数据 | resources/list, resources/read | 文件(可 @ 引用) |
| prompts | 预设指令模板 | prompts/list, prompts/get | 斜杠命令模板 |
server 在 initialize 的 capabilities 里声明支持哪几种;demo server 现在三种都有。用 /mcp resources、/mcp read demo://readme、/mcp prompts、/mcp prompt demo summarize {...} 查看。
# 二、面试官视角:一个协议为什么要分三种能力?(Why)
面试题:MCP 有了 tools 就能让模型干活了,为什么还要 resources 和 prompts?它们不能都用 tools 表达吗?
技术上能,但那会混淆三种语义完全不同的东西——区别在「谁主动、有没有副作用、所有权归谁」。
- tools 是"让模型去做事":模型主动调,有副作用/返回结果(查数据库、发请求)。控制权在模型手里。
- resources 是"给模型/用户看的数据":只读、无副作用,由用户主动挑来塞进上下文(类似 step43 的
@文件,只是来源变成 server)。控制权在用户手里。 - prompts 是"预写好的指令模板":用户主动触发,展开成一条用户消息发给模型(像一个填空的斜杠命令)。控制权也在用户手里。
| 维度 | tools | resources | prompts |
|---|---|---|---|
| 谁主动 | 模型 | 用户 | 用户 |
| 副作用 | 有 | 无(只读) | 无(只是消息模板) |
| 类比 | 函数调用 | @文件引用 | /斜杠命令 |
如果全塞进 tools,就等于把"用户想主动引用一份数据"和"模型想主动执行一个动作"混为一谈——模型可能擅自去"调用"一个本该由用户挑选的资源,副作用边界也乱了。分成三种,是把「主动权归属」和「副作用有无」这两个正交维度在协议层显式表达出来。
一句话:三种能力对应三种"控制权+副作用"的组合——tools 是模型的动作、resources 是用户的只读数据、prompts 是用户的指令模板,分开才能各自安全。
# 三、原理:它是怎么工作的(How)
# 能力协商:握手时声明支持哪几种
server 在 initialize 响应的 capabilities 里报自己有什么,客户端据此决定拉什么:
// exampleServer.mjs —— 现在三种能力都声明
send({ jsonrpc: "2.0", id, result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {}, resources: {}, prompts: {} }, // ★ 三种齐全
serverInfo: { name: "demo-mcp", version: "0.1.0" } } });
2
3
4
5
# 连接时预取,后续查看零往返
客户端 connect() 在 tools/list 之后,顺带把 resources/prompts 列表也拉回缓存(server 不支持就静默忽略):
// client.ts —— connect() 节选
this.tools = (await this.request("tools/list"))?.tools ?? [];
try { this.resources = (await this.request("resources/list"))?.resources ?? []; }
catch { this.resources = []; } // ★ server 不支持就忽略
try { this.prompts = (await this.request("prompts/list"))?.prompts ?? []; }
catch { this.prompts = []; }
// 读一个资源,返回其文本内容
async readResource(uri: string): Promise<string> {
const res = await this.request("resources/read", { uri });
return (res?.contents ?? []).map((c) => c.text ?? "").join("\n");
}
// 取一个提示模板,把它的消息拼成纯文本(供注入对话)
async getPrompt(name: string, args: Record<string, string> = {}): Promise<string> {
const res = await this.request("prompts/get", { name, arguments: args });
return (res?.messages ?? [])
.map((m) => (typeof m.content === "string" ? m.content : m.content?.text ?? ""))
.join("\n");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# server 端:resource 存正文、prompt 填模板
const RESOURCES = [
{ uri: "demo://readme", name: "Demo Readme", mimeType: "text/plain" },
{ uri: "demo://config", name: "Demo Config", mimeType: "application/json" },
];
// resources/read → 按 uri 回 contents
// prompts/get(summarize, {text}) → 回一条填好的 user 消息:
// "请把下面这段文本总结成要点:\n\n" + text
2
3
4
5
6
7
# 数据流
连接时:connect() 顺带 resources/list + prompts/list → 存到 client.resources / client.prompts
查看: /mcp resources 列资源
/mcp read demo://readme → resources/read → 资源正文
/mcp prompts 列提示模板
/mcp prompt demo summarize {"text":"…"} → prompts/get → 拼出「请把下面这段文本总结成要点:…」
2
3
4
5
# 题眼:resource 的 URI 和 prompt 的模板参数
- resource 用 URI 标识(
demo://readme),不是普通路径——因为资源可能来自任意 server、任意来源(数据库行、API 返回、内存对象),URI 是一个统一的、来源无关的寻址方式。 - prompt 带
arguments(summarize需要text),prompts/get时把参数填进模板,返回的是已经填好、可直接发送的消息。这正是"斜杠命令模板"的本质——预写骨架 + 运行时填空。
# 四、深入追问(面试常见 follow-up)
Q:resources 和 step43 的 @文件 到底什么关系?
A:几乎是同一个概念,只是数据来源不同。@文件 从本地文件系统读、resource 从 MCP server 读,但两者都是"用户主动挑一份只读数据塞进上下文"。所以真实 Claude Code 把 MCP resource 也做成 @ 可引用的东西——用户 @ 一个 resource URI,内容就附进消息,和 @ 本地文件的体验统一。本质上 resource 是 @文件 机制向"任意 server 来源"的推广。
Q:prompts 和我们前面做的斜杠命令(step35)有什么联系?
A:真实 Claude Code 把每个 MCP prompt 自动注册成一个斜杠命令 /mcp__<server>__<prompt>——用户选它、填参数,模板就填好发给模型。所以 MCP prompt = "server 提供的斜杠命令"。我们这版从简,用 /mcp prompt 手动取出文本演示机制,没做成自动斜杠命令,但语义一致:prompt 是预写好、运行时填空、由用户触发的指令模板。
Q:为什么 connect 要在连接时就把 resources/prompts 全拉回来缓存,用时再拉不行吗?
A:预取换取查看时的零往返。列表类信息(有哪些资源、哪些模板)变化不频繁,连接时一次性拉回缓存,之后 /mcp resources 直接读缓存、无需再跟 server 往返。而正文(resources/read)才按需拉——因为正文可能大、也可能变。这是"列表预取、内容懒取"的典型分层。
Q:如果 server 只支持 tools、不支持 resources/prompts,客户端会怎样?
A:connect() 里对 resources/list、prompts/list 都包了 try/catch,拉失败就设成空数组、静默继续。这依赖前面的能力协商——理论上客户端可以先看 capabilities 决定要不要发这两个请求,更严谨;我们用 try/catch 兜底是更省事的等价做法。面对可能不支持某能力的 server,客户端必须优雅降级,不能因为对方缺一种能力就整个连接失败。
# 五、踩坑 / 设计权衡
- 三种能力别混用:最大的设计陷阱就是"反正都能传数据,全用 tools 算了"。那会丢掉「主动权归属」和「副作用有无」这两个关键区分,导致模型可能擅自执行本该用户挑选的东西。协议分三种正是为了在类型层守住这条边界。
- 我们的简化:resource 没做成
@可引用、prompt 没做成自动斜杠命令、也没做 resource 订阅更新通知。机制都通了,缺的是与前面@/斜杠命令体系的自动打通。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
| resources list/read | 一致(标准 MCP 方法) |
| prompts list/get | 一致;真实把 prompt 做成斜杠命令自动注入 |
| 能力协商(capabilities 声明三种) | 一致 |
/mcp 子命令查看 | 真实有 /mcp 面板 + resource @ 引用 UI |
| 还缺:resource 做成 @ 可引用、prompt 自动注册为斜杠命令、resource 订阅更新通知 | 真实版都有 |
# 七、一句话总结
MCP 三能力 = 三种"控制权+副作用"组合:tools 是模型主动、有副作用的动作,resources 是用户主动、只读的数据(≈@文件),prompts 是用户主动触发、填空即发的指令模板(≈斜杠命令)——server 握手时用 capabilities 协商声明、客户端连接时预取列表按需读正文,分成三种才能把"谁主动"和"有没有副作用"这两条边界在协议层守住。
# 下一节预告
玩具 server 三种能力都跑通了,该拿去实战验证。step50 用同一套客户端接入真实工业级 server——微软的 @playwright/mcp,驱动浏览器。