MCP Server 不只能提供 Tool,还可以提供 Resource。
Tool 很容易理解:模型需要查询用户、读取文件或执行命令时,发起一次工具调用,Runtime 执行后把结果交还给模型。
Resource 看起来更像"资料":使用指南、配置说明、数据库结构、项目文档,都可以通过 MCP Server 暴露出来。
但这里有一个很容易产生的误解:
text
Server 注册了 Resource,模型就能自动看到它。
实际上,注册只代表这份资源可以被 MCP Client 发现和读取。它什么时候被读取、哪些内容进入 Prompt,仍然由 Host 应用决定。
本文结合一个最小 Demo,把下面这条链路完整拆开:
text
registerResource
→ listResources
→ readResource
→ SystemMessage
→ LLM
Demo 中的 Tool 和 Resource
这个 MCP Server 同时注册了两种能力:
text
Tool:query_user
Resource:docs://guide
query_user 接收用户 ID,再从模拟数据库中查询用户信息。
docs://guide 则提供 MCP Server 的使用指南。
它们虽然来自同一个 Server,但进入模型上下文的方式不同:
| 能力 | 谁决定使用 | 进入上下文的方式 |
|---|---|---|
| Tool | 通常由模型根据任务决定 | Runtime 执行后包装成 ToolMessage |
| Resource | Host 应用决定读取哪些内容 | 应用读取后主动放入 messages |
可以先记住一句话:
text
Tool 等模型发起调用,Resource 等应用主动读取。
第一步:在 MCP Server 中注册 Resource
Server 使用 registerResource() 注册使用指南:
js
server.registerResource(
'使用指南',
'docs://guide',
{
description: '使用指南',
mimeType: 'text/plain',
},
async () => {
return {
contents: [
{
uri: 'docs://guide',
mimeType: 'text/plain',
text: `
MCP Server 使用指南
功能:提供用户查询等工具。
使用:在 MCP Client 中通过自然语言对话,
Client 会调用相应工具。
`,
},
],
};
}
);
这里有四个关键部分:
使用指南:Resource 的名称docs://guide:Resource 的 URImimeType:内容类型- 回调函数:真正返回资源内容
docs://guide 不一定是一个能够在浏览器中打开的网址。
它更像 MCP 内部使用的资源标识:Client 先通过 URI 发现资源,再使用这个 URI 请求实际内容。
注册成功不等于模型已经看到
当 Server 执行完 registerResource(),它只完成了:
text
"我这里有一份 docs://guide,可以通过 MCP 读取。"
它还没有完成:
text
"把 docs://guide 的正文发送给大模型。"
模型调用接口时,真正能看到的是当前请求里的 messages、绑定的 Tools 和其他显式上下文。
如果 Host 没有读取 Resource,也没有把内容加入 messages,模型不会因为 MCP Server 中存在这份资料就自动知道它。
为什么不默认把所有 Resource 全部塞给模型?
- Resource 可能很多,全部加入会消耗大量 Token
- 有些资料与当前问题无关,会干扰模型判断
- 不同用户可能拥有不同的资料访问权限
- 某些内容只应在特定任务中加载
因此,Resource 的选择和注入必须留在应用层控制。
第二步:Client 发现可用 Resource
Host 中先创建 MultiServerMCPClient,连接本地 MCP Server:
js
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'my-mcp-server': {
command: 'node',
args: ['/项目路径/my-mcp-server.mjs'],
},
},
});
本地 Server 会作为子进程启动,Client 通过 stdio 与它通信。
接下来调用:
js
const resources = await mcpClient.listResources();
listResources() 获取的是资源列表,作用是回答:
text
当前连接的 MCP Server 提供了哪些 Resource?
返回结果按 Server 组织,因此代码需要遍历 serverName 和对应的资源列表:
js
for (const [serverName, resources] of Object.entries(res)) {
for (const resource of resources) {
// 下一步读取资源
}
}
这一步仍然只是发现资源,还没有获得 docs://guide 的正文。
第三步:通过 URI 读取 Resource 内容
发现资源后,再调用 readResource():
js
const content = await mcpClient.readResource(
serverName,
resource.uri
);
这里需要两个信息:
text
serverName:资源来自哪个 MCP Server
resource.uri:具体读取哪一个 Resource
为什么不能只传 URI?
因为一个 Client 可以同时连接多个 MCP Server。Host 既要知道资源标识,也要知道应该向哪个 Server 发起读取请求。
Server 返回的内容可能包含多个条目,因此 Demo 会继续遍历:
js
let resourcesContent = '';
for (const [serverName, resources] of Object.entries(res)) {
for (const resource of resources) {
const content = await mcpClient.readResource(
serverName,
resource.uri
);
for (const item of content) {
if (item.text) {
resourcesContent += item.text;
}
}
}
}
执行到这里,Host 才真正拿到了 Resource 的文本内容。
第四步:把 Resource 放入 SystemMessage
拿到 resourcesContent 以后,应用主动创建 SystemMessage:
js
const messages = [
new SystemMessage(resourcesContent),
new HumanMessage(query),
];
至此,Resource 才真正进入模型上下文。
完整过程是:
text
MCP Server 注册 Resource
↓
Host 调用 listResources() 发现资源
↓
Host 调用 readResource() 读取正文
↓
Host 筛选、整理资源内容
↓
Host 创建 SystemMessage
↓
LLM 在 messages 中看到 Resource
这个 Demo 直接拼接了所有文本内容,适合资源数量很少的最小示例。
真实项目通常还要根据 URI、名称、用户权限和当前任务筛选资源,不能不加判断地把所有内容全部放入 Prompt。
Resource 为什么不需要 ToolMessage?
ToolMessage 用来回传模型已经发起的 Tool Call 结果,因此必须带上对应的 tool_call_id。
Resource 的流程不同。
它在第一次调用模型之前,就已经由 Host 读取并放进 SystemMessage:
text
SystemMessage(Resource 内容)
HumanMessage(用户问题)
→ LLM
模型没有先生成 Resource Call,Runtime 也没有执行 Tool Call,因此这里不需要 ToolMessage。
如果用户询问:
text
MCP Server 使用指南是什么?
模型可以直接根据 SystemMessage 中的指南回答。
如果用户改问:
text
查询 ID 为 001 的用户信息。
模型才需要发起 query_user Tool Call,Runtime 执行后再通过 ToolMessage 返回查询结果。
同一个 MCP Server 中,两条链路可以同时存在:
text
Resource:Host 读取 → SystemMessage → LLM
Tool:LLM 发起 → Runtime 执行 → ToolMessage → LLM
Resource 和 RAG 有什么区别?
Resource 与 RAG 都可以为模型补充外部知识,但处理方式不同。
当前 Demo 的使用指南很短,可以一次性放进上下文:
text
读取完整 Resource → 放入 SystemMessage
如果资料变成几百份文档、几十万段文本,就不适合全部塞入 Prompt。这时更适合先建立向量索引,再根据问题只检索相关片段:
text
用户问题 → RAG 检索相关片段 → 放入 Prompt
可以这样区分:
| 场景 | 更适合的方式 |
|---|---|
| 简短、稳定、每次都需要的说明 | 直接读取 MCP Resource |
| 大量文档,需要按问题查找相关片段 | RAG 检索 |
| 需要执行查询、写文件或调用接口 | MCP Tool |
Resource 不是 RAG 的替代品,RAG 也不是 Resource 的替代品。它们解决的是不同规模和不同控制方式的上下文问题。
任务结束后仍然要关闭 Client
这个 Demo 通过 command + args 启动本地 MCP Server 子进程。
完成任务后需要关闭连接:
js
await mcpClient.close();
否则子进程和 stdio 通信通道可能仍然存在,Node.js 脚本就会一直保持运行。
更稳妥的写法是放进 finally:
js
try {
await runAgentWithTools(query);
} finally {
await mcpClient.close();
}
无论模型回答成功还是中途报错,都能释放 MCP 连接和子进程。
总结
registerResource() 只负责在 MCP Server 中声明资源,并不会自动修改模型上下文。
Resource 真正进入模型,需要 Host 明确完成四步:
text
listResources()
→ readResource(serverName, uri)
→ 整理资源文本
→ SystemMessage(resourcesContent)
这也解释了 MCP Resource 的核心控制关系:
text
Server 负责提供资源
Client 负责发现和读取资源
Host 负责筛选并注入上下文
LLM 负责理解内容并生成答案
模型不会自动读取所有资料,也不应该自动获得所有资料。把什么内容放入上下文、什么时候放、允许谁读取,最终仍然是 AI 应用需要负责的工程问题。