绝大多数 MCP 教程都从 Tools(工具)原语讲起,因为它最直观、最像"函数调用"。但 MCP 的三大原语里,Resources(资源)原语才是承载"上下文"的那一块——它让服务器把数据以可寻址、可列举、可订阅的方式暴露给客户端。本教程带你用 TypeScript 官方 SDK,从零构建一个 devnotes MCP 服务器:把一个目录里的 Markdown 笔记变成一组资源,支持静态资源、URI 模板(动态资源)、二进制资源,并配上一套实时订阅推送机制——笔记一改,客户端立刻收到更新通知。适合已经跑通过至少一个 MCP 服务器、想把"被动文件"升级为"主动知识源"的中级开发者。
fs.watch 的 recursive 选项)claude mcp add 接入过至少一个本地服务器(不熟悉可先补"斜杠命令与快捷键速查手册")@modelcontextprotocol/inspector 用于调试很多初学者的第一反应是:"把笔记内容读出来,写个 Tool 不就行了?"——可以,但会丢掉三样东西。
| 维度 | Tools(工具) | Resources(资源) |
|---|---|---|
| 语义 | 动作:让模型去"做"某事(有副作用、可失败) | 数据:让模型去"看"某物(只读上下文) |
| 寻址 | 按工具名 + 参数调用 | 按 URI 唯一寻址(如 notes://arch.md) |
| 发现 | tools/list 列出能力 |
resources/list 列出数据,resources/templates/list 列出模式 |
| 时效 | 调用即返回,无状态 | 可订阅:数据变了服务器主动推送 |
| 客户端呈现 | 通常作为"可执行操作" | 通常作为"可引用的上下文卡片" |
一句话记住分工:Tools 是"动词",Resources 是"名词",Prompts 是"剧本"。把笔记、文档、配置、数据库快照这类"被引用的上下文"做成 Resources,模型就能像查字典一样按 URI 精准取用,而不是每次都触发一次"工具调用"。
一个资源由四要素描述:
uri:全局唯一标识,自定义协议即可(notes://、postgres://、file:///)name:人类可读名称description:可选说明mimeType:可选类型(text/markdown、application/json、image/png……)围绕它的操作分三类:
resources/list(列静态资源)、resources/templates/list(列 URI 模板)resources/read(按 URI 取内容;内容可以是 text 字符串,也可以是 base64 的 blob)resources/subscribe / resources/unsubscribe,配合服务器推送的 notifications/resources/updated(内容变了)与 notifications/resources/list_changed(列表变了)其中资源模板用 RFC 6570 风格的 URI 模板描述一族资源,例如 notes://{slug}.md——客户端拿到模板后,可以自己拼出 notes://arch.md 去 read,而不必把成千上万篇笔记全列在 list 里。
没有订阅时,客户端只能轮询(反复 read),既浪费又迟钝。有了订阅,交互变成推模式:
resources/subscribenotifications/resources/updatedread 拿到最新内容这就把 MCP 服务器从"问答机器"升级成"事件源"——本教程的 devnotes 服务器会监听笔记目录,一旦你保存文件,正在用 Claude Code 的对话窗口就能感知到"上下文变了"。
mkdir devnotes-mcp && cd devnotes-mcp
npm init -y
npm i @modelcontextprotocol/sdk
npm i -D typescript @types/node tsx
把 package.json 改成 ESM,并加两条脚本:
{
"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:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
再建一个示例笔记目录 notes/,放两篇 Markdown 和一张占位图:
mkdir -p notes
printf '# 架构\n\n采用分层设计……\n' > notes/arch.md
printf '# 发布流程\n\n1. 构建\n2. 测试\n3. 发布\n' > notes/release.md
printf '' > notes/snapshot.png # 占位,真实场景放任意 PNG
新建 src/index.ts。MCP 服务器的"能力声明"非常关键——只有你在 capabilities.resources 里声明了 subscribe: true 和 listChanged: true,客户端才会认为这个服务器支持订阅和列表变更通知。
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 },
},
}
);
index 与 changelog静态资源是 URI 固定、内容可变的数据。我们先实现一个工具函数列出目录里的笔记,再实现 resources/list 和 resources/read。
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,先把两个静态资源读出来:
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}`);
});
notes://{slug}.md模板让客户端按需取任意一篇笔记。先用 resources/templates/list 把模板声明出去:
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
resourceTemplates: [
{
uriTemplate: "notes://{slug}.md",
name: "单篇笔记",
description: "按 slug 读取一篇 Markdown 笔记",
mimeType: "text/markdown",
},
],
}));
然后在 resources/read 里加一个分支,用正则匹配 notes://{slug}.md 形态的 URI:
// —— 模板: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 之前。
blob注意上面 snapshot.png 的返回值:二进制内容不用 text,而用 blob 字段,放 base64 编码的字符串,并设置 mimeType。这是 Resources 原语对图片、PDF、音视频等非文本内容的统一表达。客户端拿到后会按 MIME 类型决定是内联展示还是交给模型描述。
这是整篇教程的核心。订阅由两步组成:记录谁订阅了什么,以及在数据变化时推送通知。
// 客户端订阅某 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",
});
});
}
两个关键点:
updated 前先检查 subscriptions:协议上,服务器只应对已订阅的 URI 发 updated 通知,否则客户端会忽略甚至报错。index 和 changelog 这类汇总资源的内容也跟着变,所以要把它们一起标记为"已更新"。最后把服务器接到 stdio 传输层,并启动监听:
const transport = new StdioServerTransport();
await server.connect(transport);
startWatcher();
console.error("[devnotes] 已启动,监听目录:", NOTES_DIR);
注意:日志用
console.error打到 stderr。stdio 传输里,stdout 是 JSON-RPC 通道,任何 stdout 输出都会破坏协议,这是新手最常见的坑。
先用 Inspector 单独验证(不依赖 Claude Code):
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:
{
"mcpServers": {
"devnotes": {
"command": "npx",
"args": ["tsx", "src/index.ts"],
"env": { "NOTES_DIR": "./notes" }
}
}
}
或用命令行注册(二者等效,任选其一):
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 运行):
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);
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
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 之内。
list/read/subscribe 与通知的权威文本McpServer 高级封装与 ResourceTemplate 用法(本教程用低层 Server 换取对订阅的完全控制,二者可对照学习)| 维度 | 评估 |
|---|---|
| 适合训练营 | "MCP 服务器开发"中级营、"AI 上下文工程"进阶营、"开发者工具链"实战营 |
| 学员基础 | 需跑通过至少一个 MCP 服务器,理解 JSON-RPC 基本概念 |
| 教学时长 | 约 2.5–3 小时(讲解原语 40min + 跟练 90min + 订阅调试与排错 40min) |
| 可扩展练习 | ① 把 text 笔记换成从 SQLite 读资源;② 增加 listChanged 下的增量 list;③ 改用 Streamable HTTP transport 支持多客户端订阅 |
| 评估要点 | 能区分 Tools/Resources 边界、能正确声明能力、能让 updated 通知真正被客户端接收 |
ResourceTemplate 用法[!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 等场景全覆盖。
