<!-- title: Resources原语：构建可订阅知识源 -->

## 概述

绝大多数 MCP 教程都从 Tools（工具）原语讲起，因为它最直观、最像"函数调用"。但 MCP 的三大原语里，**Resources（资源）原语**才是承载"上下文"的那一块——它让服务器把数据以**可寻址、可列举、可订阅**的方式暴露给客户端。本教程带你用 TypeScript 官方 SDK，从零构建一个 `devnotes` MCP 服务器：把一个目录里的 Markdown 笔记变成一组资源，支持静态资源、URI 模板（动态资源）、二进制资源，并配上一套**实时订阅推送**机制——笔记一改，客户端立刻收到更新通知。适合已经跑通过至少一个 MCP 服务器、想把"被动文件"升级为"主动知识源"的中级开发者。

## 前置条件

- 已安装 Node.js 18+（推荐 20+，本教程用原生 `fs.watch` 的 `recursive` 选项）
- 能看懂 TypeScript 基础语法、了解 ES Module
- 用 `claude mcp add` 接入过至少一个本地服务器（不熟悉可先补"斜杠命令与快捷键速查手册"）
- 建议装好 `@modelcontextprotocol/inspector` 用于调试

---

## 一、Resources 为什么独立于 Tools

很多初学者的第一反应是："把笔记内容读出来，写个 Tool 不就行了？"——可以，但会丢掉三样东西。

| 维度 | Tools（工具） | Resources（资源） |
|---|---|---|
| 语义 | **动作**：让模型去"做"某事（有副作用、可失败） | **数据**：让模型去"看"某物（只读上下文） |
| 寻址 | 按工具名 + 参数调用 | 按 **URI** 唯一寻址（如 `notes://arch.md`） |
| 发现 | `tools/list` 列出能力 | `resources/list` 列出数据，`resources/templates/list` 列出模式 |
| 时效 | 调用即返回，无状态 | **可订阅**：数据变了服务器主动推送 |
| 客户端呈现 | 通常作为"可执行操作" | 通常作为"可引用的上下文卡片" |

一句话记住分工：**Tools 是"动词"，Resources 是"名词"，Prompts 是"剧本"**。把笔记、文档、配置、数据库快照这类"被引用的上下文"做成 Resources，模型就能像查字典一样按 URI 精准取用，而不是每次都触发一次"工具调用"。

## 二、Resources 原语的四要素与三类操作

一个资源由四要素描述：

- **`uri`**：全局唯一标识，自定义协议即可（`notes://`、`postgres://`、`file:///`）
- **`name`**：人类可读名称
- **`description`**：可选说明
- **`mimeType`**：可选类型（`text/markdown`、`application/json`、`image/png`……）

围绕它的操作分三类：

1. **列举**：`resources/list`（列静态资源）、`resources/templates/list`（列 URI 模板）
2. **读取**：`resources/read`（按 URI 取内容；内容可以是 `text` 字符串，也可以是 base64 的 `blob`）
3. **订阅**：`resources/subscribe` / `resources/unsubscribe`，配合服务器推送的 `notifications/resources/updated`（内容变了）与 `notifications/resources/list_changed`（列表变了）

其中**资源模板**用 RFC 6570 风格的 URI 模板描述一族资源，例如 `notes://{slug}.md`——客户端拿到模板后，可以自己拼出 `notes://arch.md` 去 `read`，而不必把成千上万篇笔记全列在 `list` 里。

## 三、订阅模型：为什么它改变交互范式

没有订阅时，客户端只能**轮询**（反复 `read`），既浪费又迟钝。有了订阅，交互变成**推模式**：

1. 客户端对感兴趣的 URI 调 `resources/subscribe`
2. 服务器记住"谁关心哪个 URI"
3. 数据变化时，服务器主动发 `notifications/resources/updated`
4. 客户端收到后重新 `read` 拿到最新内容

这就把 MCP 服务器从"问答机器"升级成"事件源"——本教程的 `devnotes` 服务器会监听笔记目录，一旦你保存文件，正在用 Claude Code 的对话窗口就能感知到"上下文变了"。

---

## 四、Step 1：初始化项目

```bash
mkdir devnotes-mcp && cd devnotes-mcp
npm init -y
npm i @modelcontextprotocol/sdk
npm i -D typescript @types/node tsx
```

把 `package.json` 改成 ESM，并加两条脚本：

```json
{
  "name": "devnotes-mcp",
  "version": "1.0.0",
  "type": "module",
  "bin": { "devnotes": "./dist/index.js" },
  "scripts": {
    "build": "tsc",
    "start": "tsx src/index.ts",
    "inspector": "npx @modelcontextprotocol/inspector npm run start"
  },
  "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0" },
  "devDependencies": { "tsx": "^4.0.0", "typescript": "^5.5.0", "@types/node": "^22.0.0" }
}
```

新建 `tsconfig.json`：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

```

再建一个示例笔记目录 `notes/`，放两篇 Markdown 和一张占位图：

```bash
mkdir -p notes
printf '# 架构\n\n采用分层设计……\n' > notes/arch.md
printf '# 发布流程\n\n1. 构建\n2. 测试\n3. 发布\n' > notes/release.md
printf '' > notes/snapshot.png   # 占位，真实场景放任意 PNG
```

## 五、Step 2：最小服务器骨架与能力声明

新建 `src/index.ts`。MCP 服务器的"能力声明"非常关键——**只有你在 `capabilities.resources` 里声明了 `subscribe: true` 和 `listChanged: true`，客户端才会认为这个服务器支持订阅和列表变更通知**。

```typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListResourcesRequestSchema,
  ListResourceTemplatesRequestSchema,
  ReadResourceRequestSchema,
  SubscribeRequestSchema,
  UnsubscribeRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { watch } from "node:fs";

const NOTES_DIR = process.env.NOTES_DIR ?? path.join(process.cwd(), "notes");

// stdio 是单连接场景，用一个 Set 跟踪当前订阅的 URI 即可
const subscriptions = new Set<string>();

const server = new Server(
  { name: "devnotes", version: "1.0.0" },
  {
    capabilities: {
      resources: { subscribe: true, listChanged: true },
    },
  }
);
```

## 六、Step 3：静态资源——`index` 与 `changelog`

静态资源是 URI 固定、内容可变的数据。我们先实现一个工具函数列出目录里的笔记，再实现 `resources/list` 和 `resources/read`。

```typescript
async function listNoteFiles(): Promise<{ slug: string; file: string }[]> {
  const entries = await fs.readdir(NOTES_DIR);
  return entries
    .filter((f) => f.endsWith(".md"))
    .map((f) => ({ slug: f.replace(/\.md$/, ""), file: f }));
}

// —— resources/list：列举静态资源 ——
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  const notes = await listNoteFiles();
  return {
    resources: [
      {
        uri: "notes://index",
        name: "笔记目录",
        description: "所有笔记的 slug、标题与摘要",
        mimeType: "application/json",
      },
      {
        uri: "notes://changelog",
        name: "最近更新",
        description: "按修改时间排序的前 5 篇笔记",
        mimeType: "text/markdown",
      },
    ],
  };
});
```

注意：**静态资源只列"固定入口"**，单篇笔记走模板，不在这里逐条列——否则笔记一多，`list` 会爆炸。

接着实现 `resources/read`，先把两个静态资源读出来：

```typescript
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;

  if (uri === "notes://index") {
    const notes = await listNoteFiles();
    const body = await Promise.all(
      notes.map(async (n) => {
        const raw = await fs.readFile(path.join(NOTES_DIR, n.file), "utf8");
        return {
          slug: n.slug,
          title: raw.split("\n")[0].replace(/^#\s*/, ""),
          preview: raw.slice(0, 80),
        };
      })
    );
    return {
      contents: [
        { uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) },
      ],
    };
  }

  if (uri === "notes://changelog") {
    const notes = await listNoteFiles();
    const statted = await Promise.all(
      notes.map(async (n) => ({
        slug: n.slug,
        mtime: (await fs.stat(path.join(NOTES_DIR, n.file))).mtimeMs,
      }))
    );
    statted.sort((a, b) => b.mtime - a.mtime);
    const lines = statted.slice(0, 5).map((s) => `- ${s.slug}`).join("\n");
    return {
      contents: [{ uri, mimeType: "text/markdown", text: `# 最近更新\n\n${lines}` }],
    };
  }

  // 模板与二进制资源在后续步骤补充
  throw new Error(`未知资源：${uri}`);
});
```

## 七、Step 4：资源模板——`notes://{slug}.md`

模板让客户端按需取任意一篇笔记。先用 `resources/templates/list` 把模板声明出去：

```typescript
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
  resourceTemplates: [
    {
      uriTemplate: "notes://{slug}.md",
      name: "单篇笔记",
      description: "按 slug 读取一篇 Markdown 笔记",
      mimeType: "text/markdown",
    },
  ],
}));
```

然后在 `resources/read` 里加一个分支，用正则匹配 `notes://{slug}.md` 形态的 URI：

```typescript
  // —— 模板：notes://{slug}.md ——
  const m = uri.match(/^notes:\/\/(.+)\.md$/);
  if (m) {
    // 白名单清洗 slug，防止路径穿越（如 ../../etc/passwd）
    const slug = m[1].replace(/[^\w-]/g, "");
    const fp = path.join(NOTES_DIR, `${slug}.md`);
    const text = await fs.readFile(fp, "utf8");
    return { contents: [{ uri, mimeType: "text/markdown", text }] };
  }

  if (uri === "notes://snapshot.png") {
    const buf = await fs.readFile(path.join(NOTES_DIR, "snapshot.png"));
    return {
      contents: [{ uri, mimeType: "image/png", blob: buf.toString("base64") }],
    };
  }
```

把这两段并入上面 `ReadResourceRequestSchema` 的 handler 里、`throw` 之前。

## 八、Step 5：二进制资源——`blob`

注意上面 `snapshot.png` 的返回值：二进制内容不用 `text`，而用 **`blob`** 字段，放 base64 编码的字符串，并设置 `mimeType`。这是 Resources 原语对图片、PDF、音视频等非文本内容的统一表达。客户端拿到后会按 MIME 类型决定是内联展示还是交给模型描述。

## 九、Step 6：订阅与实时推送

这是整篇教程的核心。订阅由两步组成：**记录谁订阅了什么**，以及**在数据变化时推送通知**。

```typescript
// 客户端订阅某 URI
server.setRequestHandler(SubscribeRequestSchema, async (req) => {
  subscriptions.add(req.params.uri);
  return {};
});

// 客户端取消订阅
server.setRequestHandler(UnsubscribeRequestSchema, async (req) => {
  subscriptions.delete(req.params.uri);
  return {};
});

// 文件监听：笔记一变就推送 updated 通知
function startWatcher() {
  watch(NOTES_DIR, { recursive: false }, async (_event, filename) => {
    if (!filename || !filename.endsWith(".md")) return;
    const slug = filename.replace(/\.md$/, "");
    const changedUri = `notes://${slug}.md`;
    // 一篇笔记变了，连带影响 index 与 changelog 这两个聚合资源
    const affected = new Set([changedUri, "notes://index", "notes://changelog"]);
    for (const u of affected) {
      if (subscriptions.has(u)) {
        await server.sendNotification({
          method: "notifications/resources/updated",
          params: { uri: u },
        });
      }
    }
    // 增删笔记会让"目录"本身变化 → 通知列表变了
    await server.sendNotification({
      method: "notifications/resources/list_changed",
    });
  });
}
```

两个关键点：

1. **推送 `updated` 前先检查 `subscriptions`**：协议上，服务器只应对**已订阅**的 URI 发 updated 通知，否则客户端会忽略甚至报错。
2. **聚合资源要级联**：单篇笔记变了，`index` 和 `changelog` 这类汇总资源的内容也跟着变，所以要把它们一起标记为"已更新"。

## 十、Step 7：启动 transport

最后把服务器接到 stdio 传输层，并启动监听：

```typescript
const transport = new StdioServerTransport();
await server.connect(transport);
startWatcher();
console.error("[devnotes] 已启动，监听目录:", NOTES_DIR);
```

> 注意：日志用 `console.error` 打到 stderr。stdio 传输里，**stdout 是 JSON-RPC 通道，任何 stdout 输出都会破坏协议**，这是新手最常见的坑。

## 十一、Step 8：接入 Claude Code 与 Inspector 验证

先用 Inspector 单独验证（不依赖 Claude Code）：

```bash
npm run inspector
# 浏览器打开提示的地址，依次点：
#   Resources 标签 → List → 应看到 index / changelog
#   Resource Templates 标签 → 应看到 notes://{slug}.md
#   在输入框填 notes://arch.md → Read → 看到正文
#   选中 notes://arch.md → Subscribe，然后去编辑 notes/arch.md 保存
#   右侧通知面板应立刻出现 resources/updated
```

确认无误后接入 Claude Code。在项目根写一个 `.mcp.json`：

```json
{
  "mcpServers": {
    "devnotes": {
      "command": "npx",
      "args": ["tsx", "src/index.ts"],
      "env": { "NOTES_DIR": "./notes" }
    }
  }
}
```

或用命令行注册（二者等效，任选其一）：

```bash
claude mcp add devnotes -- npx tsx src/index.ts
```

进入 Claude Code，输入 `/mcp` 应看到 `devnotes` 已连接。然后试一句：

> 帮我看看 `notes://arch.md` 这篇笔记讲了什么，并列出最近更新的笔记。

模型会通过 `resources/read` 取正文、通过 `read notes://changelog` 取更新列表，而不需要你写任何"读取笔记"的工具。

## 十二、完整代码清单

把前面片段拼起来，完整 `src/index.ts` 如下（可直接 `npm start` 运行）：

```typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListResourcesRequestSchema,
  ListResourceTemplatesRequestSchema,
  ReadResourceRequestSchema,
  SubscribeRequestSchema,
  UnsubscribeRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { watch } from "node:fs";

const NOTES_DIR = process.env.NOTES_DIR ?? path.join(process.cwd(), "notes");
const subscriptions = new Set<string>();

const server = new Server(
  { name: "devnotes", version: "1.0.0" },
  { capabilities: { resources: { subscribe: true, listChanged: true } } }
);

async function listNoteFiles() {
  const entries = await fs.readdir(NOTES_DIR);
  return entries
    .filter((f) => f.endsWith(".md"))
    .map((f) => ({ slug: f.replace(/\.md$/, ""), file: f }));
}

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    { uri: "notes://index", name: "笔记目录", description: "所有笔记的索引", mimeType: "application/json" },
    { uri: "notes://changelog", name: "最近更新", description: "前 5 篇最近修改的笔记", mimeType: "text/markdown" },
  ],
}));

server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
  resourceTemplates: [
    { uriTemplate: "notes://{slug}.md", name: "单篇笔记", description: "按 slug 读取笔记", mimeType: "text/markdown" },
  ],
}));

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;

  if (uri === "notes://index") {
    const notes = await listNoteFiles();
    const body = await Promise.all(notes.map(async (n) => {
      const raw = await fs.readFile(path.join(NOTES_DIR, n.file), "utf8");
      return { slug: n.slug, title: raw.split("\n")[0].replace(/^#\s*/, ""), preview: raw.slice(0, 80) };
    }));
    return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
  }

  if (uri === "notes://changelog") {
    const notes = await listNoteFiles();
    const statted = await Promise.all(notes.map(async (n) => ({
      slug: n.slug,
      mtime: (await fs.stat(path.join(NOTES_DIR, n.file))).mtimeMs,
    })));
    statted.sort((a, b) => b.mtime - a.mtime);
    return { contents: [{ uri, mimeType: "text/markdown", text: `# 最近更新\n\n${statted.slice(0, 5).map((s) => `- ${s.slug}`).join("\n")}` }] };
  }

  if (uri === "notes://snapshot.png") {
    const buf = await fs.readFile(path.join(NOTES_DIR, "snapshot.png"));
    return { contents: [{ uri, mimeType: "image/png", blob: buf.toString("base64") }] };
  }

  const m = uri.match(/^notes:\/\/(.+)\.md$/);
  if (m) {
    const slug = m[1].replace(/[^\w-]/g, "");
    const text = await fs.readFile(path.join(NOTES_DIR, `${slug}.md`), "utf8");
    return { contents: [{ uri, mimeType: "text/markdown", text }] };
  }

  throw new Error(`未知资源：${uri}`);
});

server.setRequestHandler(SubscribeRequestSchema, async (req) => { subscriptions.add(req.params.uri); return {}; });
server.setRequestHandler(UnsubscribeRequestSchema, async (req) => { subscriptions.delete(req.params.uri); return {}; });

function startWatcher() {
  watch(NOTES_DIR, { recursive: false }, async (_event, filename) => {
    if (!filename || !filename.endsWith(".md")) return;
    const slug = filename.replace(/\.md$/, "");
    for (const u of new Set([`notes://${slug}.md`, "notes://index", "notes://changelog"])) {
      if (subscriptions.has(u)) {
        await server.sendNotification({ method: "notifications/resources/updated", params: { uri: u } });
      }
    }
    await server.sendNotification({ method: "notifications/resources/list_changed" });
  });
}

const transport = new StdioServerTransport();
await server.connect(transport);
startWatcher();
console.error("[devnotes] 已启动，监听目录:", NOTES_DIR);
```

## 架构与订阅时序

```mermaid
flowchart LR
    subgraph 客户端["Claude Code / Inspector"]
        C1["resources/list\ntemplates/list"]
        C2["resources/read"]
        C3["resources/subscribe"]
    end
    subgraph 服务器["devnotes MCP 服务器 (stdio)"]
        H["setRequestHandler\n(list/templates/read/subscribe)"]
        W["fs.watch\n文件监听器"]
        S[("notes/ 目录\n*.md + *.png")]
    end
    C1 -->|列举| H
    C2 -->|读取 URI| H
    H --> S
    C3 -->|登记兴趣| H
    W -->|文件变化| S
    W -->|updated / list_changed| C3
```

```mermaid
sequenceDiagram
    participant Client as 客户端
    participant Server as devnotes 服务器
    participant FS as 笔记目录
    Client->>Server: resources/subscribe (notes://arch.md)
    Server-->>Client: 空结果（登记成功）
    Note over FS: 用户保存 arch.md
    FS->>Server: fs.watch 触发 change 事件
    Server->>Server: 检查 subscriptions 命中
    Server-->>Client: notifications/resources/updated {uri}
    Server-->>Client: notifications/resources/list_changed
    Client->>Server: resources/read (notes://arch.md)
    Server-->>Client: 最新正文
```

## 常见问题

**Q1：Inspector 里看不到 Resources 标签？**
A：说明服务器没声明资源能力，或 `resources/list` 返回了空数组。检查 `capabilities.resources` 是否写对、`ListResourcesRequestSchema` handler 是否真的返回了 `resources`。

**Q2：订阅后保存文件，客户端没收到通知？**
A：三步排查——① 服务器能力里是否声明了 `subscribe: true`；② 推送前是否检查了 `subscriptions.has(uri)`（未订阅的 URI 推了会被忽略）；③ 是否误把日志打到 stdout 破坏了协议。

**Q3：`notes://snapshot.png` 报错找不到文件？**
A：占位文件是空文件，二进制读取需要真实图片内容。换成任意 PNG 即可；同时确认走了 `blob` + base64 分支而非 `text`。

**Q4：Linux 下 `fs.watch` 的 `recursive` 选项无效？**
A：旧版 Node 在 Linux 上不支持递归监听。本教程设了 `recursive: false`，只监听单层目录；若笔记有子目录，改用 `chokidar` 包替代原生 `fs.watch`。

**Q5：模板 URI 会被恶意构造吗？**
A：会。示例里用 `slug.replace(/[^\w-]/g, "")` 做了白名单清洗，防止 `notes://../../etc/passwd.md` 这类路径穿越。生产环境务必保留这层过滤，或用 `path.resolve` 后校验仍落在 `NOTES_DIR` 之内。

## 延伸阅读

- **官方规范 · Resources（2025-06-18）**：定义 `list/read/subscribe` 与通知的权威文本
- **MCP TypeScript SDK 文档 · Server**：`McpServer` 高级封装与 `ResourceTemplate` 用法（本教程用低层 `Server` 换取对订阅的完全控制，二者可对照学习）
- **FastMCP · Resources & Templates**：Python 侧同等能力的实现，跨语言对照
- **MCP 概念文档 · Resources**：Resources 与 Tools/Prompts 的设计动机

## 训练营适用性评估

| 维度 | 评估 |
|---|---|
| 适合训练营 | "MCP 服务器开发"中级营、"AI 上下文工程"进阶营、"开发者工具链"实战营 |
| 学员基础 | 需跑通过至少一个 MCP 服务器，理解 JSON-RPC 基本概念 |
| 教学时长 | 约 2.5–3 小时（讲解原语 40min + 跟练 90min + 订阅调试与排错 40min） |
| 可扩展练习 | ① 把 `text` 笔记换成从 SQLite 读资源；② 增加 `listChanged` 下的增量 `list`；③ 改用 Streamable HTTP transport 支持多客户端订阅 |
| 评估要点 | 能区分 Tools/Resources 边界、能正确声明能力、能让 updated 通知真正被客户端接收 |

## 参考来源

1. [Resources — Model Context Protocol 规范（2025-06-18）](https://modelcontextprotocol.io/specification/2025-06-18/server/resources) — Resources 原语的官方规范，定义 list/read/subscribe 及 updated/list_changed 通知
2. [Server — MCP TypeScript SDK 文档](https://ts.sdk.modelcontextprotocol.io/documents/server.html) — 官方 TS SDK 的 Server 构造、传输层与 `ResourceTemplate` 用法
3. [Resources & Templates — FastMCP](https://fastmcp.wiki/en/servers/resources) — Python 侧 Resources 与模板的对照实现，便于跨语言理解
4. [Resources — MCP 概念文档](https://modelcontextprotocol.io/docs/concepts/resources) — Resources 设计动机与三大原语分工说明
5. [Complete Guide to MCP in 2026](https://dev.to/x4nent/complete-guide-to-mcp-model-context-protocol-in-2026-architecture-implementation-and-4a11) — 2026 年 MCP 架构、Streamable HTTP 与生产部署全景

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
