MCP 协议设计与实现
第6章 Resource:结构化的上下文注入
大语言模型的能力边界,本质上取决于它能"看到"什么。模型的推理再强大,如果上下文窗口中没有相关的代码文件、数据库 schema 或 API 文档,它也只能凭空臆测。MCP 的 Resource 机制正是为了解决这个问题而设计的——它提供了一种标准化的方式,让服务端向客户端暴露结构化的上下文数据,使 LLM 在正确的信息基础上进行推理。
但 Resource 不仅仅是"读取数据"这么简单。它背后蕴含着一个关键的设计哲学:由谁来决定模型应该看到什么? 在 MCP 的世界观中,这个决定权不在模型手中,而在应用程序和用户手中。这一设计选择,将 Resource 与 Tool 从根本上区分开来,也奠定了 MCP 在安全性和可控性上的基调。
本章将从协议规范出发,逐层深入到 TypeScript 和 Python SDK 的实现细节,完整剖析 Resource 的设计哲学、协议消息、URI 体系、发现机制、订阅模型以及工程实践模式。
本章要点
- Resource 的本质:应用程序控制的上下文数据,而非模型驱动的操作
- 与 Tool 的核心区别:Resource 由应用/用户选择,Tool 由模型选择——这不是细节差异,而是安全模型的基石
- URI 寻址体系:静态 URI 与 URI Template 的双层设计,支持从固定资源到参数化资源的完整光谱
- 发现与订阅:listResources / resourceTemplates / subscribe 三位一体的资源生命周期管理
- SDK 实现模式:TypeScript 的 registerResource 与 Python 的 @resource 装饰器,两种风格殊途同归
- 数据类型:text 与 blob 的二元内容模型,MIME type 驱动的类型识别
6.1 Resource 是什么:重新理解"上下文"
6.1.1 从信息不对称说起
在传统的 API 设计中,客户端调用服务端获取数据是一件稀松平常的事。但在 LLM 应用的语境下,"获取数据"有了全新的含义——这些数据不是给人看的,而是要注入到模型的上下文窗口中,直接影响模型的推理过程。
MCP 规范对 Resource 的定义开门见山:
Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information.
这里的关键词是 context(上下文)。Resource 不是通用的数据获取接口,它的存在目的非常明确——为 LLM 提供推理所需的背景信息。一个文件的内容、一张数据库表的 schema、一段 git 历史记录,这些都是典型的 Resource。
6.1.2 应用程序控制 vs 模型控制
Resource 最核心的设计特征,在于它的交互模型是 application-driven(应用程序驱动的)。规范中明确指出:
Resources in MCP are designed to be application-driven, with host applications determining how to incorporate context based on their needs.
这意味着什么?意味着是应用程序(而非模型)决定哪些 Resource 应该被读取并注入到上下文中。应用程序可以:
- 在 UI 中以树形或列表视图提供资源选择器,让用户显式地挑选要注入的上下文
- 让用户搜索、过滤可用资源
- 基于启发式规则或 AI 模型的选择,自动把资源纳入上下文
规范同时说明,协议本身不强制任何特定的用户交互模式。
这与 Tool 形成了鲜明的对比。Tool 是模型驱动的——模型在推理过程中自主决定调用哪个工具、传入什么参数。而 Resource 用不用、用哪些,由 Host 应用决定(即便交给模型挑选,也是 Host 的策略)。这种划分有明确的安全含义,我们将在 6.8 节详细讨论。
flowchart TB
subgraph Resource["Resource(应用控制)"]
direction TB
User["用户/应用程序"] -->|"选择资源"| App["Host 应用"]
App -->|"resources/read"| Server1["MCP Server"]
Server1 -->|"返回内容"| App
App -->|"注入上下文"| LLM1["LLM"]
end
subgraph Tool["Tool(模型控制)"]
direction TB
LLM2["LLM"] -->|"决定调用"| Client["MCP Client"]
Client -->|"tools/call"| Server2["MCP Server"]
Server2 -->|"执行结果"| Client
Client -->|"返回结果"| LLM2
end
style Resource fill:#e8f5e9,stroke:#2e7d32
style Tool fill:#e3f2fd,stroke:#1565c0
这张图清晰地展示了两者的控制流差异。在 Resource 模式中,控制流从用户/应用程序出发;在 Tool 模式中,控制流从 LLM 出发。这个差异决定了两者在安全模型、权限管理和使用场景上的根本不同。
6.2 Resource 的数据类型:text 与 blob
Resource 的内容只有两种形态:文本(text)和二进制(blob)。这种简洁的二元设计覆盖了几乎所有的实际场景。
6.2.1 文本内容
文本内容是最常见的 Resource 类型。源代码文件、配置文件、数据库查询结果、API 文档——这些都以纯文本的形式返回:
{
"uri": "file:///project/src/main.rs",
"mimeType": "text/x-rust",
"text": "fn main() {\n println!(\"Hello world!\");\n}"
}
mimeType 字段告诉客户端如何解释这段文本。text/plain 是纯文本,text/x-rust 是 Rust 代码,application/json 是 JSON 数据。客户端可以根据 MIME 类型决定是否进行语法高亮、格式化或其他处理。
6.2.2 二进制内容
对于图片、PDF、编译产物等非文本资源,Resource 使用 base64 编码的 blob 字段:
{
"uri": "file:///project/logo.png",
"mimeType": "image/png",
"blob": "iVBORw0KGgoAAAANSUhEUgAA..."
}
blob 内容必须经过 base64 编码——JSON 本身不支持二进制,base64 是在 JSON 里携带二进制数据最通用的编码方式。
这两种形态在 schema 里分别是 TextResourceContents(text 字段)和 BlobResourceContents(blob 字段),都继承 ResourceContents 的 uri、可选的 mimeType 与 _meta;schema 注明 text 只应在内容确实能表示为文本时使用(schema/2025-11-25/schema.ts:881-919)。
6.2.3 一次读取返回多个内容
resources/read 的响应中,contents 是一个数组,意味着单次读取可以返回多个内容块。这在实践中非常有用——例如读取一个目录时,服务端可以一次返回目录下所有文件的内容:
{
"contents": [
{ "uri": "file:///project/src/main.rs", "mimeType": "text/x-rust", "text": "..." },
{ "uri": "file:///project/src/lib.rs", "mimeType": "text/x-rust", "text": "..." }
]
}
6.3 URI 寻址体系:标识与模板
每个 Resource 都通过 URI 唯一标识。这不是随意的选择——URI 是互联网上最成熟的资源标识机制,MCP 直接复用了 RFC 3986 的语义。
6.3.1 标准 URI Scheme
MCP 规范定义了几种常见的 URI scheme:
| Scheme | 用途 | 示例 |
|---|---|---|
file:// |
文件系统资源 | file:///project/src/main.rs |
https:// |
Web 资源(客户端可直接获取) | https://api.example.com/schema |
git:// |
Git 版本控制资源 | git://repo/commit/abc123 |
| 自定义 scheme | 应用特定资源 | postgres://db/users/schema |
这里有一个重要的细节:https:// scheme 仅应在客户端能够自行获取资源时使用。如果资源需要服务端代为获取(即使服务端内部通过 HTTP 获取),应该使用自定义 scheme。这个约定避免了客户端与服务端之间的职责混淆。
自定义 URI scheme 必须符合 RFC 3986 规范,但协议对 scheme 名称没有强制限制。这意味着你可以设计 postgres://、redis://、jira:// 等任意 scheme 来标识你的领域资源。
6.3.2 URI Template:参数化的资源定位
静态 URI 只能标识固定的资源。但很多场景下,资源是动态的——用户想查看特定城市的天气、特定提交的代码差异、特定表的 schema。URI Template(RFC 6570)为此提供了解决方案:
{
"uriTemplate": "weather://{city}/current",
"name": "Current Weather",
"description": "Get current weather for a city"
}
URI Template 使用 {variable} 语法定义参数占位符。客户端在读取资源时,将参数替换为实际值,得到一个具体的 URI(如 weather://beijing/current),然后通过 resources/read 请求该 URI。
flowchart LR
Template["URI Template\nweather://{city}/current"] -->|"city = beijing"| URI1["weather://beijing/current"]
Template -->|"city = shanghai"| URI2["weather://shanghai/current"]
Template -->|"city = tokyo"| URI3["weather://tokyo/current"]
URI1 --> Read1["resources/read"]
URI2 --> Read2["resources/read"]
URI3 --> Read3["resources/read"]
style Template fill:#fff3e0,stroke:#e65100
URI Template 的参数还支持自动补全。客户端可以通过 MCP 的 completion API 获取参数的候选值,为用户提供智能提示。这让 Resource 的使用体验接近于搜索引擎的自动建议。
6.4 资源发现:listResources 与 resourceTemplates
客户端如何知道服务端提供了哪些资源?MCP 通过两个独立的发现端点来解决这个问题。
6.4.1 listResources:枚举静态资源
resources/list 请求返回服务端当前可用的所有静态资源列表。该操作支持分页,适用于资源数量较大的场景:
// 请求
{ "method": "resources/list", "params": { "cursor": "optional-cursor" } }
// 响应
{
"resources": [
{
"uri": "file:///project/src/main.rs",
"name": "main.rs",
"title": "Rust Application Main File",
"description": "Primary application entry point",
"mimeType": "text/x-rust"
}
],
"nextCursor": "next-page-cursor"
}
每个资源描述包含以下字段:
- uri:资源的唯一标识符,是后续读取操作的关键
- name:资源名称,用于程序化处理
- title:可选的人类可读标题,用于 UI 展示
- description:可选的资源描述
- mimeType:可选的 MIME 类型,指示内容格式
- size:可选的资源大小(字节,指 base64 编码或分词之前的原始内容),帮助客户端预估上下文消耗
- annotations:可选的元数据注解,包括受众(audience)、优先级(priority)和最后修改时间(lastModified)
- icons:可选的图标数组,供客户端在界面上展示
这些字段不是一次到位的(逐版对照 schema/<版本>/schema.ts 的 Resource 定义,见 schema/2025-11-25/schema.ts:802-838):uri、name、description、mimeType、size 和 annotations 里的 audience、priority 在 2024-11-05 首版就有;title 与 annotations.lastModified 在 2025-06-18 加入;icons 在 2025-11-25 加入。
annotations 字段值得特别关注。audience 可以设为 ["user"]、["assistant"] 或 ["user", "assistant"],告诉客户端这个资源的目标受众是谁。priority 是 0.0 到 1.0 的数,1 表示"最重要"(实际上必须包含),0 表示"最不重要"(完全可选)。lastModified 是 ISO 8601 格式的时间戳(如 "2025-01-12T15:00:58Z")。按规范,资源、资源模板和内容块都可以带这组注解;客户端可以利用它们按受众过滤、决定哪些资源值得注入上下文窗口、按修改时间排序。
6.4.2 resourceTemplates:发现动态资源
resources/templates/list 请求返回服务端提供的所有 URI 模板(它和 resources/list 一样是可分页请求):
// 请求
{ "method": "resources/templates/list" }
// 响应
{
"resourceTemplates": [
{
"uriTemplate": "file:///{path}",
"name": "Project Files",
"description": "Access files in the project directory",
"mimeType": "application/octet-stream"
}
]
}
两个发现端点的分离设计是有意为之的。静态资源可以直接枚举和展示,而模板资源代表的是一个无限的资源空间——你无法枚举所有可能的 {path} 值。将两者分开,让客户端可以用不同的 UI 模式来处理:静态资源展示为列表或树形结构,模板资源展示为搜索框或输入表单。
6.4.3 Capability 声明
服务端在初始化阶段通过 capability 声明自己支持 Resource 功能:
{
"capabilities": {
"resources": {
"subscribe": true,
"listChanged": true
}
}
}
两个子能力都是可选的:
- subscribe:是否支持客户端订阅单个资源的变更通知
- listChanged:是否会在资源列表变化时发送通知
6.5 订阅与实时更新
Resource 不是一次性的快照——在很多场景下,资源的内容会随时间变化。一个正在被编辑的文件、一个不断更新的数据库表、一个实时变化的监控指标,都需要通知机制来让客户端保持数据的新鲜度。
6.5.1 资源列表变更通知
当服务端的可用资源列表发生变化(新增资源、移除资源)时,声明了 listChanged 能力的服务端应该发送通知:
{
"method": "notifications/resources/list_changed"
}
客户端收到此通知后,应重新调用 resources/list 获取最新的资源列表。
6.5.2 单资源订阅
对于特定资源的内容变更,MCP 提供了细粒度的订阅机制:
// 订阅
{ "method": "resources/subscribe", "params": { "uri": "file:///project/config.yaml" } }
// 服务端确认订阅后,当资源内容变化时发送通知
{ "method": "notifications/resources/updated", "params": { "uri": "file:///project/config.yaml" } }
订阅的前提是服务端在 capability 里声明了 subscribe: true。通知里的 uri 也不一定就是订阅时那个 URI——schema 注明它可能是所订阅资源的一个子资源。
注意:更新通知只告诉客户端"这个资源变了",但不包含新内容。客户端需要自行决定是否重新读取——这是一个推(push)+拉(pull)的混合模式。通知是推送的,但内容获取仍然是拉取的。这种设计避免了不必要的数据传输:客户端可能在收到通知后决定暂时不更新(比如用户已经切换到了其他上下文)。
sequenceDiagram
participant App as Host 应用
participant Client as MCP Client
participant Server as MCP Server
Client->>Server: resources/list
Server-->>Client: 资源列表
Client->>App: 展示可用资源
App->>Client: 用户选择资源
Client->>Server: resources/read (config.yaml)
Server-->>Client: 文件内容
Client->>Server: resources/subscribe (config.yaml)
Server-->>Client: 订阅确认
Note over Server: config.yaml 被修改
Server--)Client: notifications/resources/updated
Client->>App: 通知资源已更新
App->>Client: 决定重新读取
Client->>Server: resources/read (config.yaml)
Server-->>Client: 更新后的内容
Client->>Server: resources/unsubscribe (config.yaml)
Server-->>Client: 取消订阅确认
6.5.3 取消订阅
当客户端不再需要某个资源的变更通知时,可以发送 resources/unsubscribe 请求:
{ "method": "resources/unsubscribe", "params": { "uri": "file:///project/config.yaml" } }
良好的客户端实现应该在不再关注某个资源时及时取消订阅,避免服务端做不必要的变更检测和通知推送。
一个容易踩空的实现细节:TypeScript SDK 的高层 McpServer 并没有内置 resources/subscribe / resources/unsubscribe 的处理器——setResourceRequestHandlers() 只注册了 resources/list、resources/templates/list、resources/read 三个,并且 registerCapabilities 里只声明 resources.listChanged,不声明 subscribe。协议类型是齐的(packages/core/src/types/schemas.ts 里有 SubscribeRequestSchema / UnsubscribeRequestSchema / ResourceUpdatedNotificationSchema),但订阅语义要由你自己在底层 Server 上 setRequestHandler('resources/subscribe', …) 实现,并手动把 subscribe: true 加进 capabilities。实跑 pin 版:McpServer 声明的能力是 {"resources":{"listChanged":true}},Client 照发 resources/subscribe 会收到 -32601(Method not found)。Python SDK 的高层 MCPServer 同样没有接订阅:它构造底层 Server 时只传了 list/read/templates 三个资源回调(src/mcp/server/mcpserver/server.py:180-182),声明出的能力是 subscribe=False;要订阅得用底层 Server 的 on_subscribe_resource 参数(src/mcp/server/lowlevel/server.py:142)。
2026-07-28 版规范去掉了 resources/subscribe / resources/unsubscribe 这对请求:客户端改为发 subscriptions/listen,在 notifications.resourceSubscriptions 里列出要关注的 URI,notifications/resources/updated 从这条流上送达(见该版 changelog.mdx 与 server/resources.mdx,SEP-2575)。本专栏仍按 2025-11-25 讲。
6.6 SDK 实现:TypeScript 与 Python 的注册模式
理解了协议层面的设计后,让我们深入到两个官方 SDK 的实现,看看开发者在实践中如何注册和使用 Resource。
6.6.1 TypeScript SDK:registerResource
TypeScript SDK 的 McpServer 类通过 registerResource 方法注册资源。该方法支持两种重载签名——静态资源和模板资源:
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
const server = new McpServer({ name: 'example-server', version: '1.0.0' });
// 静态资源注册
server.registerResource(
'config', // name
'config://app', // URI 字符串
{
title: 'Application Config',
mimeType: 'text/plain'
},
async (uri) => ({
contents: [{
uri: uri.href,
text: 'App configuration here'
}]
})
);
// 模板资源注册
server.registerResource(
'user-profile',
new ResourceTemplate('users://{userId}/profile', { list: undefined }),
{ title: 'User Profile', mimeType: 'application/json' },
async (uri, { userId }) => ({
contents: [{
uri: uri.href,
text: JSON.stringify({ id: userId, name: 'Example User' })
}]
})
);
静态资源传入 URI 字符串,模板资源传入 ResourceTemplate 对象。两者的回调函数签名略有不同:静态资源回调是 (uri, ctx),模板资源回调是 (uri, variables, ctx),多出模板变量的键值对(packages/server/src/server/mcp.ts:1174、1198-1202)。ResourceTemplate 构造时的 list 必须显式给出,哪怕是 undefined——源码注释说这是为了避免忘记实现资源枚举;给了 list 回调,它枚举出的具体资源会并入 resources/list 的结果。以上两段在 pin 版上实跑通过。
注册完成后,registerResource 返回一个 RegisteredResource 对象,提供了动态管理的能力:
const resource = server.registerResource('config', 'config://app', {}, callback);
// 动态禁用(从列表中移除,但不删除注册)
resource.disable();
// 重新启用
resource.enable();
// 完全移除注册
resource.remove();
// 动态更新资源属性
resource.update({
name: 'new-config',
metadata: { title: 'Updated Config' }
});
这个设计让资源的生命周期管理变得灵活——服务端可以根据运行时状态动态调整可用资源,而不需要重启或重新初始化。注册本身以及每次调用 disable()、enable()、remove() 或 update(),在已连接时都会自动发出 notifications/resources/list_changed(packages/server/src/server/mcp.ts:603、619、650、683、982-986),客户端据此重新拉取列表。注意被禁用的静态资源不仅从列表里消失,直接 resources/read 也会得到 -32602(Resource … disabled)。
6.6.2 Python SDK:@resource 装饰器
Python SDK 采用了更 Pythonic 的装饰器风格。MCPServer(注意 SDK 里类名是三个大写字母开头的 MCPServer,不是 McpServer)的 resource() 方法返回一个装饰器,用于将普通函数注册为资源处理器:
import json
from mcp.server.mcpserver import MCPServer
server = MCPServer("example-server")
# 静态资源
@server.resource("config://app", name="config", title="Application Config")
def get_config() -> str:
return "App configuration here"
# 模板资源——URI 中包含 {variable} 时自动识别为模板
@server.resource("weather://{city}/current", name="weather")
def get_weather(city: str) -> str:
return f"Weather for {city}: sunny, 25°C"
# 异步资源
@server.resource("db://users/schema")
async def get_user_schema() -> str:
schema = await fetch_db_schema("users") # 示意:换成你自己的查询函数
return json.dumps(schema)
Python SDK 这个注册流程里藏着几处需要拎出来看的细节。直接读 src/mcp/server/mcpserver/server.py:627 的 resource 装饰器实现:
"是模板还是静态资源"——双条件判定。 装饰器从函数签名和 URI 字面同时读取信号(src/mcp/server/mcpserver/server.py:691-694):
# src/mcp/server/mcpserver/server.py:691-694(节选)
has_uri_params = "{" in uri and "}" in uri
has_func_params = bool(sig.parameters)
if has_uri_params or has_func_params:
# 注册为模板
任意一个条件成立就走模板路径。注意 sig.parameters 连 Context 参数也算在内。这意味着即使 URI 写 config://app(字面常量),只要被装饰的函数带参数,也会进模板分支:带的是非 Context 参数,这时 URI 变量集是空集、函数参数集非空,下一步的变量匹配检查会报错;只带一个 Context 参数,校验能通过,但注册出来的是一个零变量模板——它的 matches() 返回空字典 {},而 ResourceManager.get_resource() 用 if params := template.matches(uri_str) 判断(src/mcp/server/mcpserver/resources/resource_manager.py:92),空字典为假,于是这个资源永远读不到,实跑返回 Unknown resource。这条约束非常严格,它强迫"URI 变量 ≡ 函数参数"成为硬契约,避免了"函数参数没有出现在 URI 里、客户端就没法传值"这种隐蔽 bug。
变量集的精确相等检查(src/mcp/server/mcpserver/server.py:699-707,节选,省略注释):
uri_params = set(re.findall(r"{(\w+)}", uri))
func_params = {p for p in sig.parameters.keys() if p != context_param}
if uri_params != func_params:
raise ValueError(
f"Mismatch between URI parameters {uri_params} and function parameters {func_params}"
)
集合不等——少一个多一个、名字错一个字母——都会在注册时(而非运行时)立即抛错。错误消息直接把两个集合打印出来方便诊断。注意 context_param 被从 func_params 里剔除——这是 Context 注入机制对资源注册的唯一让步。
装饰器缺括号保护(src/mcp/server/mcpserver/server.py:682)跟 @prompt() 同款:
if callable(uri):
raise TypeError("The @resource decorator was used incorrectly. ...")
函数返回值的三态转换发生在底层 FunctionResource.read()(src/mcp/server/mcpserver/resources/types.py:57),规约写在 resource 装饰器的 docstring 里(src/mcp/server/mcpserver/server.py:642-645);再把结果装进文本或二进制内容结构的,是 src/mcp/server/mcpserver/server.py:340-365 处的 _handle_read_resource:
- 返回
str:作为文本内容,写进TextResourceContents.text - 返回
bytes:作为二进制内容,base64 编码后写进BlobResourceContents.blob - 返回其他类型:用
pydantic_core.to_json序列化为 JSON 字符串(按文本处理)
没传 mime_type 时,资源的 mimeType 一律是基类默认的 text/plain(src/mcp/server/mcpserver/resources/base.py:26)——返回 bytes 也不例外,实跑得到的是 mimeType: "text/plain" 的 blob,所以二进制资源记得显式传 mime_type。
6.6.3 模板匹配的真实算法:一个 regex 和它的三个边界
读完了注册路径,来看一次真实的 resources/read 请求是如何落到具体函数参数的。核心算法在 src/mcp/server/mcpserver/resources/templates.py:87:
# src/mcp/server/mcpserver/resources/templates.py:87(节选,省略 docstring 与注释)
def matches(self, uri: str) -> dict[str, Any] | None:
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
match = re.match(f"^{pattern}$", uri)
if match:
return {key: unquote(value) for key, value in match.groupdict().items()}
return None
一行替换 + 一次 re.match + 一步 URL 解码。看似朴素,三个边界却决定了模板在实战中的行为:
边界 1:变量值里不能有斜杠。 {city} 被展开为 (?P<city>[^/]+)——[^/]+ 明确排除斜杠。这导致 weather://san/francisco/current 不会 匹配 weather://{city}/current——虽然用户直觉上可能以为 city=san/francisco。要么把 URI 改造成 weather://{region}/{city}/current,要么让客户端提前对斜杠做 %2F 编码(下一条边界会自动帮你解码)。这个 [^/]+ 的选择是在 "URL path 语义" 和"表达力"之间取的默认——符合大多数 REST 风格 URI 的直觉。
边界 2:值会自动 URL 解码。 unquote(value) 在每个参数返回前执行。客户端传 weather://%E5%8C%97%E4%BA%AC/current 时函数拿到的是 city="北京",不是 "%E5%8C%97%E4%BA%AC"。这意味着客户端应该 percent-encode 含特殊字符的变量值——包括 Unicode、空格、/、? 等 URI 保留字符。TS SDK 在这一点上行为不同:UriTemplate.match() 取出变量值后不做解码(packages/core/src/shared/uriTemplate.ts:255-289),实跑读 users://a%2Fb/profile,回调拿到的 userId 就是字面的 "a%2Fb",需要的话自己 decodeURIComponent。
边界 3:精确 ^...$ 锚定。 re.match 只匹配开头,但这里显式加了 ^ 和 $——既防前缀也防后缀污染。weather://beijing/currentXX 不会匹配 weather://{city}/current——即使 [^/]+ 能吃字符、XX 在模式之外就被 $ 拒掉。
一次成功 read 的全路径(src/mcp/server/mcpserver/resources/resource_manager.py 入口 → templates.py 匹配 → FunctionResource.read):
- 收到
resources/read { uri: "weather://beijing/current" } - ResourceManager 先查静态资源表,没有命中再遍历已注册的
ResourceTemplate,对每个调template.matches(uri) - 匹配成功:得到
{"city": "beijing"} - 调
template.create_resource(uri, params, context)——注入 Context、异步/同步统一包装、运行用户函数、拿到"Weather for beijing: sunny..." - 把结果包进
FunctionResource(fn=lambda: result)(src/mcp/server/mcpserver/resources/templates.py:121-131),再由read()取出这份已算好的结果
第 5 步的 lambda 只是为了让模板结果套上和静态资源相同的 Resource 接口:这个 FunctionResource 是本次请求临时创建的,ResourceManager 并不缓存它(resource_manager.py:91-94 每次都重新 create_resource)。所以每一次 resources/read 都会重新调用一次你的函数——实跑连读两次 weather://beijing/current,函数执行了两次。需要缓存就得自己做(如 §6.7.4)。另外,模板函数里抛出的异常会在 create_resource 里被包成 ValueError,最终被报告为 Unknown resource,而不是读取错误。
6.6.4 两种风格的对比
| 维度 | TypeScript SDK | Python SDK |
|---|---|---|
| 注册方式 | registerResource() 方法调用 |
@resource() 装饰器 |
| 模板识别 | 显式传入 ResourceTemplate 对象 |
自动根据 URI 和函数签名推断 |
| 回调签名 | 静态 (uri, ctx),模板 (uri, variables, ctx),返回 ReadResourceResult |
(variables?) => str | bytes | Any(可另带 Context 参数) |
| 返回值 | 需要手动构造 contents 数组 |
自动封装为合适的内容类型 |
| 生命周期管理 | 返回 RegisteredResource 对象,可 enable/disable/update/remove,已连接时自动发 list_changed |
只有 add_resource(),没有禁用、移除接口,添加资源也不发 list_changed |
两种风格各有所长。TypeScript SDK 更显式、更灵活,给开发者完全的控制权;Python SDK 更简洁、更符合 Python 的约定优于配置哲学,用装饰器和类型推断减少了样板代码。
6.7 实际应用场景
Resource 的抽象足够通用,可以覆盖大量实际场景。以下是几个典型的应用模式。
6.7.1 文件系统资源
最直接的用例是暴露文件系统内容:
@server.resource("file:///{path}", name="project-files")
def read_file(path: str) -> str:
file_path = Path(project_root) / path
# 安全检查:防止路径穿越
if not file_path.resolve().is_relative_to(project_root):
raise ValueError("Access denied: path traversal detected")
return file_path.read_text()
这里有一个关键的安全细节:必须验证路径不会穿越出允许的根目录。Resource 的服务端实现承担着访问控制的责任。(project_root 须是已 resolve() 的 Path。)
结合 §6.6.3 的边界 1 和边界 2 再看这个例子:{path} 匹配不了带 / 的多级路径,客户端要读子目录里的文件,得把斜杠编码成 %2F;而解码之后的值可能就是 ../../etc/passwd,这正是上面那道检查必须存在的原因——实跑 file:///..%2F..%2Fetc%2Fpasswd 被它拦下(客户端看到的是 Unknown resource,原因见 §6.6.3)。
6.7.2 数据库 Schema
将数据库的结构信息暴露为 Resource,让 LLM 在编写 SQL 时有准确的 schema 参考:
@server.resource("postgres://{table}/schema", name="table-schema")
async def get_table_schema(table: str) -> str:
columns = await db.fetch_columns(table)
return json.dumps({
"table": table,
"columns": [
{"name": c.name, "type": c.type, "nullable": c.nullable}
for c in columns
]
})
6.7.3 Git 历史
将代码仓库的提交历史、文件差异暴露为 Resource:
@server.resource("git://log/recent", name="recent-commits")
def get_recent_commits() -> str:
result = subprocess.run(
["git", "log", "--oneline", "-20"],
capture_output=True, text=True
)
return result.stdout
@server.resource("git://diff/{commit}", name="commit-diff")
def get_commit_diff(commit: str) -> str:
if commit.startswith("-"): # 防止把参数当成 git 选项注入
raise ValueError("invalid commit")
result = subprocess.run(
["git", "show", commit, "--stat"],
capture_output=True, text=True
)
return result.stdout
6.7.4 API 响应缓存
将外部 API 的响应缓存为 Resource,避免在多轮对话中重复请求:
@server.resource("api://weather/{city}", name="weather-data")
async def get_weather(city: str) -> str:
# 缓存层避免重复请求
cached = cache.get(f"weather:{city}")
if cached:
return cached
data = await weather_api.get_current(city)
result = json.dumps(data)
cache.set(f"weather:{city}", result, ttl=300)
return result
6.8 设计理性:为什么要将 Resource 与 Tool 分开?
这是理解 MCP 架构最重要的问题之一。初看之下,Resource 完全可以用 Tool 来实现——定义一个 read_file 工具不也能读取文件吗?但 MCP 坚持将两者分开,背后有三层深刻的考量。
6.8.1 安全性:控制权的归属
Tool 的执行由模型决定。这意味着如果你把文件读取实现为 Tool,模型在推理过程中可以自主决定读取任意文件——即使用户没有要求这样做。而 Resource 读不读、读哪些由 Host 决定,协议没有给模型一个"自己去读资源"的入口——Host 即便让模型参与挑选,那也是 Host 自己设定并可以约束的策略。
这个区别在涉及敏感数据时尤为关键。假设一个 MCP 服务端同时连接了代码仓库和生产数据库。如果数据库查询被实现为 Tool,一个经过精心构造的 prompt 注入攻击可能诱使模型执行恶意查询。但如果数据库 schema 被实现为 Resource,只要 Host 不把资源选择交给模型,模型就只能看到用户/应用程序明确选择注入的 schema 信息,无法主动发起查询。
6.8.2 可控性:上下文窗口的精细管理
LLM 的上下文窗口是有限的。将所有可能的信息都通过 Tool 调用加载到上下文中,会迅速耗尽窗口容量。Resource 的应用控制模型让客户端可以精细地管理上下文:
- 通过
annotations.priority决定哪些资源必须包含、哪些可以省略 - 通过
size字段预估资源对上下文窗口的消耗 - 通过
annotations.audience决定资源是给用户看还是给模型看
这种精细的控制在 Tool 模式下很难做到——Tool 的结果是模型发起调用之后才产生的,Host 没有一份事先可供挑选的清单。
6.8.3 可组合性:声明式 vs 命令式
Resource 是声明式的——它描述"有什么数据可用",而不是"执行什么操作"。这种声明式的特性使得 Resource 具有天然的可组合性:
- 多个 Resource 可以同时注入上下文,不存在执行顺序的问题
- Resource 的读取是取数据而不是执行操作,一般不应产生副作用(内容本身可以随时间变化,这正是订阅机制存在的原因)
- 应用程序可以预先加载所有需要的 Resource,构建一个完整的上下文快照
Tool 则是命令式的——它描述"做什么"。Tool 的调用可能有副作用、有执行顺序依赖、有并发安全问题。将上下文数据从 Tool 中分离出来,让 Resource 专注于无副作用的数据提供,使整个系统的行为更加可预测。
graph TB
subgraph 设计维度
direction LR
S["安全性"] --- C["可控性"] --- Comp["可组合性"]
end
subgraph Resource特性
S1["应用/用户控制\n读取由 Host 决定"]
C1["annotations 精细管理\n上下文窗口可预测"]
Comp1["声明式、只读\n无副作用、可并行"]
end
subgraph Tool特性
S2["模型自主决定\n需要权限系统约束"]
C2["调用即注入\n上下文消耗不可预测"]
Comp2["命令式、有副作用\n可能有顺序依赖"]
end
S --> S1
S --> S2
C --> C1
C --> C2
Comp --> Comp1
Comp --> Comp2
style S1 fill:#e8f5e9,stroke:#2e7d32
style C1 fill:#e8f5e9,stroke:#2e7d32
style Comp1 fill:#e8f5e9,stroke:#2e7d32
style S2 fill:#e3f2fd,stroke:#1565c0
style C2 fill:#e3f2fd,stroke:#1565c0
style Comp2 fill:#e3f2fd,stroke:#1565c0
6.9 错误处理
Resource 操作可能遇到各种错误。2025-11-25 版 server/resources.mdx 建议(SHOULD)服务端对常见失败返回这样的 JSON-RPC 错误:
- -32002:资源未找到(Resource not found)
- -32603:内部错误(Internal error)
-32603 是 JSON-RPC 2.0 的预定义码;-32002 则是 MCP 在 JSON-RPC 留给实现自定义的 -32000 到 -32099 区间里取的码,它只写在 resources.mdx 这一节,2025-11-25 的 schema.ts 并没有定义这个常量(第 3 章 §3.4.2)。2026-07-28 版改为:资源不存在时服务端必须(MUST)返回 -32602(Invalid Params),客户端为兼容旧版也应接受 -32002,并且不得用空的 contents 数组表示资源不存在。下面是 2025-11-25 规范给出的示例:
{
"error": {
"code": -32002,
"message": "Resource not found",
"data": { "uri": "file:///nonexistent.txt" }
}
}
两个 SDK 在 pin 版上的实际行为(实跑)并不一致:TS McpServer 对未注册的 URI 返回 -32002(packages/server/src/server/mcp.ts:501),message 为 Resource <uri> not found,响应里没有示例中的 data 字段;对已禁用的资源返回 -32602;Python MCPServer 对未知 URI 返回的 code 是 0、message 为 Unknown resource: <uri>(src/mcp/server/mcpserver/server.py:451),读取函数抛异常则是 Error reading resource <uri>,code 同样是 0——底层 Server 把处理器抛出的普通异常一律转成 code: 0(src/mcp/server/lowlevel/server.py:512-515,见第 3 章 §3.4.3)。
规范的安全要求是:
- 验证所有 URI:防止路径穿越、注入攻击等安全问题
- 实施访问控制:根据客户端身份或上下文限制可访问的资源
- 正确编码二进制数据:确保 blob 内容是有效的 base64
- 在操作前检查权限:不要在返回数据后才发现权限不足
6.9.1 实测:两 SDK Resource 实现规模与 URI Template 工程量
§6.6 给出 SDK 注册模式对比——把 Resource 实现在两 SDK 里实测(wc -l + 方法行区间,均为 b8886e7 / 3d7b311 的真实行号,区间含方法前的 JSDoc,与 Python 侧整文件、含 docstring 的口径一致)——
TS SDK——Resource 逻辑分散在 mcp.ts 与 core 共享模块两处——
| 位置 | 行数 | 角色 |
|---|---|---|
packages/server/src/server/mcp.ts:428-505 |
78 | setResourceRequestHandlers()——只注册了 resources/list、resources/templates/list、resources/read 三个处理器 |
packages/server/src/server/mcp.ts:557-696 |
140 | registerResource 两个重载 + 实现,以及 _createRegisteredResource / _createRegisteredResourceTemplate |
packages/server/src/server/mcp.ts:402-424、979-986 |
31 | 模板变量补全 handleResourceCompletion() + sendResourceListChanged() |
packages/server/src/server/mcp.ts:1007-1063、1161-1221 |
118 | CompleteResourceTemplateCallback 类型与 ResourceTemplate 类 + ResourceMetadata / RegisteredResource / RegisteredResourceTemplate 等类型声明 |
packages/core/src/shared/uriTemplate.ts |
290 | URI Template(RFC 6570)实现——独立模块,§6.3.2 模板参数化的核心 |
| TS SDK Resource 相关代码 | ≈ 657 | — |
Python SDK——独立子目录 src/mcp/server/mcpserver/resources/——
| 文件 | 行 | 角色 |
|---|---|---|
types.py |
205 | TextResource / BinaryResource / FunctionResource / FileResource / HttpResource / DirectoryResource 六个具体 Resource 类 |
templates.py |
133 | ResourceTemplate 类——§6.6.3 提到的"模板匹配真实算法"在这里 |
resource_manager.py |
108 | ResourceManager——add_resource / add_template / get_resource / list_resources / list_templates |
base.py |
44 | 抽象基类 Resource |
__init__.py |
23 | export |
src/mcp/server/mcpserver/server.py:627-737 的 @resource 装饰器 |
111 | 模板判定 + 变量集校验 + 分派注册 |
| Python SDK Resource 相关代码 | ≈ 624 | — |
两个正文没有强调的事实——
- TS SDK 把 URI Template 单独成 290 行模块——
packages/core/src/shared/uriTemplate.ts支持 RFC 6570 七个 expression operator 中的六个(源码里就是const operators = ['+', '#', '.', '/', '?', '&'],缺路径参数风格的;)加上*explode 修饰符,并带MAX_TEMPLATE_LENGTH/MAX_TEMPLATE_EXPRESSIONS/MAX_REGEX_LENGTH等输入长度与数量上限;但它不支持;和{var:N}这种前缀截断修饰符,所以准确说法是"覆盖 RFC 6570 的大部分 operator"而非"完整实现"。Python SDK 的templates.py仅 133 行——只做基本{var}替换 + 一次re.match——和 §6.6.3 说的"一个 regex 和它的三个边界"吻合:TS 是 operator 级实现、Python 是子集实现 - Python SDK 把 6 种 Resource 类型定义在独立 types.py 205 行——TextResource / BinaryResource / FunctionResource / FileResource / HttpResource / DirectoryResource——每种对应不同读取语义;TS SDK 没有对应的类型层级,读取结果的 text/blob 之分完全由 callback 返回的
contents[]元素自己决定——Python 用类型层级表达多态、TS 用返回值结构表达多态——是两个语言生态在"多态如何编码"上的差异(OOP vs 结构类型)
值得注意的是两边总量几乎一样(≈ 657 vs ≈ 624)——和 §7.11.1 测得的 Prompt(TS ≈ 267 / Python ≈ 367)一样,都不存在"数量级差异"。真正的差别不在总量而在分布:TS 把复杂度集中在一个 290 行的通用 URI Template 模块里(core 包共享,Client 侧也能用),Python 把复杂度摊在六个 Resource 子类上。
6.10 本章小结
Resource 是 MCP 三大核心原语之一,它的设计哲学可以用一句话概括:让正确的信息,以正确的方式,在正确的时机,出现在正确的位置。
回顾本章的核心要点:
- Resource 是应用程序控制的上下文数据,它的存在目的是为 LLM 提供推理所需的背景信息
- 与 Tool 最本质的区别在于控制权的归属:Resource 由应用/用户选择,Tool 由模型选择
- URI 寻址体系分为静态 URI 和 URI Template 两层,覆盖了从固定资源到无限参数化资源的完整光谱
- 发现机制通过 listResources 和 resourceTemplates 两个端点分别处理可枚举资源和参数化资源
- 订阅模型采用推(通知)+拉(读取)的混合模式,兼顾实时性和效率
- TypeScript SDK 的 registerResource 和 Python SDK 的 @resource 装饰器体现了两种语言生态的不同哲学
- Resource 与 Tool 的分离不是偶然的设计选择,而是安全性、可控性和可组合性三重考量的产物