在上一篇文章中,我们深入剖析了 Claude Code 如何通过 MCPTool 将外部 MCP Server 的 tools/list 能力无缝接入自身的工具循环。Tool 解决的是执行问题——让模型能够调用外部函数完成动作。但 MCP 协议的版图远不止于此,它还定义了 Resource(资源) 这一核心原语,解决的是数据供给问题。
如果说 Tool 是动词(Verb),那么 Resource 就是名词(Noun)。当模型需要了解当前项目的结构、查询数据库中的记录、读取 Slack 频道的消息历史,或者查看某个配置文件的内容时,它需要的不是执行某个动作,而是读取一份数据。Resource 正是 MCP 为这种场景设计的标准化数据访问接口。
本文将聚焦 Claude Code 中负责 Resource 接入的两个专用工具——ListMcpResourcesTool 和 ReadMcpResourceTool,并延伸到支撑这一切的传输层实现,完整呈现 MCP 协议在 Claude Code 中的资源侧图景。
一、MCP Resources 概念体系
1.1 Resource 与 Tool 的本质区别
MCP 协议将服务器暴露的能力划分为三大类:Tool、Resource 和 Prompt。其中 Tool 与 Resource 的区分最为关键,它直接决定了模型与外部系统的交互方式。
| 维度 | Tool | Resource |
|---|---|---|
| 控制方 | 模型控制(Model-controlled) | 应用控制(Application-controlled) |
| 操作语义 | 执行动作、修改状态 | 只读访问、获取数据 |
| 典型示例 | 发送消息、创建文件、执行查询 | 读取文件、获取配置、查看日志 |
| 调用方式 | 模型根据上下文自主决定调用 | 由应用或用户显式选择后加载 |
| 副作用 | 可能有(创建、修改、删除) | 无副作用 |
这种区分不是简单的语义游戏,而是深刻影响了架构设计。Tool 被包装为模型可直接调用的函数,出现在 tool_use 循环中;Resource 则需要通过应用层的资源发现和资源读取机制来访问。Claude Code 正是通过 ListMcpResourcesTool 和 ReadMcpResourceTool 这两个内建工具,将 Resource 的能力桥接到模型的工具调用循环中。
1.2 Resource 的 URI 格式
每个 MCP Resource 都由一个唯一的 URI 标识,格式遵循标准的 URI 规范:
[protocol]://[host]/[path]具体的 protocol 和 path 结构由 MCP Server 自行定义。例如:
file:///home/user/documents/report.pdf—— 文件系统资源postgres://database/customers/schema—— 数据库资源slack://messages/general—— Slack 频道消息config://app/settings—— 应用配置
URI 的设计赋予了 MCP Server 极大的灵活性。服务器可以创建完全自定义的命名空间,客户端无需理解底层的存储细节,只需通过标准化的 URI 来寻址和读取资源。这种解耦是 MCP 协议能够连接异构数据源的根本保障。
1.3 Resource 的类型与内容
MCP Resource 支持两种内容类型:
文本资源(Text Resource):包含 UTF-8 编码的文本数据,适用于源代码、配置文件、日志、JSON/XML 数据等场景。返回格式中包含 text 字段和 mimeType 字段。
二进制资源(Binary Resource):包含 Base64 编码的原始二进制数据,适用于图片、PDF、音频、视频等非文本格式。返回格式中使用 blob 字段承载 Base64 数据。
资源响应的标准结构如下:
{
"contents": [
{
"uri": "file:///path/to/resource",
"text": "Resource text content",
"mimeType": "text/plain"
}
]
}或者对于二进制资源:
{
"contents": [
{
"uri": "file:///path/to/image.png",
"blob": "iVBORw0KGgoAAAANSUhEUg...",
"mimeType": "image/png"
}
]
}MCP 还区分静态资源和动态资源。静态资源拥有固定的 URI,直接在 resources/list 中枚举;动态资源则通过 URI Template(如 file:///{path})来声明模式,客户端根据模板构造具体的 URI 进行读取。这种设计让服务器无需为成千上万个文件逐一注册,一个模板即可覆盖整个目录树。
二、ListMcpResourcesTool:资源发现机制
2.1 文件位置与工具定义
ListMcpResourcesTool 的实现位于:
src/tools/ListMcpResourcesTool/ListMcpResourcesTool.tsx与 Claude Code 中所有内建工具一样,它通过 buildTool 工厂函数构建,遵循统一的 ToolDef 接口规范。该工具被标记为只读(Read-Only)和并发安全(Concurrency Safe),这意味着它可以与其他只读工具并行执行,不会阻塞工具编排流水线。
在 src/tools.ts 的工具注册管线中,ListMcpResourcesTool 和 ReadMcpResourceTool 被归类为特殊工具:
// 源码文件:src/tools.ts
const specialTools = new Set([
ListMcpResourcesTool.name,
ReadMcpResourceTool.name,
SYNTHETIC_OUTPUT_TOOL_NAME,
])
const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))它们不会出现在基础工具列表中,而是根据 MCP 连接状态动态注入。这种设计的精妙之处在于:如果当前没有任何 MCP Server 声明了 resources 能力,这两个工具就不会出现在模型的视野中,避免了无意义的工具膨胀。
2.2 资源发现流程
资源发现的核心逻辑在 src/services/mcp/client.ts 的 fetchResourcesForClient 函数中:
// 源码文件:src/services/mcp/client.ts
export const fetchResourcesForClient = memoizeWithLRU(
async (client: MCPServerConnection): Promise<ServerResource[]> => {
if (client.type !== 'connected') return []
if (!client.capabilities?.resources) {
return []
}
const result = await client.client.request(
{ method: 'resources/list' },
ListResourcesResultSchema,
)
if (!result.resources) return []
return result.resources.map(resource => ({
...resource,
server: client.name,
}))
},
(client: MCPServerConnection) => client.name,
MCP_FETCH_CACHE_SIZE,
)这个函数体现了几个关键设计决策:
能力检查前置。 在发送 resources/list 请求前,先检查服务器在初始化握手阶段是否声明了 resources 能力。若未声明,直接返回空数组,避免无效的网络往返。
来源标记。 每个资源对象被附加 server 字段,标识其来源服务器。类型定义为 ServerResource = Resource & { server: string }。这个字段在后续聚合多个服务器的资源列表时至关重要,它让模型能够区分同名资源来自哪个服务。
LRU 缓存。 资源列表使用 LRU(Least Recently Used)缓存,缓存键为服务器名称。在同一会话中,资源列表只会从服务器获取一次,除非连接被重建(重连时会清除缓存)。缓存大小限制为 20 个条目,防止连接大量服务器时内存膨胀。
2.3 自动注入与去重
当 MCP Server 连接建立后,reconnectMcpServerImpl 函数会检测服务器的资源能力,并自动将资源工具注入工具列表:
// 源码文件:src/services/mcp/client.ts
const supportsResources = !!client.capabilities?.resources
const [tools, mcpCommands, mcpSkills, resources] = await Promise.all([
fetchToolsForClient(client),
fetchCommandsForClient(client),
feature('MCP_SKILLS') && supportsResources
? fetchMcpSkillsForClient!(client) : Promise.resolve([]),
supportsResources ? fetchResourcesForClient(client) : Promise.resolve([]),
])
const resourceTools: Tool[] = []
if (supportsResources) {
const hasResourceTools = [ListMcpResourcesTool, ReadMcpResourceTool].some(
tool => tools.some(t => toolMatchesName(t, tool.name)),
)
if (!hasResourceTools) {
resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool)
}
}
return {
client,
tools: [...tools, ...resourceTools],
commands,
resources: resources.length > 0 ? resources : undefined,
}这里包含一个去重检查:如果 MCP Server 自身已经提供了同名的资源工具(例如某些高级服务器希望自定义资源访问行为),则不再注入默认的内建工具。这种设计既保证了通用性,又保留了扩展性。
ListMcpResourcesTool 被调用时,会聚合所有已连接服务器的资源列表,返回一个统一的资源目录。模型可以基于这个目录决定接下来要读取哪些资源。
2.4 列表格式与分页
虽然 MCP 协议本身支持资源列表的分页(通过 cursor 机制),但在 Claude Code 的实现中,ListMcpResourcesTool 通常会将多个服务器的资源聚合为扁平列表返回给模型。返回格式大致如下:
{
"resources": [
{ "server": "filesystem", "uri": "file:///project/README.md", "name": "README", "mimeType": "text/markdown" },
{ "server": "slack", "uri": "slack://messages/general", "name": "general-channel", "mimeType": "text/json" },
{ "server": "postgres", "uri": "postgres://db/users/schema", "name": "users-schema", "mimeType": "application/json" }
]
}模型获得这个列表后,可以基于 URI 和名称推断资源的用途,进而通过 ReadMcpResourceTool 读取感兴趣的内容。
三、ReadMcpResourceTool:资源读取与格式转换
3.1 文件位置与调用语义
ReadMcpResourceTool 的实现位于:
src/tools/ReadMcpResourceTool/ReadMcpResourceTool.tsx与 ListMcpResourcesTool 一样,它也是只读、并发安全的工具。其输入 Schema 接受 uri 参数(以及可选的 server 提示),通过标准的 MCP resources/read 请求获取资源内容。
3.2 URI 解析与路由
当模型调用 ReadMcpResourceTool 时,Claude Code 需要解决一个关键问题:这个 URI 属于哪个 MCP Server?路由逻辑基于 AppState.mcp.mcpResources 中缓存的资源列表。每个资源都带有 server 来源标记,系统通过 URI 匹配找到对应的服务器连接,然后转发 resources/read 请求。
如果 URI 没有明确的来源标记,系统会尝试在所有声明了 resources 能力的服务器上广播查找。这种容错设计确保了模型即使在信息不完整的情况下也能成功读取资源。
3.3 内容读取与格式转换
资源读取通过 MCP 标准的 resources/read 请求完成:
// 伪代码示意,基于协议规范与 client.ts 中的模式
const result = await client.request(
{ method: 'resources/read', params: { uri } },
ReadResourceResultSchema,
)读取到的内容需要被转换为 Claude API 能理解的 ContentBlockParam 格式,才能嵌入对话上下文。src/services/mcp/client.ts 中的 transformResultContent 函数负责这一转换:
// 源码文件:src/services/mcp/client.ts
export async function transformResultContent(
resultContent: PromptMessage['content'],
serverName: string,
): Promise<Array<ContentBlockParam>> {
switch (resultContent.type) {
case 'text':
return [{ type: 'text', text: resultContent.text }]
case 'image': {
const imageBuffer = Buffer.from(String(resultContent.data), 'base64')
const resized = await maybeResizeAndDownsampleImageBuffer(
imageBuffer, imageBuffer.length, ext,
)
return [{ type: 'image', source: { data: resized.buffer.toString('base64'), ... } }]
}
case 'resource': {
const resource = resultContent.resource
if ('text' in resource) {
return [{ type: 'text', text: `[Resource from ${serverName}] ${resource.text}` }]
} else if ('blob' in resource) {
// 图片 blob 进行缩放,其他二进制 blob 持久化到文件
// ...
}
}
case 'resource_link': {
return [{ type: 'text', text: `[Resource link: ${name}] ${uri}` }]
}
}
}这段代码体现了资源内容的三级处理策略:
文本资源直接嵌入为 text 类型的 Content Block,并在前面加上来源标记(如 [Resource from slack at slack://messages/general])。这个来源标记至关重要——它帮助模型理解数据的出处和可信度,避免将不同来源的信息混为一谈。
图片资源经过 maybeResizeAndDownsampleImageBuffer 自动缩放和压缩,确保不超出 API 的尺寸限制,然后以 image 类型嵌入。
其他二进制资源(PDF、音频、视频等)不会被直接嵌入上下文(API 不支持这些格式),而是被持久化到磁盘临时文件,上下文中只保留文件路径引用。模型随后可以通过 FileReadTool 等内建工具读取这些持久化后的文件。
3.4 大结果处理
资源内容可能非常庞大(例如完整的数据库表导出、大型日志文件)。Claude Code 对大结果的处理采用了与 MCP Tool 结果相同的三级降级策略:
- 正常返回:结果大小在阈值内(约 100KB),直接作为上下文传递。
- 持久化到文件:超出阈值时,将内容写入临时文件,返回文件路径和读取指引。
- 截断:如果文件持久化也失败,则截断内容并添加
[truncated]提示。
特别地,如果资源结果中包含图片内容,系统会绕过文件持久化路径,直接使用截断策略——因为将图片序列化为 JSON 既浪费空间又破坏了图片压缩逻辑。
四、MCP 传输层:从 stdio 到 WebSocket
Resource 的发现和读取最终都依赖传输层将 JSON-RPC 消息送达 MCP Server。Claude Code 支持多种传输协议,其中 stdio 和 WebSocket 是最具代表性的两种。
4.1 stdio 传输:本地子进程模式
stdio 传输是 MCP 的默认传输方式,也是使用最广泛的本地集成方案。它通过启动一个子进程来运行 MCP Server,使用标准输入(stdin)和标准输出(stdout)进行 JSON-RPC 消息交换:
// 源码文件:src/services/mcp/client.ts
transport = new StdioClientTransport({
command: finalCommand,
args: finalArgs,
env: {
...subprocessEnv(),
...serverRef.env,
} as Record<string, string>,
stderr: 'pipe',
})stdio 传输的特点:
- 进程隔离:每个 MCP Server 运行在独立的子进程中,崩溃不会影响 Claude Code 主进程。
- 环境可控:
subprocessEnv()提供清理后的基础环境变量,配置中的env字段允许用户注入自定义变量,支持${VAR}和${VAR:-default}展开语法。 - stderr 捕获:
stderr: 'pipe'将服务器的错误输出捕获到内存缓冲区(限制 64MB),供调试使用,避免污染用户终端。 - 不重连策略:stdio 连接断开后不自动重连。因为子进程退出通常意味着程序崩溃,盲目重启可能只会重复失败。
4.2 WebSocket 传输:mcpWebSocketTransport.ts
对于需要长连接、双向通信的远程 MCP Server,Claude Code 提供了 WebSocket 传输实现,位于:
src/utils/mcpWebSocketTransport.ts这个文件实现了 WebSocketTransport 类,直接对接 MCP SDK 的 Transport 接口。它的核心职责是将 WebSocket 的消息事件桥接为 JSON-RPC 消息流。
// 源码文件:src/utils/mcpWebSocketTransport.ts(第 1-40 行)
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import {
type JSONRPCMessage,
JSONRPCMessageSchema,
} from '@modelcontextprotocol/sdk/types.js'
import type WsWebSocket from 'ws'
import { logForDiagnosticsNoPII } from './diagLogs.js'
import { toError } from './errors.js'
import { jsonParse, jsonStringify } from './slowOperations.js'
// WebSocket readyState constants (same for both native and ws)
const WS_CONNECTING = 0
const WS_OPEN = 1
// Minimal interface shared by globalThis.WebSocket and ws.WebSocket
type WebSocketLike = {
readonly readyState: number
close(): void
send(data: string): void
}
export class WebSocketTransport implements Transport {
private started = false
private opened: Promise<void>
private isBun = typeof Bun !== 'undefined'
constructor(private ws: WebSocketLike) {
this.opened = new Promise((resolve, reject) => {
if (this.ws.readyState === WS_OPEN) {
resolve()
} else if (this.isBun) {
const nws = this.ws as unknown as globalThis.WebSocket
const onOpen = () => {
nws.removeEventListener('open', onOpen)
nws.removeEventListener('error', onError)
resolve()
}
const onError = (event: Event) => {
nws.removeEventListener('open', onOpen)
nws.removeEventListener('error', onError)
logForDiagnosticsNoPII('error', 'mcp_websocket_connect_fail')
reject(event)
}
nws.addEventListener('open', onOpen)
nws.addEventListener('error', onError)
} else {
const nws = this.ws as unknown as WsWebSocket
nws.on('open', () => {
resolve()
})
nws.on('error', error => {
logForDiagnosticsNoPII('error', 'mcp_websocket_connect_fail')
reject(error)
})
}
})这段代码展示了几个值得关注的工程细节:
双运行时支持。 Claude Code 使用 Bun 作为运行时,但同时也兼容 Node.js 的 ws 包。通过 this.isBun = typeof Bun !== 'undefined' 检测运行时环境,分别使用原生 WebSocket API(addEventListener)和 ws 包的事件 API(on/off)。这种设计让同一份代码在两个运行时上都能正确工作。
连接状态抽象。 定义了 WebSocketLike 最小接口,只暴露 readyState、close() 和 send(data: string) 三个成员。这种鸭子类型(Duck Typing)的抽象避免了直接依赖具体的 WebSocket 实现,提高了可测试性和可移植性。
// 源码文件:src/utils/mcpWebSocketTransport.ts(第 85-145 行)
private onBunMessage = (event: MessageEvent) => {
try {
const data =
typeof event.data === 'string' ? event.data : String(event.data)
const messageObj = jsonParse(data)
const message = JSONRPCMessageSchema.parse(messageObj)
this.onmessage?.(message)
} catch (error) {
this.handleError(error)
}
}
private onNodeMessage = (data: Buffer) => {
try {
const messageObj = jsonParse(data.toString('utf-8'))
const message = JSONRPCMessageSchema.parse(messageObj)
this.onmessage?.(message)
} catch (error) {
this.handleError(error)
}
}
// Shared error handler
private handleError(error: unknown): void {
logForDiagnosticsNoPII('error', 'mcp_websocket_message_fail')
this.onerror?.(toError(error))
}
// Shared close handler with listener cleanup
private handleCloseCleanup(): void {
this.onclose?.()
if (this.isBun) {
const nws = this.ws as unknown as globalThis.WebSocket
nws.removeEventListener('message', this.onBunMessage)
nws.removeEventListener('error', this.onBunError)
nws.removeEventListener('close', this.onBunClose)
} else {
const nws = this.ws as unknown as WsWebSocket
nws.off('message', this.onNodeMessage)
nws.off('error', this.onNodeError)
nws.off('close', this.onNodeClose)
}
}消息解析与校验。 无论 Bun 还是 Node,收到消息后都经过相同的处理流水线:jsonParse 解析字符串 → JSONRPCMessageSchema.parse 校验结构 → 调用 this.onmessage 回调。Zod Schema 校验确保了只有符合 JSON-RPC 2.0 规范的消息才能进入上层处理逻辑, malformed message 会被捕获并通过 handleError 上报。
// 源码文件:src/utils/mcpWebSocketTransport.ts(第 146-190 行)
async start(): Promise<void> {
if (this.started) {
throw new Error('Start can only be called once per transport.')
}
await this.opened
if (this.ws.readyState !== WS_OPEN) {
logForDiagnosticsNoPII('error', 'mcp_websocket_start_not_opened')
throw new Error('WebSocket is not open. Cannot start transport.')
}
this.started = true
}
async close(): Promise<void> {
if (
this.ws.readyState === WS_OPEN ||
this.ws.readyState === WS_CONNECTING
) {
this.ws.close()
}
this.handleCloseCleanup()
}
async send(message: JSONRPCMessage): Promise<void> {
if (this.ws.readyState !== WS_OPEN) {
logForDiagnosticsNoPII('error', 'mcp_websocket_send_not_opened')
throw new Error('WebSocket is not open. Cannot send message.')
}
const json = jsonStringify(message)
try {
if (this.isBun) {
this.ws.send(json)
} else {
await new Promise<void>((resolve, reject) => {
;(this.ws as unknown as WsWebSocket).send(json, error => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
} catch (error) {
this.handleError(error)
throw error
}
}
}发送语义差异。 Bun 的原生 WebSocket send() 是同步的(无回调),而 Node.js 的 ws 包 send() 支持回调式错误处理。WebSocketTransport.send 通过运行时分支统一了这两种语义:Bun 下直接调用,Node 下包装为 Promise。
生命周期管理。 start() 确保连接已打开后才标记为 started;close() 在关闭连接的同时清理所有事件监听器,防止内存泄漏。这种严格的生命周期管理对于长时间运行的 Claude Code 会话尤为重要。
4.3 消息格式:JSON-RPC 2.0
无论 stdio 还是 WebSocket,MCP 的线格式都是 JSON-RPC 2.0。一条典型的资源读取请求如下:
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/read",
"params": {
"uri": "file:///project/config.yaml"
}
}服务器的响应:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"contents": [
{
"uri": "file:///project/config.yaml",
"text": "database:\n host: localhost\n port: 5432",
"mimeType": "text/yaml"
}
]
}
}JSON-RPC 的请求-响应模型天然适合 MCP 的交互模式:resources/list 和 resources/read 是标准请求;而服务器也可以通过 notifications/resources/list_changed 主动推送资源列表变更通知,让客户端及时刷新缓存。
五、资源与工具的协同:完整的 MCP 能力拼图
将上篇的 Tool 接入与本篇的 Resource 接入结合起来,我们可以得到 Claude Code 中 MCP 协议的完整能力映射:
flowchart TB
subgraph Config["配置层"]
A["MCP Server 配置
config.ts"]
end
subgraph Transport["传输层"]
B["stdio 子进程"]
C["WebSocket 长连接"]
D["SSE / HTTP"]
end
subgraph Protocol["协议层"]
E["JSON-RPC 2.0"]
end
subgraph Discovery["发现层"]
F["tools/list"]
G["resources/list"]
H["prompts/list"]
end
subgraph Tools["工具集成层"]
I["MCPTool
执行动作"]
J["ListMcpResourcesTool
发现数据"]
K["ReadMcpResourceTool
读取数据"]
L["MCP Prompt → Skill"]
end
subgraph Agent["Agent 循环"]
M["模型上下文"]
end
A --> Transport
Transport --> Protocol
Protocol --> Discovery
F --> I
G --> J
J --> K
K --> M
I --> M
H --> L
L --> M这个架构的优雅之处在于传输层对上层完全透明。无论是本地 stdio 子进程还是远程 WebSocket 连接,上层的 fetchResourcesForClient、resources/read 调用都使用完全相同的接口。新的传输协议可以在不修改业务逻辑的情况下被引入。
资源的生命周期在对话中的流动可以用以下时序图表示:
sequenceDiagram
participant Model as Claude 模型
participant List as ListMcpResourcesTool
participant Read as ReadMcpResourceTool
participant Client as MCP Client
participant Server as MCP Server
Model->>List: 调用 ListMcpResourcesTool()
List->>Client: 查询 AppState.mcp.mcpResources
Client-->>List: 返回聚合的资源列表
List-->>Model: { resources: [...] }
Model->>Read: 调用 ReadMcpResourceTool(uri)
Read->>Client: 路由到对应服务器
Client->>Server: resources/read (JSON-RPC)
Server-->>Client: { contents: [...] }
Client->>Client: transformResultContent()
alt 文本资源
Client-->>Read: [Resource from X] text
else 图片资源
Client-->>Read: 压缩后的 image block
else 二进制资源
Client-->>Read: 持久化文件路径
end
Read-->>Model: tool_result六、总结
本文深入解析了 Claude Code 中 MCP Resource 的接入机制,重点剖析了 ListMcpResourcesTool 和 ReadMcpResourceTool 的设计与实现,并延伸到 WebSocketTransport 的传输层细节。
关键要点回顾:
Resource 是 MCP 的只读数据原语,与 Tool 的执行语义形成互补。它通过 URI 统一标识,支持文本和二进制两种内容类型。
ListMcpResourcesTool 负责资源发现,通过
fetchResourcesForClient调用resources/list,并使用 LRU 缓存和来源标记(server字段)来管理多服务器的资源聚合。ReadMcpResourceTool 负责资源读取,通过
resources/read获取内容后,利用transformResultContent将资源转换为模型可消费的ContentBlockParam,并根据内容类型(文本/图片/二进制)采取不同的嵌入策略。WebSocketTransport 实现了跨运行时的传输层抽象,同时支持 Bun 原生 WebSocket 和 Node.js
ws包,通过严格的生命周期管理和消息校验保障了连接的稳定性和安全性。传输层透明性是 Claude Code MCP 架构的核心设计决策。stdio、WebSocket、SSE、HTTP 等传输协议被统一抽象,上层的资源发现和读取逻辑完全不感知底层差异。
结合上篇对 MCPTool 的分析,我们现在已经完整理解了 Claude Code 如何同时接入 MCP 的 Tool 和 Resource 两大能力。这种设计让 Claude Code 既能通过 Tool 执行外部动作,又能通过 Resource 注入外部数据,形成了一个真正开放、可扩展的 AI Agent 工具平台。