绝大多数 MCP 教程都在教你怎么造「工具(Tools)」——让模型执行动作。但 MCP 体系里还有一个被严重低估的原语:Resources(资源)。它的定位是只读上下文:把文件、配置、数据库 schema、运行日志、文档片段等数据,用标准 URI 暴露给模型,让模型在你授权后"阅读",而不是"调用"。
本教程聚焦唯一一个面向"数据"的 MCP 原语。学完你将掌握:Resources 与 Tools 的本质区别、@mcp.resource 装饰器与 URI 模板(RFC 6570)、静态资源与动态资源模板的写法、resources/list 与 resources/read 协议握手、列表变更与订阅通知,并在最后落地一个可运行的「项目知识库」MCP 服务器。适合已经写过 Tool、想补齐全套原语的开发者。
| 项 | 要求 | 说明 |
|---|---|---|
| Python | 3.10+ | 资源函数支持类型注解即可 |
| FastMCP | pip install fastmcp |
官方维护的高层 SDK,资源 API 最简洁 |
| Claude Code | 已安装并跑通过至少一个 MCP | 本教程假设你用过 /mcp 命令 |
| MCP Inspector | npx @modelcontextprotocol/inspector |
用于离线验证资源是否正确暴露 |
你应当已经理解 MCP 的客户端/服务器架构,并写过至少一个 Tool。如果还没有,先补「首个 MCP 工具」再回来——本文不重复 Tool 的基础。
MCP 的服务端能力被拆成三类,对应模型与"世界"互动的三种姿势:
一个高频误区是把所有东西都做成 Tool。后果是:模型会"自作主张"地反复调用,浪费 token、放大幻觉风险,而且用户失去对"哪些数据进入了上下文"的控制权。Resources 的设计哲学是 application-driven(由应用/用户驱动):哪些资源进入上下文,由宿主应用(如 Claude Code 的资源选择器、@ 提及)决定,而不是模型自己拍板。这一条决定了它的安全语义——只读、幂等、可审计。
服务端若支持资源,必须在初始化握手里声明 resources 能力:
{
"capabilities": {
"resources": {
"subscribe": true,
"listChanged": true
}
}
}
两个子能力都是可选的:
subscribe:客户端能否订阅某个资源,在其变更时收到通知;listChanged:资源清单变化时,服务端是否会主动广播。围绕这两个开关,协议定义了一组消息:
| 方法 | 方向 | 作用 |
|---|---|---|
resources/list |
客户端→服务端 | 列出所有静态资源(支持分页 cursor) |
resources/read |
客户端→服务端 | 按 URI 读取资源内容 |
resources/templates/list |
客户端→服务端 | 列出动态资源模板 |
resources/subscribe |
客户端→服务端 | 订阅某 URI 的变更 |
notifications/resources/updated |
服务端→客户端 | 某资源内容变了 |
notifications/resources/list_changed |
服务端→客户端 | 资源清单变了,请重新 list |
读懂这张表,你就掌握了资源的全部交互面。下面把它落到代码里。
新建 kb_server.py:
import json
from fastmcp import FastMCP
mcp = FastMCP(name="ProjectKB")
# 静态资源:固定 URI,内容在读取时求值(懒加载)
@mcp.resource("kb://manifest")
def get_manifest() -> str:
"""项目清单:名称、版本、模块入口,供模型理解项目骨架。"""
return json.dumps({
"name": "order-service",
"version": "2.4.1",
"modules": ["api", "worker", "scheduler"],
"entry": "api/main.py"
}, ensure_ascii=False, indent=2)
启动方式有两种,二选一:
# 方式 A:FastMCP CLI(开发期最省事)
fastmcp dev kb_server.py
# 方式 B:直接以 stdio 模式跑(Claude Code 接入用)
fastmcp run kb_server.py
用 MCP Inspector 连上后,切到 Resources 标签页,你会看到一个 URI 为 kb://manifest 的条目。点击它,右侧返回:
{
"contents": [
{
"uri": "kb://manifest",
"mimeType": "text/plain",
"text": "{\n \"name\": \"order-service\", ... }"
}
]
}
对应的底层 JSON-RPC 就是协议里的 resources/read:
{ "jsonrpc": "2.0", "id": 2, "method": "resources/read",
"params": { "uri": "kb://manifest" } }
resources: true。text 是 Python 对象的 repr? 资源函数必须返回 str/bytes/ResourceResult 三者之一。返回 dict 不会自动序列化——务必 json.dumps。json.dumps 加 ensure_ascii=False;二进制资源(如图片)返回 bytes,FastMCP 会自动 base64 编码为 BlobResourceContents。静态资源适合「全局唯一」的数据(清单、版本)。真实世界更多是按参数取的数据:某一天的报告、某个模块的文档、某个指标的当前值。这时用资源模板(Resource Template),本质是带占位符的 URI 模板(遵循 RFC 6570)。
from pathlib import Path
import json
NOTES_DIR = Path("notes") # 存放 markdown 文档的目录
METRICS = { # 模拟一个"指标数据库"
"qps": {"value": 1280, "unit": "req/s"},
"p99": {"value": 42, "unit": "ms"},
"error_rate": {"value": 0.03, "unit": "%"},
}
@mcp.resource("kb://guide/{topic}")
def get_guide(topic: str) -> str:
"""按主题读取一篇指南(notes/<topic>.md)。"""
# 安全校验:topic 只允许字母数字与短横线,杜绝路径穿越
if not topic.replace("-", "").isalnum():
raise ValueError(f"非法主题名: {topic}")
path = (NOTES_DIR / f"{topic}.md").resolve()
if not str(path).startswith(str(NOTES_DIR.resolve())):
raise ValueError("禁止访问 notes 目录之外的文件")
if not path.exists():
raise FileNotFoundError(f"未找到主题: {topic}")
return path.read_text(encoding="utf-8")
@mcp.resource("kb://metric/{name}")
def get_metric(name: str) -> str:
"""按名称读取一个运行指标。"""
if name not in METRICS:
raise KeyError(f"未知指标: {name}")
return json.dumps({"name": name, **METRICS[name]}, ensure_ascii=False)
关键点:URI 里的 {topic}、{name} 与函数参数名必须同名,FastMCP 会把模板参数自动注入。模板注册后,客户端通过 resources/templates/list 拿到 uriTemplate: "kb://guide/{topic}",然后用 kb://guide/deploy 这样的具体 URI 去 read。
Inspector 的 Resource Templates 标签会列出两个模板。在地址栏输入 kb://metric/qps,得到:
{ "name": "qps", "value": 1280, "unit": "req/s" }
输入 kb://guide/deploy(前提是 notes/deploy.md 存在)则返回该文档全文。
{topic} 不能匹配含 / 的值。FastMCP 提供通配参数(RFC 6570 的保留展开 {+path})来捕获剩余路径。若版本不支持,退而求其次:把多段路径编码成单段(如用 __ 代替 /),或干脆拆成多个具名参数 kb://guide/{section}/{slug}。*args 报错? 资源模板不支持 *args,但支持 **kwargs——因为 URI 模板本身就定义了具名参数集。资源可以挂注解(Annotations),提示客户端如何呈现、如何缓存。默认情况下,资源的 readOnlyHint 和 idempotentHint 都是 true——这正反映了"只读幂等"的核心承诺。显式声明能让客户端做更好的 UI 与访问优化:
@mcp.resource(
"kb://changelog",
name="变更日志",
description="最近一次发布的功能与修复清单。",
mime_type="text/markdown",
annotations={"readOnlyHint": True, "idempotentHint": True},
)
def get_changelog() -> str:
return Path("CHANGELOG.md").read_text(encoding="utf-8")
| 注解 | 默认 | 含义 |
|---|---|---|
readOnlyHint |
true |
只提供数据,不修改环境 |
idempotentHint |
true |
多次读取等价于一次 |
meta |
空 | 透传给客户端的自定义元数据,可用于版本号、归属等 |
注意:注解是建议性的,不会在协议层强制执行。它的价值在于让 Claude Code 这类客户端把只读资源和有副作用的工具分开渲染、分别授权。
很多教程止步于静态读取。但真实场景里数据会变——指标在更新、文档被改写。两个通知机制让资源具备时效性。
FastMCP 在你运行时增删资源时,会自动广播 notifications/resources/list_changed,无需手写。运行期动态注册一个资源,客户端会自动刷新清单:
@mcp.tool()
def publish_runbook(name: str, content: str) -> str:
"""把一段手册发布成资源(带副作用的动作,所以用 Tool)。"""
# 注意:这是 Tool,不是 Resource——它改变了世界
mcp.add_resource_fn(lambda: content, uri=f"kb://runbook/{name}",
name=name)
return f"已发布 {name}"
声明 subscribe 能力后,客户端可订阅特定 URI,服务端在内容变化时推送 notifications/resources/updated:
from fastmcp import Context
@mcp.resource("kb://live-qps")
async def live_qps(ctx: Context) -> str:
"""实时 QPS(演示订阅语义)。"""
return json.dumps({"qps": await fetch_qps()})
设计提示:把"读当前值"做成 Resource(
kb://live-qps),把"发布一个值"做成 Tool。这是 Resources/Tools 边界最清晰的实战体现——读用资源,写用工具。
把前面的零件组装成一个完整服务。目录结构:
project-kb/
├── kb_server.py # MCP 服务器
├── notes/
│ ├── deploy.md # 部署指南
│ └── rollback.md # 回滚手册
├── metrics.json # 指标数据源
├── CHANGELOG.md
└── .mcp.json # Claude Code 接入配置
kb_server.py 完整版:
import json
from pathlib import Path
from fastmcp import FastMCP
mcp = FastMCP(name="ProjectKB")
ROOT = Path(__file__).parent
# ① 静态资源:项目骨架
@mcp.resource("kb://manifest")
def manifest() -> str:
return json.dumps({
"name": "order-service", "version": "2.4.1",
"modules": ["api", "worker", "scheduler"],
}, ensure_ascii=False)
# ② 静态资源:变更日志(markdown)
@mcp.resource("kb://changelog",
mime_type="text/markdown",
description="最近发布的功能与修复。")
def changelog() -> str:
return (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
# ③ 动态模板:按主题读文档(带路径穿越防护)
@mcp.resource("kb://guide/{topic}")
def guide(topic: str) -> str:
if not topic.replace("-", "").isalnum():
raise ValueError("非法主题名")
p = (ROOT / "notes" / f"{topic}.md").resolve()
if not str(p).startswith(str((ROOT / "notes").resolve())):
raise ValueError("越界访问")
return p.read_text(encoding="utf-8")
# ④ 动态模板:按名读指标
@mcp.resource("kb://metric/{name}")
def metric(name: str) -> str:
data = json.loads((ROOT / "metrics.json").read_text(encoding="utf-8"))
if name not in data:
raise KeyError(f"未知指标: {name}")
return json.dumps({"name": name, **data[name]}, ensure_ascii=False)
if __name__ == "__main__":
mcp.run() # 默认 stdio 传输
.mcp.json(放在项目根,Claude Code 启动时自动加载):
{
"mcpServers": {
"project-kb": {
"command": "fastmcp",
"args": ["run", "kb_server.py"]
}
}
}
在项目目录里启动 Claude Code,运行 /mcp 应看到 project-kb 已连接。然后让模型读数据——资源由你(用户)显式喂入,而非模型自己调:
提示词示例:
@kb://manifest 结合 @kb://guide/deploy,说明本次部署要改哪些模块的配置。
Claude Code 会把这两个资源的内容作为上下文读进来,再基于它们作答。若想确认资源是否真被读到,可用 /context 查看当前上下文里的资源片段。
/mcp 显示 project-kb: connected,且能力含 resourcesresources/list 返回 2 个静态资源、resources/templates/list 返回 2 个模板@kb://metric/qps 能把指标读进上下文kb://guide/../../etc/passwd 被拒绝(路径穿越防护生效)资源从声明到被消费的完整链路:
sequenceDiagram
participant CC as Claude Code(客户端)
participant SRV as MCP 服务器
Note over CC,SRV: 初始化握手
CC->>SRV: initialize(声明期望)
SRV-->>CC: capabilities.resources = {subscribe, listChanged}
Note over CC,SRV: ① 发现
CC->>SRV: resources/list(cursor 分页)
SRV-->>CC: 静态资源清单
CC->>SRV: resources/templates/list
SRV-->>CC: 动态模板清单 kb://guide/{topic}
Note over CC,SRV: ② 用户用 @ 显式选择
CC->>SRV: resources/read(uri=kb://guide/deploy)
SRV->>SRV: 校验 topic → 读 notes/deploy.md
SRV-->>CC: TextResourceContents
Note over CC,SRV: ③ 变更通知
SRV-->>CC: notifications/resources/list_changed
CC->>SRV: resources/list(重新拉取)
何时选 Resource、何时选 Tool,用下面这张决策图收口:
flowchart TD
A[要给模型的东西] --> B{是动作还是数据?}
B-- 动作/有副作用 --> T[Tool 工具]
B-- 数据/只读 --> C{由谁决定读不读?}
C-- 模型自主调用 --> T
C-- 用户/应用显式喂入 --> R[Resource 资源]
R --> D{数据是唯一的还是按参数取?}
D-- 唯一 --> R1[静态资源 kb://manifest]
D-- 按参数 --> R2[资源模板 kb://guide/{topic}]
resolve() + 前缀校验,并拒绝 .. 与绝对路径。上面的 guide() 就是模板写法。read 别返回超长内容;超大文件应按段切片成多个 URI(如 kb://log/2024-06-24/p1)。bytes。返回 bytes 会被 base64 成 BlobResourceContents,记得显式给 mime_type(如 image/png)。idempotentHint 的本意。modelcontextprotocol.io/specification/2025-06-18/server/resources,权威协议定义与 JSON-RPC 细节。gofastmcp.com/servers/resources,@mcp.resource 装饰器与模板参数的完整参考。datatracker.ietf.org/doc/html/rfc6570,理解 {name} 与 {+path} 展开规则。csharp.sdk.modelcontextprotocol.io/concepts/resources,跨语言对照资源原语写法。| 维度 | 评估 |
|---|---|
| 适合训练营 | MCP 进阶营、企业 AI 工程营、内部知识库自动化营 |
| 学员基础 | 已能独立写出至少一个 MCP Tool |
| 教学时长 | 讲授 45 分钟 + 动手 60 分钟 + 联调 30 分钟,合计约 2.5 小时 |
| 验收标准 | 学员独立交付一个含 1 个静态资源 + 2 个资源模板的 KB 服务器,并通过 Inspector 与 Claude Code @ 引用双重验证 |
| 延伸作业 | 给资源加上 subscribe 通知;把指标数据源从 JSON 换成真实数据库只读视图 |
@mcp.resource 装饰器、URI 模板参数、返回类型与注解的权威 API 参考。{name} 与 {+path} 保留展开的语法定义。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
