8.3 核心原语:tools、resources、prompts
一句话结论:三个原语的分界线是**"谁发起、改不改变状态"**。混用它们会导致权限模型失效——把"读数据"做成"工具",就等于让模型自由决定要读什么。
读这篇之前
需要先读 8.2(两层结构)、4.1(工具调用)。这一篇讲 MCP 的语义核心。
一、三个原语一览
MCP 数据层定义了服务端能提供的三类东西:[核验:MCP 官方架构]
| 原语 | 语义 | 谁发起 | 改变状态 |
|---|---|---|---|
| tools | 让 AI 执行动作 | 模型(通过调用) | 可能 |
| resources | 提供上下文数据 | 客户端(可主动读) | 不改变 |
| prompts | 提供交互模板 | 用户/客户端(选用) | 不改变 |
记住这张表的三列,后面所有区别都从这里推出来。
二、tools:模型能做的事
和 Function Calling 的关系
形态上几乎一致(name + description + schema + 返回结果)。区别在"谁提供":
| Function Calling | MCP tools | |
|---|---|---|
| 工具由谁定义 | 你的应用代码 | 独立运行的服务端 |
| 复用范围 | 单个应用 | 所有 MCP 客户端 |
| 生命周期 | 跟着应用 | 独立进程/服务 |
设计要点
MCP 的 tools 设计原则,和 4.2–4.4 讲的完全一致:
| 要点 | 出处 |
|---|---|
| description 要写"什么时候用/不用" | 4.2 |
| 参数用枚举而非自由文本 | 4.3 |
| 副作用分级(只读/写入/不可逆) | 4.5 |
| 防呆设计优于报错 | 4.4 |
MCP 不改变这些原则。 它只改变"工具从哪来"。
三、resources:只读数据的正确位置
这是最容易被误用的原语。
它解决什么问题
假设你有一个知识库服务端。有两种暴露方式:
❌ 做成工具:read_document(path)
→ 模型自由决定读哪个文件
→ 等于把整个知识库的访问权交给模型
✅ 做成 resource:documents://handbook
→ 客户端决定提供哪些资源
→ 模型只能看被给予的内容
这个区别就是权限模型的区别。
resources 的典型形态
| 类型 | 例子 |
|---|---|
| 文件 | file:///project/README.md |
| 数据库记录 | db://users/123 |
| 日志 | logs://app/2026-10-25 |
| 配置 | config://app/settings |
为什么"客户端发起"很重要
因为是否把某个 resource 放进上下文,是客户端的决定,不是模型的。
这意味着:
- 用户/应用可以控制"给模型看什么"
- 不会出现"模型自己决定去读生产数据库"的情况
这条是资源与工具最本质的差别。
四、prompts:把"最佳提问方式"封装起来
它解决什么问题
一个数据源往往有"最佳提问姿势"。比如一个 GitHub 服务端知道:
- 问"最近改了什么"应该用
compare(from, to) - 问"谁写的这行"应该用
blame(path, line)
prompts 允许把这类模板预置在服务端,客户端可以直接选用,而不用让用户自己摸索。
它和 Skill 的区别
| MCP prompts | Skill | |
|---|---|---|
| 位置 | 服务端 | 客户端/用户配置 |
| 内容 | 针对特定数据源的交互模板 | 针对特定任务的能力包 |
| 加载 | 用户选用 | 按需加载(9.1) |
两者都是"提示模板",但归属不同:prompts 属于数据源,Skill 属于使用方。
五、怎么选:一个决策表
| 你想做的事 | 用哪个 | 理由 |
|---|---|---|
| 执行动作(改文件、发消息) | tools | 需要模型决定 |
| 提供可读数据(文件、记录) | resources | 让客户端控制暴露范围 |
| 预置提问模板 | prompts | 封装最佳实践 |
| 既读又改 | 拆成两个:read_x + update_x |
副作用分级(4.5) |
最后一行很重要:不要把"读"和"写"合成一个工具,否则无法单独对写操作加审批。
六、一个完整例子:GitHub 服务端
假设你要做一个 GitHub MCP 服务端,三个原语分别怎么用:
tools(模型决定调用):
create_issue(repo, title, body) ← 写入
add_comment(issue_number, body) ← 写入
merge_pr(pr_number) ← 不可逆,需审批
resources(客户端提供):
repo://owner/name/README.md
repo://owner/name/issues/123
repo://owner/name/pr/456/diff
prompts(用户选用):
"审查这个 PR" → 模板:先读 diff,再按清单检查
"总结本周提交" → 模板:读 commit 列表,按主题归类
这样划分的好处:
| 关注点 | 落在哪 |
|---|---|
| 模型能做什么(且可审计) | tools |
| 用户愿意暴露哪些数据 | resources |
| 常见任务怎么做最好 | prompts |
七、动手:把 GitHub 服务端画完整
上一节给了分类,这一节给出每个原语的完整定义形态,便于照抄。
tools 的三个定义
// ① 只读查询
{"name":"list_issues",
"description":"列出仓库的 issue。只读。适用于:查看待办、统计数量。
不适用于:搜索代码(用 search_code)。",
"inputSchema":{"type":"object",
"properties":{
"repo":{"type":"string","description":"owner/name 格式"},
"state":{"type":"string","enum":["open","closed","all"],"description":"默认 open"}},
"required":["repo"]}}
// ② 写入(需要审批)
{"name":"create_issue",
"description":"创建 issue。写操作,会通知仓库成员。",
"inputSchema":{"type":"object",
"properties":{"repo":{"type":"string"},"title":{"type":"string"},"body":{"type":"string"}},
"required":["repo","title"]}}
// ③ 不可逆(需人工确认)
{"name":"merge_pr",
"description":"合并 PR 到目标分支。不可逆,会触发部署流水线。",
"inputSchema":{"type":"object",
"properties":{"repo":{"type":"string"},"pr":{"type":"integer"}},
"required":["repo","pr"]}}
注意三者的 description 写法差异:
| 类型 | description 里必须有什么 |
|---|---|
| 只读 | "只读"+ 不适用场景 |
| 写入 | "写操作"+ 副作用(通知谁) |
| 不可逆 | "不可逆"+ 后续影响(触发部署) |
这就是 4.5 讲的"用描述暴露副作用等级"——模型看到"不可逆"三个字,会更谨慎。
resources 的 URI 设计
repo://owner/name/README.md
repo://owner/name/issues/123
repo://owner/name/pr/456/diff
URI 设计的三条原则:
| 原则 | 理由 |
|---|---|
| 层次化 | 便于客户端做前缀匹配(如只允许 repo://myorg/*) |
| 可预测 | 客户端能拼出自己需要的 URI |
| 不含敏感信息 | 防止日志泄露(14.5) |
prompts 的定义
{"name":"review_pr",
"description":"审查一个 PR 的完整流程",
"arguments":[{"name":"pr","description":"PR 编号","required":true}]}
调用后服务端返回一段预置的提示模板。
八、一个必须回答的问题:三个原语能不能互通
技术上可以(用 tools 实现读文件),但会改变权限模型。
| 用 tools 实现读 | 用 resources 实现读 |
|---|---|
| 模型自己决定读哪个 | 客户端决定提供哪个 |
| 无需用户确认 | 可事先审批范围 |
| 攻击面大(8.6) | 攻击面小 |
所以"能不能"和"该不该"是两件事。 只读数据用 resources,是安全设计而非风格偏好。
九、三个原语的返回形态与错误约定
定义清楚还不够,返回什么同样影响模型能否用好。
| 原语 | 返回形态 | 关键约定 |
|---|---|---|
| tools | 内容块数组 + isError |
业务错误放 isError,不放协议 error |
| resources | MIME 类型 + 内容 | 要声明类型(text/markdown、application/json) |
| prompts | 消息数组 | 可带参数占位符 |
tools 返回的一个细节
好:content: [{"type":"text","text":"共 42 条 issue,已返回前 20 条(state=open)"}]
差:content: [{"type":"text","text":"{...完整 JSON 500KB...}"}]
理由(4.4 原则三):返回值只给模型需要的。大 JSON 既占上下文又稀释注意力(2.2)。
resources 的 MIME 类型为什么重要
| 类型 | 客户端怎么处理 |
|---|---|
text/markdown |
直接放进上下文 |
application/json |
可能要先摘要(12.3 的摘要传递) |
image/png |
走图像通道,不进文本上下文 |
不声明类型,客户端只能猜——猜错就会把二进制塞进文本上下文。
prompts 的参数
{"name":"review_pr","arguments":[{"name":"pr","required":true}]}
参数要声明必填,否则模型可能传空值进去,得到一段残缺的模板。
补充误区
误区七:resources 就是"换个名字的工具"。
发起方与权限模型完全不同(第三节)。
误区八:prompts 不重要,客户端自己写就行。
服务端最懂自己的数据结构与最佳提问方式。
误区九:URI 可以随便设计。
不层次化就无法做前缀白名单。
误区十:读取数据做成 tools 更灵活。
灵活等于把访问权交给模型。只读用 resources。
十、设计三个原语时的三条原则
原则一:按"谁发起"分配,不按"方便"分配
错:把读文件做成 tool,因为"模型可以自己决定读哪个"更方便
对:做成 resource,因为"客户端决定给哪个"才安全
"方便"往往等于"把权限交给模型"(14.4)。
原则二:一个原语对应一类能力,不要混杂
| 原语 | 该装什么 | 不该装什么 |
|---|---|---|
| tools | 会改变状态的动作 | 只读查询 |
| resources | 静态数据 | 需要参数的复杂查询 |
| prompts | 固定模板 | 需要动态数据的内容 |
"需要参数的复杂查询"放 tools 更合适——因为 resource 的 URI 表达能力有限。
原则三:给客户端留"选择权"
resources 的价值是客户端能选择提供哪些。所以:
好:resources 提供 20 个细粒度资源,客户端按需挑 3 个
差:resources 只提供 1 个"全部数据"的巨型资源
细粒度让客户端能控制上下文(2.2)。
十一、常见误区
误区一:所有能力都做成 tools 最方便。
方便,但等于把访问权交给模型。只读数据应该是 resources。
误区二:resources 就是"另一种工具"。
发起方不同:resources 由客户端提供,tools 由模型调用。这是权限分界。
误区三:prompts 是多余的,客户端自己写就行。
服务端更懂自己的数据源该怎么问。prompts 是把专家知识封装起来。
误区四:读和写可以合一个工具。
无法分级审批。必须拆开。
误区五:三个原语可以随便互换。
互换会改变权限模型与发起方,不是等价替换。
误区六:MCP 原语和 Skill 是竞争关系。
不是。prompts 属数据源,Skill 属使用方,可以并存。
十二、动手:三个原语的完整定义示例
以文件系统服务端为例,三个原语各自怎么写。
tools(会改变状态的动作)
{"name": "write_file",
"description": "写入文件。写操作,会覆盖原有内容。不可逆。",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "项目内相对路径,如 src/app.ts"},
"content": {"type": "string", "description": "完整文件内容"}
},
"required": ["path", "content"]}}
注意 description 里的三个词:"写操作""会覆盖""不可逆" —— 这是给模型的风险信号(4.5)。
resources(只读数据)
{"uri": "file:///project/README.md",
"name": "README",
"mimeType": "text/markdown"}
关键差异:没有参数,客户端决定读哪个(8.3 第三节)。
prompts(预置模板)
{"name": "explain_file",
"description": "解释一个文件的作用",
"arguments": [{"name": "path", "required": true}]}
三者的选择速查
| 你想 | 用 |
|---|---|
| 让模型执行动作 | tools |
| 让客户端控制暴露哪些数据 | resources |
| 封装"最佳提问方式" | prompts |
| 读和写都要 | 拆成两个工具(副作用不同) |
最后一行最容易做错:把读写合在一个工具里,就没法单独给写操作加审批(14.3)。
十三、动手:一个最简 MCP Server 的 60 行骨架
知道协议后,最快的学习方式是自己写一个。
import sys, json
TOOLS = {
"read_file": {
"description": "读取项目内文本文件。只读。",
"inputSchema": {
"type": "object",
"properties": {"path": {"type": "string", "description": "相对路径"}},
"required": ["path"],
},
}
}
def handle(req):
m = req.get("method")
if m == "server/discover":
return {"protocolVersion": "2026-07-28",
"capabilities": {"tools": {}},
"serverInfo": {"name": "mini", "version": "0.1"}}
if m == "tools/list":
return {"tools": [{"name": k, **v} for k, v in TOOLS.items()]}
if m == "tools/call":
name = req["params"]["name"]
args = req["params"]["arguments"]
if name not in TOOLS:
return {"error": {"code": -32601, "message": f"未知工具 {name}"}}
try:
text = open(args["path"], encoding="utf-8").read()[:20000]
return {"content": [{"type": "text", "text": text}], "isError": False}
except Exception as e:
return {"content": [{"type": "text", "text": f"读取失败:{e}"}],
"isError": True}
return {"error": {"code": -32601, "message": f"未知方法 {m}"}}
for line in sys.stdin: # 一行一个 JSON
req = json.loads(line)
result = handle(req)
resp = {"jsonrpc": "2.0", "id": req.get("id"), **result}
print(json.dumps(resp, ensure_ascii=False), flush=True)
这个 60 行里包含的四个协议要点
| 要点 | 代码 |
|---|---|
| 能力发现 | server/discover 分支 |
| 工具列表 | tools/list |
| 业务错误 | result.isError: true(不是 JSON-RPC error) |
| 协议错误 | error: {code, message} |
第三、四行是新手最容易混的 —— 混用会导致模型看到一堆技术报错,或者客户端无法识别失败。
两个必须注意的实现细节
| 细节 | 原因 |
|---|---|
flush=True |
不刷新缓冲,客户端会一直等 |
| 日志必须写到 stderr | stdout 只能有协议消息 |
十四、三个原语的一个常见误解:resources 不能带参数
很多人想用 resources 做"带参数的查询":
错:resource: "db://users?name=张三"
问题是:resources 的语义是"可枚举的静态资源",带参数会让它变成"查询接口"——那就该是 tool。
判据表
| 你的需求 | 用 |
|---|---|
| 固定的一组资源(文件、记录、配置) | resources |
| 需要按条件查询 | tools |
| 查询结果集大小未知 | tools(可加 limit 参数) |
| 结果集固定且小 | resources |
关键区别:
resources:客户端知道有哪些,选择给哪些
tools:模型提出条件,由服务端执行查询
把带参数的查询塞进 resources,等于放弃了"客户端控制暴露范围"这个核心优势(8.3 第三节)。
一个折中
如果确实需要"按类别取资源",可以定义一组 resource 模板:
repo://{owner}/{name}/README.md
repo://{owner}/{name}/issues/{id}
但模板参数是"定位",不是"查询条件" —— 这个界限要守住。
十五、下一篇
数据层还有一个设计值得单独讲:能力协商与版本发现。
它看起来只是协议的"握手",但它是让生态能持续演进的前提——否则客户端和不同版本的服务端根本无法共存。
下一篇讲这个机制。
十二、一个补充:三个原语与权限模型的对应
| 原语 | 谁决定"用不用" | 权限控制点 |
|---|---|---|
| tools | 模型 | 工具白名单 + 参数校验 + 审批 |
| resources | 客户端 | 资源 URI 前缀白名单 |
| prompts | 用户/客户端 | 一般无风险(只提供模板) |
这张表的实际用途:设计权限时,按原语分别配置,不要一刀切。
一个常见的权限漏洞
错:只给 tools 配了白名单,resources 全放开
后果:服务端列出 file:///etc/,客户端全盘接受
resources 也必须做白名单 —— 按 URI 前缀限制(如只允许 repo://myorg/*)。
这也是 8.6 检查清单里"看 resources 列表"那一条的原因。