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 接收到它后,可以:

  1. 解析 Scheme:orders
  2. 解析租户:acme
  3. 解析资源类型:order
  4. 解析主键:1001
  5. 校验当前用户是否有权限读取;
  6. 查询订单;
  7. 返回 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 面向客户端的使用提示

nametitle 不应混为一谈。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 版本为列表和资源读取结果增加了 ttlMscacheScope,用于表达缓存有效时间和共享范围。(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 猜测每个内容块的身份。


六、内容类型:textblob 不是同一种数据

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"

二进制传输有三个工程风险:

  1. Base64 会增加传输体积;
  2. 客户端可能把图片、PDF 等内容错误地作为普通文本送入模型;
  3. 未限制大小时,恶意资源可能消耗大量内存和上下文窗口。

因此客户端通常需要根据 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:内容面向 userassistant 或两者;
  • priority:客户端选择上下文时可参考的重要性,范围通常为 0.01.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/listenresourceSubscriptions 过滤器请求资源更新。(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

如果进程有权限,就可能读取工作区之外的文件。

一个更完整的实现应当:

  1. 将配置的根目录解析为绝对路径;
  2. 将用户路径解析为候选路径;
  3. 对候选路径执行 resolve
  4. 检查候选路径是否仍位于根目录之下;
  5. 根据策略决定是否允许符号链接;
  6. 再执行读取。

示例:

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\"}"
}

每一步的作用是:

  1. parse_order_uri 限制可接受的 URI 结构;
  2. 正则表达式限制租户和订单 ID 的字符集合;
  3. orders:read 控制操作类型;
  4. principal.tenants 控制资源所属租户;
  5. ORDERS.get 判断资源是否存在;
  6. 最后才把业务对象编码为 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、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。