Agent 工程体系 · 第 34/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
MCP Resources:URI、模板、订阅、内容类型和访问控制
MCP Resource 是服务器向客户端暴露的“可读取上下文”。它可以代表文件、数据库 Schema、Git 对象、网页、业务记录,也可以代表完全由应用定义的数据。客户端通过 MCP 协议发现资源、读取资源,并在资源发生变化时接收通知。
Resource 的核心不是“服务器提供一个下载接口”,而是建立一层稳定的资源标识、发现、读取和变化传播协议:
资源身份 ──URI──> 资源发现 ──resources/list──> 资源读取 ──resources/read──> 内容
│ │
└──资源模板──> 参数化 URI └──内容类型、权限、缓存
│
资源订阅──> updated 通知
在 2026-07-28 协议版本中,MCP 使用按请求携带的协议版本、客户端身份和能力元数据,不再依赖旧版本的初始化握手来建立协议会话。本文以 2026-07-28 为主,同时指出与旧版本的兼容边界。(modelcontextprotocol.io)
一、先区分 Resource、Tool 和 Prompt
MCP Server 可以提供三类不同性质的能力:
- Resource:服务器提供的数据或上下文,客户端读取它;
- Tool:服务器提供的可执行操作,模型或应用调用它;
- Prompt:服务器提供的消息模板或工作流入口,客户端将它呈现给用户或模型。
这三者最容易混淆的地方在于:Resource 也可能触发后端查询,Tool 也可能返回数据,Prompt 也可能引用 Resource。但协议语义不同。
假设服务器提供一个订单系统:
orders://tenant/acme/order/1001
它可以作为 Resource,让客户端读取订单详情:
{
"uri": "orders://tenant/acme/order/1001",
"mimeType": "application/json",
"text": "{\"id\":1001,\"status\":\"paid\"}"
}
如果服务器提供:
get_order(order_id)
这就是 Tool。它表达的是“执行一次查询或操作”,而不是“某个可被读取的资源身份”。
如果服务器提供:
review_order(order_id, language)
它可能是 Prompt,用来生成一组消息模板,而不是直接返回订单数据。
因此,判断一个对象是否应该建模为 Resource,可以使用下面的条件:
如果客户端需要表达“我要读取某个稳定身份对应的数据”,这个对象适合建模为 Resource;如果客户端需要表达“请服务器执行一次动作”,则更适合建模为 Tool。
Resource 不等于静态文件。一个 db://schema/customer 可以由数据库实时生成,一个 git://repo/commit/path 可以由 Git 服务动态解析,一个 orders://... 可以由业务 API 动态读取。
MCP 将 Host、Client 和 Server 分开:Host 负责用户界面、模型编排和安全决策;Client 负责与单个 Server 通信;Server 暴露 Resource、Tool 和 Prompt。一个 Host 可以管理多个 Client,但一个 Client 与一个 Server 保持一对一关系。(modelcontextprotocol.io)
二、URI 是资源的身份,不是一定要直接访问的地址
2.1 URI 的基本含义
URI,即 Uniform Resource Identifier,是资源的统一标识符。对于 MCP Resource,URI 主要回答:
客户端下一次读取时,应该用哪个字符串准确地指向这个资源?
例如:
file:///workspace/src/main.py
https://docs.example.com/api/auth
git://repo.example.com/project/commit/abc123/path/README.md
orders://tenant/acme/order/1001
URI 的重要属性是稳定、唯一、可再次使用,而不是必须可以被浏览器直接打开。
例如:
orders://tenant/acme/order/1001
可能没有任何公共网络服务可以处理这个地址,但它仍然可以作为有效的 MCP Resource URI。MCP Server 接收到它后,可以:
- 解析 Scheme:
orders; - 解析租户:
acme; - 解析资源类型:
order; - 解析主键:
1001; - 校验当前用户是否有权限读取;
- 查询订单;
- 返回 JSON 内容。
因此,下面两种 URI 的含义不同:
https://api.example.com/orders/1001
orders://tenant/acme/order/1001
前者通常表示客户端可以直接访问的 Web 资源;后者表示需要通过 MCP Server 的协议语义读取的业务资源。
规范建议:只有当客户端能够自行通过 Web 获取资源时,服务器才应使用 https:// 表示该资源;如果必须经过 MCP Server 才能完成鉴权、转换或聚合,应考虑使用其他 Scheme 或自定义 Scheme。自定义 Scheme 仍然必须符合 URI 语法要求。(modelcontextprotocol.io)
2.2 URI 的组成与规范化
以 URI 为例:
orders://tenant/acme/order/1001?view=summary
可以拆成:
scheme = orders
authority = tenant
path = /acme/order/1001
query = view=summary
fragment = none
在权限系统中,URI 规范化非常重要,因为下面两个字符串是否表示同一个资源,不能凭直觉判断:
file:///workspace/a.txt
file:///workspace/./a.txt
对于文件系统,它们可能指向同一文件;但如果服务器把 URI 直接作为数据库键,它们可能被视为两个不同资源。
工程上应明确资源身份规则:
规范化前 URI
│
├──解析 Scheme、Authority、Path、Query
├──校验允许的字符和结构
├──执行必要的路径规范化
├──拒绝 fragment 或未定义字段
└──生成 canonical URI
但不能进行无条件的“字符串清洗”。例如:
https://example.com/a%2Fb
https://example.com/a/b
%2F 是否等价于 /,取决于具体资源语义。过度解码可能让两个本来不同的资源发生碰撞,也可能绕过权限边界。
规范明确要求服务器校验所有资源 URI;提供 file:// 资源时,还必须清理文件路径以阻止目录遍历。(modelcontextprotocol.io)
2.3 URI 不是权限凭证
一个常见错误是把 URI 当成“拥有资源的证明”:
file:///home/alice/private/secrets.txt
这个字符串只能说明客户端请求了一个目标,不能说明客户端有权读取它。
正确的判断关系是:
可访问
= URI 语法合法
∧ 资源存在
∧ 请求者身份有效
∧ 令牌面向当前 Server
∧ 请求者拥有该资源所需权限
只要其中一个条件不成立,服务器都不应该返回资源内容。
三、资源发现:resources/list 返回的是当前可见资源集合
3.1 Resource 能力声明
支持 Resource 的 Server 必须声明 resources 能力:
{
"capabilities": {
"resources": {
"listChanged": true,
"subscribe": true
}
}
}
两个字段表达不同的变化机制:
listChanged:资源列表本身发生变化;subscribe:客户端可以订阅某个具体 Resource 的更新通知。
它们可以独立声明。Server 也可以只声明:
{
"capabilities": {
"resources": {}
}
}
此时服务器支持 Resource,但不承诺列表变化通知,也不承诺资源更新订阅。(modelcontextprotocol.io)
在 2026-07-28 版本中,客户端和服务器通过请求元数据表达协议版本、身份和能力。客户端可以调用 server/discover 提前发现服务器能力,也可以直接调用某个操作并处理版本或能力错误。(modelcontextprotocol.io)
3.2 resources/list
客户端通过 resources/list 发现资源:
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "wr-host",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {
"roots": {}
}
}
}
}
服务器返回:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"resources": [
{
"uri": "orders://tenant/acme/order/1001",
"name": "order-1001",
"title": "订单 1001",
"description": "Acme 租户的订单 1001",
"mimeType": "application/json",
"size": 248,
"annotations": {
"audience": ["assistant"],
"priority": 0.8,
"lastModified": "2026-08-31T08:00:00Z"
}
}
],
"ttlMs": 300000,
"cacheScope": "private"
}
}
资源描述中的字段用途不同:
| 字段 | 作用 |
|---|---|
uri |
资源的唯一身份 |
name |
稳定的程序或协议名称 |
title |
面向用户界面的显示名称 |
description |
帮助客户端或用户理解资源 |
mimeType |
资源内容的媒体类型 |
size |
可选的大小提示,单位为字节 |
annotations |
面向客户端的使用提示 |
name 与 title 不应混为一谈。name 更适合作为稳定标识,title 可以随着产品界面语言或展示策略变化。
resources/list 支持分页。服务器可以返回:
{
"resultType": "complete",
"resources": [],
"nextCursor": "eyJwYWdlIjoyfQ=="
}
客户端必须继续使用 nextCursor 请求下一页,不能因为第一页为空就推断整个资源集合为空。
3.3 列表是“当前对该请求可见的集合”
资源列表不是服务器全局资源表的无条件导出。规范允许资源列表根据请求中携带的授权信息变化。例如:
管理员请求 -> 看到 /finance、/hr、/engineering
工程师请求 -> 看到 /engineering
访客请求 -> 看到公开资源
规范要求:服务器声明 Resource 能力后,必须响应 resources/list,返回当前对请求客户端可用的资源集合;这个集合可以为空,也可以随时间变化。集合可以因请求中携带的授权而不同,但不应因为同一连接上的其他请求产生隐式变化。(modelcontextprotocol.io)
这带来一个重要推论:
resources/list的缓存键不能只包含 Server 地址,还必须考虑授权上下文。
如果两个用户使用相同 MCP Server:
GET /mcp
Authorization: Bearer token-A
和:
GET /mcp
Authorization: Bearer token-B
服务器返回的资源列表不同,那么把第一个响应作为公共缓存返回给第二个用户,就是信息泄露。
因此,带有用户权限差异的列表响应通常应标记为:
{
"ttlMs": 300000,
"cacheScope": "private"
}
只有在内容与用户身份无关时,才适合使用公共缓存语义。2026-07-28 版本为列表和资源读取结果增加了 ttlMs 与 cacheScope,用于表达缓存有效时间和共享范围。(modelcontextprotocol.io)
四、Resource Template:用 URI 模板表达参数化资源
4.1 模板不是资源实例
固定 Resource:
orders://tenant/acme/order/1001
表示一个已经确定的资源实例。
Resource Template:
orders://tenant/{tenant}/order/{orderId}
表示一类可以通过参数生成的资源。
模板本身不是订单 1001,也不是订单列表。它描述的是:
给定 tenant 和 orderId
-> 生成 URI
-> 调用 resources/read
-> 获取对应订单
客户端通过 resources/templates/list 发现模板:
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/templates/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "wr-host",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
服务器返回:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"resourceTemplates": [
{
"uriTemplate": "orders://tenant/{tenant}/order/{orderId}",
"name": "Order",
"title": "订单",
"description": "根据租户和订单 ID 读取订单",
"mimeType": "application/json"
}
]
}
}
MCP 使用 URI Template 表达参数化资源,并允许通过 Completion API 为模板参数提供补全。模板列表同样支持分页和缓存。(modelcontextprotocol.io)
4.2 模板展开的形式化过程
设模板为:
T = orders://tenant/{tenant}/order/{orderId}
参数映射为:
V = {
tenant: "acme",
orderId: "1001"
}
模板展开函数可以写作:
expand(T, V)
= orders://tenant/acme/order/1001
但真实实现至少要满足三个条件:
模板展开成功
= 每个必需变量都有值
∧ 每个值符合变量的语法约束
∧ 展开后的 URI 通过资源权限校验
例如:
tenant = "acme"
orderId = "1001"
可以展开为:
orders://tenant/acme/order/1001
而下面的输入不能直接拼接:
tenant = "../other-tenant"
orderId = "1001/../../secret"
如果服务器把字符串直接拼成 URI:
orders://tenant/../other-tenant/order/1001/../../secret
就可能产生租户穿越或资源边界绕过。
因此,模板变量必须经过按字段定义的校验:
import re
from urllib.parse import quote
TENANT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
ORDER_ID_RE = re.compile(r"^[1-9][0-9]{0,19}$")
def build_order_uri(tenant: str, order_id: str) -> str:
if not TENANT_RE.fullmatch(tenant):
raise ValueError("invalid tenant")
if not ORDER_ID_RE.fullmatch(order_id):
raise ValueError("invalid order id")
return (
f"orders://tenant/{quote(tenant, safe='')}"
f"/order/{quote(order_id, safe='')}"
)
print(build_order_uri("acme", "1001"))
预期输出:
orders://tenant/acme/order/1001
这里的 quote 不能替代业务校验。URL 编码解决的是 URI 表示问题,不会自动保证租户名称合法,也不会判断当前用户是否属于该租户。
4.3 模板参数补全不是权限过滤
假设服务器为模板提供补全:
orders://tenant/{tenant}/order/{orderId}
客户端输入:
tenant = ac
服务器可以返回候选:
[
{"value": "acme", "label": "Acme"},
{"value": "acme-test", "label": "Acme Test"}
]
但补全结果不能仅根据字符串匹配生成。它必须基于当前授权上下文:
当前用户可访问租户 = {acme}
输入前缀 = "ac"
返回结果 = {acme}
如果服务器返回了用户无权访问的租户名,即使客户端后续读取时仍会拒绝,也可能泄露租户存在性。
所以:
模板补全属于信息访问路径,同样需要权限控制;它不是“无害的 UI 辅助功能”。
五、读取 Resource:resources/read 的数据流和返回模型
5.1 基本读取请求
客户端使用最终 URI 读取资源:
{
"jsonrpc": "2.0",
"id": 3,
"method": "resources/read",
"params": {
"uri": "orders://tenant/acme/order/1001",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "wr-host",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
服务器返回:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"resultType": "complete",
"contents": [
{
"uri": "orders://tenant/acme/order/1001",
"mimeType": "application/json",
"text": "{\"id\":1001,\"status\":\"paid\"}"
}
],
"ttlMs": 60000,
"cacheScope": "private"
}
}
读取流程应当是:
收到 URI
│
├──解析并规范化 URI
├──判断 Scheme 是否支持
├──判断资源是否存在
├──校验访问令牌和资源受众
├──执行资源级授权
├──读取后端数据
├──转换为 text 或 blob
└──返回 contents
其中,URI 解析和授权的顺序不能简单理解为“先查数据再判断权限”。通常应尽早拒绝未授权请求,避免通过响应时间、错误类型或资源大小暴露敏感信息。
5.2 一个响应可以包含多个内容块
服务器可以在一次 resources/read 中返回多个 Resource Contents。例如读取目录时,服务器可能返回多个文件:
{
"resultType": "complete",
"contents": [
{
"uri": "file:///project/README.md",
"mimeType": "text/markdown",
"text": "# Project"
},
{
"uri": "file:///project/package.json",
"mimeType": "application/json",
"text": "{\"name\":\"demo\"}"
}
]
}
这意味着客户端不能假定:
contents.length == 1
更稳妥的处理方式是:
for item in result["contents"]:
if "text" in item:
consume_text(item["uri"], item["mimeType"], item["text"])
elif "blob" in item:
consume_binary(item["uri"], item["mimeType"], item["blob"])
else:
raise ValueError("invalid resource content")
多个内容块也意味着 URI 语义需要清楚:每个内容块都应提供自己的 uri,客户端不能仅根据外层请求 URI 猜测每个内容块的身份。
六、内容类型:text 和 blob 不是同一种数据
6.1 文本内容
文本内容使用:
{
"uri": "file:///example.txt",
"mimeType": "text/plain",
"text": "Resource content"
}
适合文本的 MIME 类型包括:
text/plain
text/markdown
text/html
application/json
application/xml
text/csv
application/json 虽然不是 text/*,但内容仍然可以作为 Unicode 文本返回。MIME 类型描述的是内容格式,不是 MCP 是否使用 text 字段的唯一判断条件。实际客户端应根据协议结构和 MIME 类型共同处理。
例如:
{
"uri": "orders://tenant/acme/order/1001",
"mimeType": "application/json",
"text": "{\"id\":1001,\"status\":\"paid\"}"
}
这里:
mimeType表示内容遵循 JSON 格式;text表示内容以文本形式传输;- 客户端可以解析 JSON,也可以把原始文本交给模型。
6.2 二进制内容
二进制内容使用 Base64 编码:
{
"uri": "file:///project/logo.png",
"mimeType": "image/png",
"blob": "iVBORw0KGgoAAAANSUhEUgAA..."
}
blob 不是任意字符串,而是经过 Base64 表示的二进制数据。服务器不能直接把原始二进制塞进 JSON 字符串。
解码过程:
import base64
encoded = "SGVsbG8="
raw = base64.b64decode(encoded)
assert raw == b"Hello"
预期结果:
raw = b"Hello"
二进制传输有三个工程风险:
- Base64 会增加传输体积;
- 客户端可能把图片、PDF 等内容错误地作为普通文本送入模型;
- 未限制大小时,恶意资源可能消耗大量内存和上下文窗口。
因此客户端通常需要根据 MIME 类型选择处理器:
text/markdown -> Markdown 解析或文本展示
application/json -> JSON 解析
image/png -> 图片处理器
application/pdf -> PDF 解析器
application/octet-stream -> 默认二进制或拒绝
但 MIME 类型只能作为提示,不能自动等同于可信事实。服务器可能错误标注,甚至恶意伪造 MIME 类型。对于需要安全解析的格式,应使用实际内容检测、大小限制和沙箱。
MCP Resource Content 支持文本和二进制两种表示;资源定义还可以提供 MIME 类型、大小以及面向客户端的注解。(modelcontextprotocol.io)
七、Annotations:使用提示,不是访问控制
Resource、Resource Template 和内容块都可以带有 annotations:
{
"uri": "file:///project/README.md",
"name": "README.md",
"mimeType": "text/markdown",
"annotations": {
"audience": ["user"],
"priority": 0.8,
"lastModified": "2026-08-31T08:00:00Z"
}
}
几个字段的语义是:
audience:内容面向user、assistant或两者;priority:客户端选择上下文时可参考的重要性,范围通常为0.0到1.0;lastModified:资源的最后修改时间提示。
例如:
"audience": ["assistant"],
"priority": 0.9
可以帮助 Host 判断该资源更适合作为模型上下文,而不是直接展示给用户。
但是:
audience != authorization
priority != confidentiality
lastModified != integrity proof
一个资源标记为:
"audience": ["user"]
不表示只有用户可以读取;一个资源标记为:
"priority": 0.1
也不表示它不敏感。
Annotations 是客户端使用提示,不是安全策略。MCP 规范将其定义为帮助客户端过滤、排序和展示资源的提示信息。(modelcontextprotocol.io)
八、订阅:通知资源发生变化,而不是推送完整内容
8.1 订阅解决什么问题
如果客户端每隔一分钟执行:
resources/read(uri)
来判断资源是否变化,会产生轮询开销,而且无法及时感知变化。
资源订阅将流程变为:
客户端订阅 URI
│
├──服务器保存或转发订阅关系
├──资源发生变化
├──服务器发送 resources/updated
└──客户端重新执行 resources/read
订阅通知通常只表达:
这个 URI 发生了变化,请你重新读取。
它不是完整的新内容,也不应被理解为增量补丁。
8.2 2026-07-28 的 subscriptions/listen
在 2026-07-28 协议版本中,客户端通过 subscriptions/listen 打开长连接通知流,并在过滤器中声明希望接收的资源更新通知:
{
"jsonrpc": "2.0",
"id": "listen-1",
"method": "subscriptions/listen",
"params": {
"notifications": {
"resourceSubscriptions": [
"orders://tenant/acme/order/1001"
]
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "wr-host",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
服务器不会因为客户端声明支持订阅,就自动发送所有资源变化;客户端必须显式打开订阅流,并指定资源 URI。服务器还应根据自身能力和权限返回实际接受的订阅集合。
资源变化时,服务器发送:
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": "listen-1"
},
"uri": "orders://tenant/acme/order/1001"
}
}
客户端收到通知后:
async def on_resource_updated(uri: str):
invalidate_cache(uri)
fresh = await resources_read(uri)
update_context(uri, fresh)
这里的正确顺序是:
收到 updated
-> 使本地缓存失效
-> 重新读取
-> 校验新内容
-> 决定是否重新注入模型上下文
不能把通知本身当作新资源内容,也不能只更新缓存时间而不重新读取。
8.3 订阅是水平触发,不是事件日志
资源更新通知通常是水平触发语义:
updated(uri)
只说明“现在这个资源值得重新读取”,不保证:
- 每次修改都有一条通知;
- 通知包含修改前后的版本;
- 断线期间的通知可以重放;
- 通知严格按照后端写入顺序到达;
- 重复通知不会发生。
因此,客户端不能依赖通知数量推导修改次数:
收到 1 次通知 ≠ 资源只修改了 1 次
收到 0 次通知 ≠ 资源一定没有修改
一些官方 SDK 将订阅事件明确作为“发生变化后重新获取”的信号,并指出订阅没有通用的重放语义;客户端重连后需要重新建立订阅,并重新读取其依赖的资源。(py.sdk.modelcontextprotocol.io)
8.4 订阅状态机
一个客户端订阅可以抽象为:
stateDiagram-v2
[*] --> Closed
Closed --> Opening: subscriptions/listen
Opening --> Active: acknowledged
Opening --> Failed: error / timeout
Active --> Active: resources/updated
Active --> Closing: client close / cancel
Active --> GracefulClosed: server empty result
Active --> Lost: transport drop
Closing --> Closed
GracefulClosed --> Closed
Lost --> Opening: re-listen
Failed --> Closed
关键状态的含义:
Opening:请求已经发出,但服务器尚未确认实际接受哪些通知;Active:订阅生效;GracefulClosed:服务器有意关闭订阅;Lost:连接异常中断;Closed:客户端不再接收该订阅的事件。
订阅确认很重要,因为客户端请求的资源集合不一定全部被服务器接受。例如:
客户端请求:
[order/1001, order/1002, order/1003]
服务器允许:
[order/1001, order/1003]
服务器拒绝:
order/1002 无权限或不支持订阅
客户端应该以服务器确认的结果为准,而不是假设请求全部成功。
8.5 旧版本兼容
旧版 MCP 使用过基于初始化会话和 resources/subscribe 的机制。2026-07-28 使用 subscriptions/listen 替代旧的资源订阅方式;兼容实现可能同时支持新旧两套机制,但客户端必须依据实际协议版本选择调用方式。官方 SDK 的迁移文档明确指出,resources/subscribe 属于 2025 时代的接口,在 2026-07-28 连接上应通过 subscriptions/listen 的 resourceSubscriptions 过滤器请求资源更新。(ts.sdk.modelcontextprotocol.io)
错误的兼容策略是:
无论协议版本是什么,都调用 resources/subscribe
可能表现为:
Method not found
订阅请求被忽略
客户端一直等待通知
服务器和客户端对通知模型理解不一致
正确策略是:
协议版本 = 2026-07-28
-> subscriptions/listen
协议版本 = 2025 或更早
-> 使用该版本定义的订阅机制
九、列表变化和资源内容变化是两种不同通知
MCP 中至少要区分两类变化:
9.1 Resource 列表变化
例如:
新增一个文档
删除一个项目文件
某个模板被注册或注销
这改变的是:
resources/list
对应的通知是:
{
"jsonrpc": "2.0",
"method": "notifications/resources/list_changed"
}
客户端收到后应重新执行:
resources/list
resources/templates/list
具体重新请求哪些列表,取决于本地缓存和服务器能力。
9.2 某个 Resource 内容变化
例如:
orders://tenant/acme/order/1001
这个 URI 仍然存在,但订单状态从:
{"status": "paid"}
变为:
{"status": "shipped"}
这不会改变资源列表,只会改变资源内容。对应的通知是:
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"uri": "orders://tenant/acme/order/1001"
}
}
二者不能混用:
list_changed -> 重新获取资源目录
updated(uri) -> 重新获取指定 URI
如果服务器每次订单更新都发送 list_changed,客户端只能重新扫描整个资源列表,既浪费性能,也混淆了协议语义。
十、访问控制:Resource 的权限必须落到读取动作
10.1 能力声明不是授权
下面的能力声明:
{
"capabilities": {
"resources": {
"subscribe": true
}
}
}
只表示服务器实现了订阅功能,不表示当前客户端可以订阅所有资源。
访问控制至少需要覆盖:
发现资源
读取资源
订阅资源
接收资源变化通知
读取模板参数候选
尤其不能只保护 resources/read,却无条件暴露:
resources/list
resources/templates/list
completion/complete
notifications/resources/updated
即使资源内容不会泄露,资源名称、URI、租户名、文件名和更新时间也可能构成敏感信息。
10.2 HTTP 传输中的授权
MCP 的 HTTP 授权建立在 OAuth 资源服务器模型之上。HTTP MCP Server 可以作为 OAuth Resource Server,客户端携带 Access Token 请求资源。规范要求 HTTP 请求使用:
Authorization: Bearer <access-token>
而不能把访问令牌放进 URI 查询字符串。服务器必须验证令牌,并确认令牌确实是为当前 MCP Server 颁发的。(modelcontextprotocol.io)
一个合法的 HTTP 请求形态是:
POST /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: resources/read
Mcp-Name: orders://tenant/acme/order/1001
令牌校验至少要考虑:
签名或令牌有效性
过期时间
issuer
audience/resource
scope
用户或主体身份
“令牌是有效 JWT”不等于“可以读取当前资源”。如果令牌是发给另一个 MCP Server 的,当前服务器必须拒绝,而不能因为签名正确就接受。
10.3 STDIO 传输的边界
对于 STDIO 传输,服务器通常从启动环境、操作系统用户身份或宿主进程配置中取得凭证,而不是照搬 HTTP OAuth 流程。MCP 授权规范将 HTTP 授权作为重点,并建议 STDIO 实现通过环境获取凭证。(modelcontextprotocol.io)
这不意味着 STDIO 没有权限问题。相反,STDIO 的主要边界转移到了:
谁启动了 Server 进程
Server 进程拥有哪些文件权限
Host 是否允许该 Server 访问指定目录
环境变量中是否包含过大的凭证权限
例如,一个本地文件 Server 如果以用户身份运行,就不应因为客户端请求:
file:///etc/shadow
而突破操作系统权限。服务器仍然必须进行路径范围校验,Host 也应限制 Server 的可访问根目录。
10.4 Scope 与资源级权限不是一回事
OAuth Scope 通常表达较粗粒度的授权:
files:read
files:write
orders:read
orders:write
但真正的 Resource 权限通常还需要资源级判断:
scope = orders:read
user = alice
resource = orders://tenant/acme/order/1001
最终允许读取的条件可能是:
允许
= token 包含 orders:read
∧ token 主体属于 acme 租户
∧ 订单 1001 对该主体可见
因此,不能写成:
if "orders:read" in token.scopes:
return order
更完整的伪代码应是:
def read_order(token, tenant, order_id):
require_valid_token(token)
require_audience(token, "https://mcp.example.com")
require_scope(token, "orders:read")
require_tenant_membership(token.subject, tenant)
order = repository.get_order(tenant, order_id)
if order is None:
raise ResourceNotFound()
if not policy.can_read_order(token.subject, order):
raise Forbidden()
return order
Scope 解决“这类操作是否可能被允许”,资源级策略解决“当前主体能否访问这个具体对象”。
10.5 401、403 和资源不存在
HTTP 授权错误应区分:
401 Unauthorized:没有有效身份或令牌失效;403 Forbidden:身份有效,但权限或 Scope 不足;400 Bad Request:授权请求格式错误。
当令牌存在但 Scope 不足时,服务器可以返回:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="orders:read",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
客户端可以据此执行 step-up authorization,申请更大的权限集合,再有限次数重试原请求。规范还要求客户端在重新授权时保留此前已经申请的 Scope,避免新的令牌丢失旧权限。(modelcontextprotocol.io)
对于不存在的 Resource,MCP 资源规范要求服务器返回 JSON-RPC 错误,而不能返回:
{
"result": {
"contents": []
}
}
空数组有歧义:它既可能表示资源存在但内容为空,也可能表示资源不存在。当前规范要求资源不存在时使用 -32602,并建议客户端兼容旧版本曾使用的 -32002。(modelcontextprotocol.io)
一个明确的错误响应是:
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32602,
"message": "Resource not found",
"data": {
"uri": "orders://tenant/acme/order/9999"
}
}
}
但是“不存在”和“无权访问”是否向客户端暴露为不同错误,需要结合信息泄露风险设计。如果资源名称本身敏感,可以对未授权资源使用统一的外部错误表现,避免攻击者通过错误差异枚举资源存在性。
十一、文件 Resource 的安全边界:根目录、路径穿越和符号链接
最常见的文件资源模板是:
file:///{path}
这是一个高风险模板,因为 {path} 直接影响服务器读取的文件位置。
危险实现:
def read_file(path: str) -> str:
with open("/workspace/" + path, "r") as f:
return f.read()
攻击者可以请求:
../../etc/passwd
得到:
/workspace/../../etc/passwd
如果进程有权限,就可能读取工作区之外的文件。
一个更完整的实现应当:
- 将配置的根目录解析为绝对路径;
- 将用户路径解析为候选路径;
- 对候选路径执行
resolve; - 检查候选路径是否仍位于根目录之下;
- 根据策略决定是否允许符号链接;
- 再执行读取。
示例:
from pathlib import Path
ROOT = Path("/workspace").resolve()
def safe_path(relative_path: str) -> Path:
candidate = (ROOT / relative_path).resolve()
try:
candidate.relative_to(ROOT)
except ValueError:
raise PermissionError("path escapes resource root")
if not candidate.is_file():
raise FileNotFoundError(str(candidate))
return candidate
print(safe_path("src/main.py"))
对于:
src/main.py
预期结果:
/workspace/src/main.py
对于:
../../etc/passwd
预期结果:
PermissionError: path escapes resource root
但 resolve() 还会处理符号链接。假如:
/workspace/public/link -> /etc
请求:
public/link/passwd
解析后可能落到:
/etc/passwd
是否允许这种访问必须有明确策略:
严格模式:拒绝任何解析后位于根目录外的路径
宽松模式:允许部分符号链接,但逐个校验目标
生产系统通常应选择严格模式。MCP 规范特别要求提供 file:// 资源时清理路径,防止目录遍历。(modelcontextprotocol.io)
十二、缓存、版本和并发:通知不能替代一致性设计
12.1 缓存必须绑定 URI 和权限上下文
一个 Resource 缓存键至少应包含:
(protocol server identity,
canonical URI,
authorization subject or cache scope,
representation parameters)
例如:
(
"https://mcp.example.com",
"orders://tenant/acme/order/1001",
"user:alice",
"application/json"
)
不能只使用:
cache["order/1001"]
否则不同用户、不同租户或不同视图可能互相污染。
12.2 ttlMs 不是数据版本号
如果服务器返回:
{
"ttlMs": 60000
}
它表达的是缓存可以保持新鲜的时间提示,不代表资源在 60 秒内绝对不会变化,也不代表客户端可以跳过权限检查。
同样:
"lastModified": "2026-08-31T08:00:00Z"
是资源注解,不是强一致的版本校验令牌。
需要强一致时,应在 Resource 内容中携带业务版本:
{
"id": 1001,
"version": 42,
"status": "shipped"
}
或者由服务器定义 URI 查询参数:
orders://tenant/acme/order/1001?version=42
但后者必须明确 URI 身份是否包含 Query。不要让客户端自行猜测。
12.3 通知和读取之间存在竞态
考虑以下时序:
t1 资源版本从 41 变为 42
t2 服务器发送 updated(uri)
t3 资源版本从 42 变为 43
t4 客户端执行 resources/read
t5 客户端读到版本 43
客户端没有读到版本 42 并不表示通知丢失。通知的语义是“重新读取”,不是“读取某个特定历史版本”。
反过来:
t1 客户端收到 updated(uri)
t2 客户端清理缓存
t3 服务器读取后端暂时失败
客户端应保留旧缓存或进入 stale 状态,而不是把失败结果当成资源为空。
可以采用:
Fresh
-> 收到更新通知
Stale
-> 重新读取成功 -> Fresh
-> 读取失败 -> Stale + retry/backoff
如果资源对业务决策很敏感,模型不应继续使用未经重新验证的旧内容。
十三、一个端到端示例:订单 Resource Server
下面用一个简化的 Python 服务器逻辑说明固定 Resource、模板 Resource、读取和授权之间的关系。它不是某个 SDK 的完整启动程序,而是可直接运行的核心处理示例。
from dataclasses import dataclass
from typing import Any
import json
import re
@dataclass
class Principal:
user_id: str
scopes: set[str]
tenants: set[str]
ORDERS = {
("acme", "1001"): {
"id": 1001,
"tenant": "acme",
"status": "paid"
}
}
TENANT_RE = re.compile(r"^[a-z0-9-]{1,63}$")
ORDER_ID_RE = re.compile(r"^[1-9][0-9]{0,19}$")
def parse_order_uri(uri: str) -> tuple[str, str]:
prefix = "orders://tenant/"
if not uri.startswith(prefix):
raise ValueError("unsupported URI scheme")
rest = uri[len(prefix):]
parts = rest.split("/")
if len(parts) != 3 or parts[1] != "order":
raise ValueError("invalid order URI")
tenant, _, order_id = parts
if not TENANT_RE.fullmatch(tenant):
raise ValueError("invalid tenant")
if not ORDER_ID_RE.fullmatch(order_id):
raise ValueError("invalid order id")
return tenant, order_id
def read_resource(uri: str, principal: Principal) -> dict[str, Any]:
tenant, order_id = parse_order_uri(uri)
if "orders:read" not in principal.scopes:
raise PermissionError("insufficient scope")
if tenant not in principal.tenants:
raise PermissionError("tenant access denied")
order = ORDERS.get((tenant, order_id))
if order is None:
raise LookupError("resource not found")
return {
"uri": uri,
"mimeType": "application/json",
"text": json.dumps(order, separators=(",", ":"))
}
principal = Principal(
user_id="alice",
scopes={"orders:read"},
tenants={"acme"}
)
item = read_resource(
"orders://tenant/acme/order/1001",
principal
)
print(json.dumps(item, ensure_ascii=False, indent=2))
预期输出:
{
"uri": "orders://tenant/acme/order/1001",
"mimeType": "application/json",
"text": "{\"id\":1001,\"tenant\":\"acme\",\"status\":\"paid\"}"
}
每一步的作用是:
parse_order_uri限制可接受的 URI 结构;- 正则表达式限制租户和订单 ID 的字符集合;
orders:read控制操作类型;principal.tenants控制资源所属租户;ORDERS.get判断资源是否存在;- 最后才把业务对象编码为 JSON 文本。
反例是只做字符串前缀判断:
if uri.startswith("orders://"):
return database.query(uri)
这段代码没有验证:
- URI 是否符合业务结构;
- 租户是否属于当前用户;
- 订单是否存在;
- 请求是否具备正确 Scope;
- 数据库查询是否会被注入;
- 返回数据是否需要脱敏。
十四、故障路径与诊断方法
14.1 resources/list 为空
可能原因:
Server 没有声明 resources 能力
当前凭证没有可见资源
分页游标使用错误
资源注册尚未完成
缓存使用了错误的授权上下文
诊断顺序:
1. 调用 server/discover,确认 resources 能力
2. 检查 HTTP Authorization 或 STDIO 身份
3. 记录 resources/list 的请求身份摘要
4. 检查响应是否带 nextCursor
5. 禁用客户端缓存重新请求
6. 对比管理员和普通用户的可见集合
不要直接把“列表为空”解释为“服务器没有资源”。
14.2 resources/read 返回 -32602
常见原因:
URI 不存在
URI 结构错误
模板变量展开错误
URI 规范化结果与注册 URI 不一致
服务器使用了错误的资源错误码
应记录:
原始 URI
规范化后的 URI
协议版本
请求 ID
资源查找结果
授权判断结果
但日志中不要直接记录访问令牌和敏感资源正文。
14.3 能读到资源,但模型上下文没有变化
可能原因:
客户端只收到通知,没有重新读取
缓存没有失效
读取后没有更新上下文
MIME 类型处理失败
订阅监听流已断开
正确的诊断链是:
资源是否发生变化
-> Server 是否发布 updated
-> listen 流是否处于 Active
-> 客户端是否收到 uri
-> 客户端是否删除缓存
-> resources/read 是否成功
-> Host 是否重新构造模型上下文
14.4 断线后收不到通知
subscriptions/listen 不是永久可靠的消息队列。异常断线后,客户端需要:
检测流关闭
-> 标记订阅 Lost
-> 重新打开 listen
-> 重新读取关键 Resource
-> 恢复正常状态
仅仅重连 MCP Client 而不重新建立订阅,会导致后续资源更新无法传递。
同时,重连后必须重新做权限判断。订阅关系不能被视为永久授权,因为用户身份、Scope、租户成员关系和资源权限都可能变化。
十五、规范保证、实现选择和工程建议
规范保证
在 2026-07-28 Resource 规范下,关键保证包括:
- Resource 通过 URI 唯一标识;
- Server 支持 Resource 时声明
resources能力; resources/list用于发现固定资源;resources/templates/list用于发现参数化资源;resources/read用于读取内容;- 内容可以是文本或 Base64 二进制;
- 资源不存在时不能用空
contents数组伪装; - Server 必须校验 URI;
- 文件资源必须防止目录遍历;
- 资源列表和读取结果可以提供缓存提示;
- 资源列表变化与具体资源更新是不同通知语义。(modelcontextprotocol.io)
常见实现
不同 SDK 可能提供:
registerResource(...)
registerResourceTemplate(...)
readResource(...)
listen(...)
onResourceUpdated(...)
但这些函数名、参数类型和异步模型属于 SDK API,不是 MCP wire protocol 的通用名称。官方 SDK 页面列出了 TypeScript、Python、C#、Go、Rust 等不同语言 SDK,并说明各 SDK 都支持创建暴露 Resource、Tool、Prompt 的服务器以及连接 MCP Server 的客户端。(modelcontextprotocol.io)
因此,跨语言设计时应先确认:
协议方法和 JSON 结构
-> 再映射到具体 SDK API
不要把某一个 SDK 的注册函数名当成协议标准。
工程建议
工程上通常应额外实现:
URI canonicalization
资源大小限制
MIME 类型白名单
文本编码校验
二进制解码限制
资源级授权
缓存隔离
订阅重连
更新后的重新读取
审计日志
敏感资源脱敏
这些并不都由 MCP 协议自动完成。MCP 规定了消息、能力和数据结构,但 Host、Client 和 Server 仍然要自行建立同意、授权、隔离和数据保护流程。协议安全原则强调,用户数据在暴露给 Server 前需要得到明确同意,并且敏感资源应配备适当的访问控制。(modelcontextprotocol.io)
十六、最后用一个模型理解 MCP Resources
可以把 MCP Resource 归纳为五层:
第一层:身份
URI 唯一标识“我要读取谁”
第二层:发现
resources/list 和 resources/templates/list 告诉客户端“有哪些可读对象”
第三层:读取
resources/read 把身份解析为文本或二进制内容
第四层:变化
list_changed 表示目录变化
resources/updated 表示具体 URI 内容变化
第五层:安全
授权决定当前主体能否发现、读取和订阅该资源
其中最容易出现的三个错误是:
把 URI 当成 URL
把订阅通知当成完整数据
把能力声明或 annotations 当成授权
更准确的关系是:
URI 是身份,
模板是生成身份的规则,
Resource Read 是读取动作,
MIME 类型描述表示形式,
订阅只提示需要重新读取,
访问控制最终决定读取是否允许。
只有把这几层分开,MCP Resource 才能从“给模型塞一段上下文”的临时接口,演化为可发现、可缓存、可更新、可授权的互操作资源系统。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:MCP Tools:发现、Schema、调用、结果、错误和安全边界
- 下一篇:MCP Prompts:参数、消息模板、发现、版本和信任边界
- 延伸:MCP 架构深解:Host、Client、Server、能力协商和生命周期
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论