MCP Resources:把数据"喂"给 AI 的正确姿势

周一上午,老李接了一个让他脸都绿了的电话。客户张总劈头盖脸:“你们那个 AI 客服怎么又说我们的合同条款是旧版的?去年十一月就改了,这都半年了!你们系统到底更不更新?”

老李挂了电话,一脸黑线地查后台。他发现 Prompt 里直接嵌了一段公司合同条款的文本——是去年十月的版本。他忘改了。这已经是本月第三次因为 Prompt 里的过时数据被投诉。

小王端着一杯拿铁走过来:“老李,要不要试试把数据从 Prompt 里抽出来?”

“抽出来放哪儿?放数据库里?那 AI 怎么读到?”老李没好气。

“用 MCP Resources。”小王把椅子拉近,“你知道你的 Prompt 现在多长吗?两万多字!里面三分之一是静态数据——产品介绍、合同条款、公司地址。不仅难维护,而且每次对话都把这些全部发给 AI,Token 烧得飞快。Resources 就是帮你把这些数据变成可按需访问的‘接口’,AI 真正需要时才去查,Prompt 瘦身了,数据更新也只需要改一处。”

老李皱起眉头:“说人话。”

“就像你以前把整个合同档案室搬到了办公桌上,每次有人问就从头翻一遍。现在你把档案室恢复成原来的样子,需要哪份合同,叫秘书去档案室调出来。”


Resource 是什么:给每份数据一个“门牌号”

小王打开笔记本,调出一个 MCP Server 的配置文件。“MCP 把数据抽象成 Resource,每个 Resource 都有一个 URI,就像每本书在图书馆里有一个索书号。AI 需要数据时,不是翻整本 Prompt,而是用 URI 去请求。”

“比如,你们公司的产品介绍,可以定义成 docs://products/api-gateway。AI 发现用户在问 API 网关的功能,它不会去搜那两万字的 Prompt 海洋,而是直接请求 docs://products/api-gateway,拿到最新版本的产品介绍,再回复用户。”

老李若有所思:“那如果用户问的是‘所有产品的价格’,不可能一个一个 URI 去请求吧?”

“你可以定义参数化的 URI 模板。就像 REST API 一样——docs://products/{product_id},AI 可以传不同的 product_id 去拉取不同产品的数据。这不是 Prompt 里能动态做到的。”

typescript
// MCP Server 中定义 Resource 及 URI 模板
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new Server({
  name: "company-docs-server",
  version: "1.0.0",
}, {
  capabilities: { resources: {} }
});

// 静态 Resource:公司地址
server.setRequestHandler("resources/list", async () => ({
  resources: [{
    uri: "company://address",
    name: "公司地址",
    mimeType: "text/plain",
    description: "公司总部地址和联系方式"
  }]
}));

server.setRequestHandler("resources/read", async (request) => {
  if (request.params.uri === "company://address") {
    return {
      contents: [{
        uri: request.params.uri,
        mimeType: "text/plain",
        text: "公司地址:北京市海淀区中关村软件园 A 座 1001 室,电话 010-12345678"
      }]
    };
  }
  throw new Error(`Resource not found: ${request.params.uri}`);
});

“看到没?”小王指着代码,“Resource 不嵌入 Prompt,不改 AI 的代码逻辑。客户条款更新了,只需要改 Server 里那个 text 字符串。AI 下次请求自动拿到新数据。”


URI 模板:让资源“活”起来

老李翻出他之前的产品手册 Prompt,指着一大段文字:“那这种按产品 ID 来查的怎么办?我总不能列几十个静态 URI。”

“用 URI 模板。”小王在原来的 Server 代码上添了几行。

typescript
// 添加参数化 Resource:docs://products/{productId}
server.setRequestHandler("resources/list", async () => ({
  resources: [{
    uri: "docs://products/{productId}",
    name: "产品文档",
    mimeType: "text/markdown",
    description: "根据产品 ID 获取详细的产品介绍文档"
  }]
}));

server.setRequestHandler("resources/read", async (request) => {
  const uri = request.params.uri;
  const match = uri.match(/^docs:\/\/products\/(.+)$/);
  if (match) {
    const productId = match[1];
    // 从实际数据库或文件系统读取
    const doc = await getProductDoc(productId);
    return {
      contents: [{
        uri,
        mimeType: "text/markdown",
        text: doc
      }]
    };
  }
  throw new Error(`Resource not found: ${uri}`);
});

“AI 知道了模板 docs://products/{productId},当它需要某个产品的文档时,就会用具体的 ID 去构造 URI 并请求。你的 Prompt 里只需要一句‘相关产品文档可通过 MCP Resource 获取’,不需要把整本手册贴进去。”

老李拿起保温杯喝了一口,点点头:“这个模板跟 OpenAPI 的 path parameter 挺像。AI 怎么知道 productId 应该填什么?”

“靠列表接口。MCP 可以提供一个 resources/list 返回所有可用资源。你还可以做一个 docs://products 的静态 Resource,列出所有产品 ID 和名称。AI 可以先查列表,再根据列表去取具体文档——两步操作,很像人在翻目录。”


不止文本:内容类型与二进制

“那如果我的资源不是纯文本呢?比如产品架构图是 PNG,或者 API 文档是 JSON?”老李追问道。

“MCP Resource 支持多种 MIME 类型。text/plaintext/markdownapplication/json,甚至 image/png。对于二进制资源,SDK 返回 base64 编码的 blob。AI 如果是多模态的,可以直接看图片;如果不是,至少能收到文件大小和类型信息。”

小王又展示了一个读取图片资源的示例:

typescript
// 二进制 Resource 示例
server.setRequestHandler("resources/read", async (request) => {
  if (request.params.uri === "images://architecture") {
    const imageBuffer = fs.readFileSync("arch.png");
    return {
      contents: [{
        uri: request.params.uri,
        mimeType: "image/png",
        blob: imageBuffer.toString("base64")
      }]
    };
  }
  throw new Error(`Resource not found: ${request.params.uri}`);
});

“设计 Resource 的时候,你可以根据内容选择最合适的 MIME 类型。合同用 Markdown,表格用 JSON,架构图用 PNG。AI 的上下文处理能力会越来越强,现在结构化数据给它 JSON 比纯文本效果好得多。”

老李忽然问:“如果我更新了产品文档,AI 怎么知道该重新请求?”


订阅机制:数据更新时,AI 不用被蒙在鼓里

“这是 MCP 比 REST 更聪明的地方。Server 可以主动发通知——notifications/resources/updated。Client 收到通知后,可以把缓存的 Resource 标记为过期,下次 AI 用到时会重新拉取。这样你就不会因为 Prompt 没改而用旧数据。”

小王把订阅相关的代码也加到 Server 里:

typescript
// 在 Server 初始化时声明支持资源订阅
const server = new Server({
  name: "company-docs-server",
  version: "1.0.0",
}, {
  capabilities: {
    resources: { subscribe: true }  // 关键:声明支持订阅
  }
});

// 当文档更新时,发送通知给所有订阅了该 URI 的 Client
async function onProductDocUpdated(productId: string) {
  server.notification({
    method: "notifications/resources/updated",
    params: { uri: `docs://products/${productId}` }
  });
}

“这个通知机制,要求 Client 也支持。目前 Claude Desktop 能接收到这类通知并自动刷新资源缓存。就算你的 AI 应用暂时不支持实时刷新,至少你可以用这个通知触发一个 webhook,人工检查一下更新。”

老李听完,靠在椅背上沉默了一会儿。然后他把之前那两万字的 Prompt 文档打开,用鼠标选中全部硬编码的数据部分,按下了 Delete。

“这些全删。改用 MCP Resources。你给我写个资源清单——公司地址、产品文档、合同条款、还有那个老出问题的年假政策。”

“老李,你这效率——”

“下不为例啊。”老李赶紧绷住脸,但嘴角已经翘了起来,“对了,你刚才说的订阅通知,如果 Client 不在线,它怎么知道错过了更新?Server 有没有类似事件序号的东西?你给我看看那部分的协议定义。”

小王咧嘴一笑,打开了 MCP 的 spec 文档。窗外阳光正好,老李的 Prompt 终于从一个“杂物间”变成了一个干净的“调度中心”,那些硬编码的数据碎片找到了各自的 URI 门牌号,安静地等待着 AI 的按需探访。

MCP Tools 和 Prompts:Agent 的左膀右臂
30 分钟搭建你的第一个 MCP Server(Python 版)