# 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" } } });
1
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");
}
1
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
1
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 → 拼出「请把下面这段文本总结成要点:…」
1
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,驱动浏览器。