MCP 协议深度解析系列写到第七篇了,前面把 Tools 和 Prompts 聊透之后,这次终于轮到 Resources 资源系统。我自己的感受是,Resources 在三层能力里关注度最低,但实际做 Agent 应用时它是绕不开的——你的大模型要回答问题,总得先拿到数据;数据从哪来、怎么定位、怎么感知变化,就是 Resources 这套机制要解决的事。这篇文章会把 URI、订阅、内容管理这三块从头到尾拆一遍,包括协议层的交互细节、我在服务端实现时踩过的坑,以及对接 Dify、Claude、Cursor 这类客户端时的常见问题。
如果你正在写 MCP server,或者想把自家的文档库、数据库、监控面板接到 Agent 里当上下文,这篇文章应该能让你少走不少弯路。就算你只是用现成 MCP 服务的用户,搞清楚 Resources 的工作原理,也能帮你更快定位“为什么 Agent 总说找不到数据”这类问题。
1. 先搞清楚:Resources 在 MCP 协议里的角色定位
1.1 为什么聊完 Tools 和 Prompts 之后,才轮到 Resources
MCP(Model Context Protocol)给服务端定义了三种能力:Tools、Resources、Prompts。从优先级上说,Tools 是大多数开发者最先接触的,因为你让 Agent 去“做事”时,调用的都是工具;Prompts 则是给模型提供 Prompt 模板化能力,把一些固定的指令编排存到服务端。Resources 排到第三种,不是因为不重要,而是它的作用更像“地基”——模型在执行任务前需要先了解背景信息,这些信息大多通过 Resources 暴露。
举个我实际遇到的场景。之前我做一个内部运维数据聚合的 MCP server,Tools 里注册了很多操作类接口,比如执行部署、拉取日志、查询服务器状态。但如果模型不知道当前有哪些服务器、每个服务器的业务归属是什么,它连“该调用哪个工具、传什么参数”都判断不了。这个“元信息”本来可以写死在 Prompt 里,可一旦服务器列表经常变动,写死就是自找麻烦。用 Resources 来暴露这些数据,Agent 就能在需要时动态读取,不用把数据塞进超大上下文里。
所以你可以把 Resources 理解为 MCP 里的“只读上下文数据源”。它不产生副作用,不执行业务操作,职责非常专一:向上层模型提供可读取的结构化或非结构化数据。
1.2 Resources、Tools、Prompts 三者如何分工
很多新手会把 Resources 和 Tools 搞混,觉得“读数据不也是一个操作吗,为什么不能做成 Tool”。这里面的分界线其实很清楚:
| 能力 | 是否允许副作用 | 典型用途 | 触发方式 |
|---|---|---|---|
| Tools | 通常有副作用 | 执行操作:下单、部署、发消息、改配置 | 由模型根据任务主动决定调用 |
| Resources | 严格只读 | 提供数据:查询结果、文档内容、配置信息 | 模型读取,或作为上下文注入 |
| Prompts | 无副作用 | 模板化指令:生成代码前的要求、报告写作框架 | 用户或模型按需加载 |
理论上,一个“读取服务器列表”的功能用 Tools 也能实现,模型调一个 list_servers 工具就拿到数据了。但这么做有几个问题:第一,Tools 的设计意图是动作,会让模型在“该读数据”和“该执行操作”之间失去判断力;第二,许多客户端会在系统启动时通过 resources/list 预加载资源清单,把资源写成 Tool 就破坏了这种机制;第三,涉及敏感数据的只读访问,用 Resources 更容易在权限模型上做区分。
简单说:要执行动作的走 Tools,要读取上下文的走 Resources,要预设行为模板的走 Prompts,三者各司其职。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. URI:MCP 资源系统的寻址基础
2.1 MCP 里的 URI 到底长什么样
Resources 之所以叫“资源系统”,核心就在“资源”的定位方式上。MCP 选用了 URI(统一资源标识符)作为定位标准,而不是自己发明一套新的寻址规则。这一点我觉得是协议设计上非常聪明的地方——URI 是互联网行业几十年的通用规范,客户端和服务端都能快速理解。
一个标准的 MCP URI 包含几个部分:
code复制scheme://authority/path?query#fragment
比如 file:///home/user/docs/report.pdf,scheme 是 file,authority 为空,path 是 /home/user/docs/report.pdf。再比如 postgres://database/orders/2025/01,scheme 是 postgres,后面跟着数据库路径和表名。MCP 规范并没有限制 URI 的 scheme 必须是 http、file 这些标准协议,服务端完全可以自定义 scheme,比如 knowledge://project/123/doc/456、sensor://factory/line-1/temperature。
我在实现时最常用的是自定义 scheme,因为标准 scheme 的表达能力有限。比如我想暴露“某个项目下的所有 Issues”,用 issue://repo/owner/issue/123 这种 URI 就比 https://... 简洁得多,模型读起来也更容易理解层级关系。
2.2 资源模板的设计与使用场景
如果你的资源是在服务启动前就能确定集合的,那直接用静态 URI 列表就行。但现实中更多情况是:资源存在,但数量很大,或者路径中带有动态参数。这时候就轮到 Resources 模板(Resource Templates)上场。
MCP 的模板写法遵循 URI Template 规范,用花括号表达变量。比如:
code复制file://{host}/home/{username}/docs/{docId}
sensor://factory/line-{lineId}/temperature
客户端的 resources/templates/list 拿到这些模板后,会通过实例化模板来构造具体资源的 URI,然后再用 resources/read 读取内容。举个例子,我之前做过一个代码评审助手,它需要读取指定仓库的指定文件,模板就定义成 repo://{owner}/{repoName}/file/{branch}/{filePath}。这样模型只要填入 owner=myteam、repoName=api-server、branch=main、filePath=utils/helper.ts,就能精准定位到目标文件。
这里要注意一个细节:模板里的变量要尽量少,并且每个变量都要有清晰的含义。如果模板里塞了五六个变量,模型在大模型推理时构造 URI 很容易出错,经常填错变量名或漏掉某个参数。宁可拆分成多个模板,也不要把变量堆到一个模板里。
2.3 自定义 URI 时要注意的规矩
自定义 scheme 虽然自由度高,但也不是没有约束。我遇到过的坑主要有这几个:
第一,大小写敏感问题。URI 的 path 部分在多数服务端实现里区分大小写,File:///a 和 file:///a 可能被当成两个不同的东西。为了降低出错概率,建议 scheme 统一小写,path 里的参数名统一用 camelCase 或 snake_case,别混着来。
第二,中文和特殊字符需要 URL 编码。资源路径里如果包含中文文件名、空格、# 号,直接用原始字符构造 URI,客户端解析时基本会出问题。我在服务端实现时强制规范:所有非 ASCII 字符和保留字符必须 percent-encode,在返回资源的 uri 字段时就用编码后的值,这样客户端拿到后直接能用,不用再做二次处理。
第三,不要在里面塞敏感信息。URI 很可能会被日志记录、被客户端缓存,如果你把数据库连接串、API Key 这种信息编码进 URI,等于把密钥写在了日志里。这个我在早期一个项目上吃过亏:当时图省事,把带密码的 PostgreSQL 连接串拼到了资源 URI 里,排查问题时日志一打,密码直接暴露了。后来改成资源 URI 只放标识符,认证信息全部在服务端配置。
3. 资源列表与内容读取的实现细节
3.1 客户端怎么向服务端要资源清单
MCP 协议里有两个与资源列表相关的方法:resources/list 和 resources/templates/list。前者返回静态资源的完整清单,后者返回可动态实例化的模板列表。客户端通常在连接建立后,会主动调用这两个接口,把服务端“有什么资源”先记住。
resources/list 的响应数据里,每个资源至少包含这几个字段:
| 字段 | 是否必填 | 说明 |
|---|---|---|
| uri | 必填 | 资源的唯一标识 |
| name | 必填 | 资源名称,用于展示 |
| description | 可选 | 描述资源用途,方便模型理解 |
| mimeType | 可选 | 内容类型,如 text/plain、application/json |
| size | 可选 | 内容大小(字节),帮助客户端预估数据量 |
这里我特别想强调 mimeType 字段的重要性。我在早期实现时很多资源不填 mimeType,结果客户端拿到资源后不知道该按纯文本解析还是按 JSON 解析,导致读取结果乱七八糟。后来我养成了习惯:能明确内容类型的资源,一定把 mimeType 带上;拿不准的,至少给个 text/plain 或 application/octet-stream 兜底。
另外,如果资源数量非常庞大,目录列表可能很大,传输效率就成了问题。MCP 协议没有强制要求分页,但部分 SDK 支持 cursor 参数做游标分页。如果你的服务端资源超过几百个,建议主动实现分页,否则一次返回几千条记录,客户端直接卡死。
3.2 资源内容类型的选择:text 还是 blob
客户端读取具体资源时,调用 resources/read,传入目标资源的 uri。协议规定返回内容分为两种:文本类型和二进制类型。
文本类型适合文档、配置、JSON 数据,服务端直接把内容以字符串返回即可;二进制类型则用 base64 编码返回,适合图片、音频、PDF 这类不能直接用文本表达的内容。
我在实际项目中,文本类型大概占了八成以上,因为大多数给模型当上下文的数据都是文本。但是也有特殊场景,比如我接的一个多模态项目,需要把建筑图纸截图发给模型识别,这就必须走二进制通道。
这里有一个值得注意的点:许多大模型客户端对二进制的支持并不完整。我试过在某个客户端里读取一张 PNG 图片资源,协议上它虽然声明支持 blob,但实际交互时模型根本无法直接“看”到这张图的内容。这种情况下,我建议服务端额外提供一个文本摘要资源,或者把图片转成 base64 但再用文本资源包装一层说明,让模型至少知道“这里有一张图,内容是建筑平面图,用户可以点击查看”。否则,资源对模型来说就是“读得到、用不上”。
3.3 资源读取的性能与缓存策略
如果每次模型需要数据,服务端都去底层数据库查一遍,效率会很差。我见过的 MCP server 实现里,最常见的性能问题就出在这里。
我自己的做法是分级缓存:第一级是内存缓存,短时间重复请求相同的 URI 直接返回结果;第二级是变更触发失效——底层数据发生变化时,由服务端主动清掉对应 URI 的缓存,而不是等 TTL 过期。这个思路和 HTTP Cache 很像,但 MCP 的订阅机制正好可以用来驱动缓存失效,所以实现起来并不难。
缓存粒度方面,我建议按 URI 做细粒度缓存,不要整表缓存。比如一个资源服务管理 100 个文档,如果只缓存一个 list 结果,任何文档更新都会导致 list 缓存失效,读到其他文档时也会走冷路径。按 URI 缓存,更新只影响对应那一条,命中率明显更高。
还有一点要提醒:不要在 resources/read 里做耗时操作。有些开发者会在读取时动态去拉取远程数据、执行耗时计算,这在协议层面没问题,但会拖慢整个 Agent 的响应链路。耗时数据建议用异步任务提前生成好,资源读取只负责把现成结果返回,这样模型侧的等待时间才能控制在可接受范围。
4. 订阅机制:从轮询到主动通知
4.1 为什么需要订阅机制
先澄清一个容易混淆的概念:MCP 的“订阅”和 ChatGPT 订阅、Midjourney 订阅完全是两码事。MCP 里的订阅是客户端向服务端登记“我想知道某个资源什么时候发生变化”的意图,服务端在资源更新时发送通知消息,客户端收到通知后再决定要不要重新读取内容。
在订阅机制出现之前,客户端只能靠定时轮询来感知资源变化。轮询有两个短板:一是实时性差,轮询间隔设置得再短也有延迟窗口;二是在资源长期不变的情况下浪费大量请求。订阅机制把“主动询问”改成了“被动通知”,数据一变化,服务端立刻推送消息,实时性和效率都更好。
打个比方,轮询就像你每隔五分钟去楼下看一次快递柜,订阅则是快递员到货后给你打个电话。前者虽然也能拿到货,但明显是后者更省心。
4.2 订阅的完整链路
订阅机制的完整链路分四个步骤:
第一步,客户端发送 resources/subscribe 请求,参数里带上要订阅的资源 URI。服务端收到请求后,校验该资源是否存在、客户端是否有权限订阅,校验通过后返回成功。
第二步,服务端监听资源变化。当底层数据源发生更新时,服务端通过 notifications/message 方法向客户端发送一条通知。这里有一个容易被误解的点:通知消息本身不包含新的资源内容,它只是告诉客户端“这个资源变了,你想看新内容就自己来读”。
第三步,客户端收到通知后,根据通知中给出的资源 URI,调用 resources/read 重新拉取最新内容。
第四步,当客户端不再需要跟踪某个资源时,发送 resources/unsubscribe 取消订阅,服务端停止向该客户端发送此资源的变更通知。
我在实现服务端时,对第四步特别在意。因为部分客户端在断开连接时并不会优雅地发送 unsubscribe,如果服务端不做清理,会积累大量失效订阅。我的做法是给每个客户端连接建立独立的订阅表,连接断开时统一清理,这样即使客户端忘了取消订阅,服务端也能自动回收资源。
4.3 订阅生命周期与边界情况处理
实际开发中,订阅机制会遇到不少边界情况,我挑几个典型的说一下。
第一,订阅通知频繁导致风暴。如果某个资源每分钟变好几次,服务端就每分钟给客户端发通知,客户端也会频繁重新读取。这种情况我一般做节流处理:短时间内同一个 URI 的多次变更合并成一次通知,或者设置最小通知间隔。否则,一个高频更新数据源会把整个会话的消息队列塞满。
第二,客户端收到通知后读取失败。资源在通知发出到客户端读取之间又被删除了,这是完全可能的情况。所以我实现客户端逻辑时,收到通知后的 resources/read 即使返回错误,也不应该影响会话继续,最多记录日志,尝试回退到缓存数据。
第三,订阅与权限的联动。不是所有客户端都有权限订阅所有资源,服务端在做资源权限控制时,应该把订阅接口也纳入统一的鉴权体系。我在生产环境遇到过一种 case:某个资源列表对模型可见,但具体内容只有特定角色能读。如果订阅接口不做同样校验,客户端订阅成功后,每次内容变更都收到通知,但重新读取时又被拒,用户体验会很奇怪。
第四,长连接的保活。MCP 默认基于 JSON-RPC 传输,如果是走 Streamable HTTP 或 WebSocket,连接可能因为网络问题断开。我的建议是客户端在订阅关键资源后,要监视连接状态,连接重建后要重新订阅那些之前订阅过的资源。不处理的话,服务端重启后客户端会变成“假订阅”状态,永远接收不到变更通知。
5. 实操:从零实现一个带资源管理的 MCP Server
5.1 用 Python SDK 快速搭一个文件资源服务
理论聊完了,落实到代码。我用 Python 的 mcp SDK 实现过一个文件资源服务,逻辑不复杂,但把 Resources 的静态资源、模板、订阅都涵盖了。
python复制from mcp.server.fastmcp import FastMCP
import os
import glob
mcp = FastMCP("FileResourceServer")
# 静态资源:暴露一个说明文档
@mcp.resource("file:///meta/README.md")
def read_readme() -> str:
return "# File Resource Server\n\nThis server exposes local files as MCP resources."
# 模板资源:按文件路径读取任意 md 文件
@mcp.resource("file:///docs/{filepath}")
def read_doc(filepath: str) -> str:
# 注意:这里要防路径穿越,不能直接用 filepath 拼路径
safe_path = os.path.normpath(os.path.join(BASE_DIR, filepath))
if not safe_path.startswith(BASE_DIR):
raise ValueError("invalid path")
with open(safe_path, "r", encoding="utf-8") as f:
return f.read()
# 动态返回资源列表
@mcp.list_resources()
def list_files() -> list:
result = []
for md_file in glob.glob(os.path.join(BASE_DIR, "**/*.md"), recursive=True):
rel_path = os.path.relpath(md_file, BASE_DIR)
result.append({
"uri": f"file:///docs/{rel_path}",
"name": os.path.basename(md_file),
"mimeType": "text/markdown",
"description": f"Markdown doc: {rel_path}"
})
result.append({
"uri": "file:///meta/README.md",
"name": "README",
"mimeType": "text/markdown"
})
return result
if __name__ == "__main__":
mcp.run(transport="stdio")
这段代码里最核心的是 @mcp.resource 装饰器。FastMCP 会自动把它注册成资源的读取入口,@mcp.list_resources 则负责返回资源清单。跑起来之后,客户端连上这个 server,就能通过 resources/list 看到各份文档,再通过 resources/read 读取具体内容。
5.2 订阅能力在 SDK 里的落地方式
上面这个例子没有写订阅逻辑,因为 FastMCP 已经帮我们处理了一部分。比如你用 mcp.server.fastmcp 创建资源,它默认会在资源内容发生变化时帮你发送变更通知。但如果你的资源是动态生成的,比如每次读取都实时从数据库查,那 SDK 无法感知变化,你得自己主动发通知。
我后来把文件资源服务改成“监听文件修改自动触发变更通知”的版本,核心逻辑是这样的:用 watchdog 库监听文件目录,文件变更时找到对应的 URI,再通过服务器实例的 send_resource_updated 方法向所有订阅方发送通知。客户端收到通知后,重新调用 resources/read 就能拿到新内容。
python复制from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class DocHandler(FileSystemEventHandler):
def on_modified(self, event):
if event.is_directory:
return
if event.src_path.endswith(".md"):
uri = f"file:///docs/{os.path.relpath(event.src_path, BASE_DIR)}"
# 向所有订阅了该 uri 的客户端发通知
mcp.server.send_resource_updated(uri)
observer = Observer()
observer.schedule(DocHandler(), BASE_DIR, recursive=True)
observer.start()
这里要注意一个 SDK 层面的细节:send_resource_updated 只是发送通知,它不会自动更新订阅关系。如果客户端此前根本没有订阅这个 URI,这个通知发出去也没人接收。所以服务端最好维护一份“已订阅 URI 集合”,只对真正有订阅者的 URI 发送通知,避免无效消息。
5.3 对接 Dify、Claude、Cursor 等客户端时的注意事项
服务端写好之后,对接客户端经常会遇到一些奇怪问题。根据我的经验,重点注意这几类:
| 客户端 | 常见现象 | 排查方向 |
|---|---|---|
| Dify | 工具列表里能看到自定义工具,但资源内容读不出来 | 检查 Dify 的 MCP 插件是否走的是 Tools 通道,部分版本不支持 Resources 的自动读取 |
| Claude Desktop | 资源列表能看到,但模型回答时总说“没有找到相关信息” | Claude Desktop 需要模型侧主动调用 read 工具,可在允许的工具列表确认 read 权限 |
| Cursor | MCP server 连接成功后,Agent 不读取任何资源 | 确认是否需要在 .mcp.json 里额外声明资源权限,或通过 Tools 二次封装 |
| Codex CLI | 资源服务没问题但工具注册不上 | 这是 Tools 没注册成功的问题,和 Resources 无关,先看服务端声明了哪些能力 |
我个人最常用的是 Dify。Dify 对 MCP 的接入相对完整,但它在 Agent 节点里更多把 MCP server 的内容当成工具来用,资源读取能力在部分版本上支持得不是很好。如果你发现 Dify 里调不到资源内容,有个取巧的办法:在 MCP server 里把“读取资源”再封装成一个 Tool,比如 read_document_by_uri(uri),这样 Dify 就能通过工具调用拿到同样的数据。虽然违背了 Resources 的设计初衷,但兼容性更好。
6. 常见问题与排查技巧实录
6.1 资源注册上了但客户端读不到内容
这是我在社区被问得最多的一类问题。现象是:客户端能看到资源清单,但调用 resources/read 时返回空或报错。
根据我的排查经验,按优先级检查这几项:
- URI 模板匹配是否精确。有些 SDK 对 URI 模板的匹配是精确匹配,
file:///docs/{filepath}只能匹配file:///docs/xxx,但客户端传的 URI 是file:///docs/xxx?raw=1,带了 query 参数就匹配不上了。方案是读取实现里做好容错,忽略 query 或单独解析。 - mimeType 是否被客户端正确识别。如果资源声明为
application/octet-stream,客户端可能不会按文本渲染,表现在界面上就是“读取成功但看着空”。 - 路径穿越被服务端拦截。很多服务端会做路径安全校验,如果 URI 里的路径包含
../、~等特殊符号,直接返回 400。可以把服务端日志打开,看看具体报错信息。
6.2 URI“无效”报错集中在哪几类
热词里出现 irm 无效的 URI 指定的端口无效、unable to load summary from remote flathub 这类摘要,虽然不完全对应 MCP,但透露了一个共性问题:URI 解析失败的报错往往不在服务端,而在传输链路的中间层。
MCP 场景里我遇到的 URI 无效情况有四种:
| 报错形式 | 原因 | 解决办法 |
|---|---|---|
uri scheme not supported |
客户端白名单限制 scheme | 改用服务端配置的允许 scheme 列表 |
invalid port |
URI 里写了非法端口号 | 自定义 URI 不要带端口,或使用合法端口范围 |
path not found |
URI 路径对应的资源不存在 | 检查模板变量是否填充正确、资源是否已注册 |
encoding error |
中文或特殊字符未编码 | URI 统一走 percent-encode |
我印象最深的是某次测试时,服务端明明注册了 issue://myorg/repo/123 这个资源,客户端却一直报 invalid port。后来看协议解析源码发现,它把 myorg 当成了 authority,把 repo 当成了端口号。这就是自定义 URI 容易被误读的典型例子。我的建议是,尽量避开 authority:port 这种可能被误解析的结构,自定义 URI 直接用 scheme:///path 三段式,不要写 authority,能少踩很多雷。
6.3 订阅失效的排查思路
订阅机制出问题,不像读取资源那样立刻暴露,更多是“静默失效”——客户端以为还在订阅,但永远收不到通知。我的排查顺序是:
- 先确认服务端是否真的发起了通知。在服务端日志里搜
resources/subscribe和notifications/message,如果没有收到通知,说明订阅关系可能没建立成功。 - 再确认传输层。如果是 stdio 传输,通知是通过标准输出发出去的,如果服务端同时往 stdout 打了业务日志,会污染 MCP 协议消息,导致客户端解析失败。这个坑特别隐蔽,我建议 MCP server 里所有业务日志统一走 stderr。
- 最后看资源变化是否触发通知。很多服务端实现者只在数据库触发时发通知,但某些资源是缓存的,缓存过期但底层数据没变,也会导致通知根本不会发。
6.4 一个实用的兜底方案
不管订阅机制写得多完善,我仍然建议在客户端侧保留一层兜底轮询。我自己在对接第三方客户端时,不会完全依赖订阅通知,而是设置一个合理的刷新间隔,定期重读关键资源。
这个兜底方案不是否定订阅机制,而是考虑到现实场景:服务端可能崩溃重启、连接可能断开、客户端的订阅状态可能丢失。订阅负责“实时性”,轮询负责“可靠性”,两手抓才不会出现数据长时间不更新的尴尬情况。
具体实现上,我通常是每 30 到 60 秒重读一次核心资源,频率不用太高。因为资源更新通常不是毫秒级需求,30 秒的延迟对大多数 Agent 场景完全够用。如果资源更新很快且延迟敏感,再考虑把轮询间隔缩短到 5 秒以内,同时注意控制服务端压力。
最后再分享一个小技巧:调试 MCP 资源系统时,不要先接大客户端,直接用 MCP Inspector 或简单的命令行客户端测一遍 resources/list、resources/templates/list、resources/read 三个方法,确认链路通了再上 Dify、Claude。这样能大大缩短问题定位时间——我每次新写一个 MCP server 都会这么做,省下的调试时间非常可观。
