本教程面向已经能写出"只会暴露 Tools"的 MCP 服务器、却总觉得还差点意思的开发者。我们将把 MCP 协议里最被忽视的两个原语——Resources(资源)与Prompts(提示)——和 Tools 一起,装进同一个本地知识库服务器里,让你彻底搞懂"什么时候该用哪一种",并跑通一个可直接接入 Claude Code 的完整工程。
npm、npx/mcp)npx 拉包的机器如果你还没写过任何 MCP 服务器,建议先补一节"用 FastMCP 构建首个 Tool-only 服务器",再回到本篇。
绝大多数 MCP 教程止步于 Tools,于是很多开发者形成了一个误区:MCP = 给大模型挂一堆可调用的函数。这其实只用了协议的三分之一。MCP 把服务器能力显式分成了三种"原语",控制权归属各不相同:
| 原语 | 控制方 | 类比 | 语义 |
|---|---|---|---|
| Tools | 模型控制(Model-controlled) | POST /action |
做一件事,可能带副作用,返回结果 |
| Resources | 应用控制(Application-controlled) | GET /data |
通过稳定 URI 暴露可读数据 |
| Prompts | 用户控制(User-controlled) | 模板库 | 用参数化的消息模板结构化一次请求 |
一句话记忆:Tools 是动作,Resources 是数据,Prompts 是意图。
为什么这个区分重要?因为它决定了"谁在什么时候触发":
get_file 工具里。把这三者搞混,集成体验就会很别扭。最常见的两个错误:把只读数据做成 Tool(白白放弃了缓存与可寻址性,且每次都要模型主动去"取");把 Prompt 写得像死命令(太死板,输出像机器人)。本教程的工程会同时演示三者的正确用法。
我们用官方 @modelcontextprotocol/sdk 的高层 McpServer API,它把三种原语都收敛成一行注册,最省样板代码。
mkdir kb-server && cd kb-server
npm init -y
npm i @modelcontextprotocol/sdk zod
npm i -D typescript @types/node
把 package.json 的关键字段改成下面这样(关键是 "type": "module",SDK 走 ESM):
{
"name": "kb-server",
"version": "1.0.0",
"type": "module",
"bin": { "kb-server": "dist/index.js" },
"scripts": { "build": "tsc", "start": "node dist/index.js" }
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
⚠️ 常见坑:
moduleResolution必须是NodeNext(或Bundler),否则 TypeScript 解析不到.../server/mcp.js这种带.js后缀的子路径导出,报Cannot find module。
mkdir -p ~/notes
printf -- '---\ntags: [release, ops]\n---\n\n# 发布检查清单\n上线前确认:回滚方案、灰度比例、告警阈值。\n' > ~/notes/release-checklist.md
printf -- '# 欢迎笔记\n这是知识库的第一篇笔记。\n' > ~/notes/hello.md
这个 ~/notes 目录就是我们的"知识库",服务器会把里面的 Markdown 暴露成资源。
新建 src/index.ts,先写骨架和两个工具。注意高层 API 的签名是 server.tool(名称, 描述, zodSchema, 回调)。
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "node:fs/promises";
import path from "node:path";
const KB_DIR = process.env.KB_DIR ?? path.join(process.env.HOME ?? ".", "notes");
const server = new McpServer({
name: "kb-server",
version: "1.0.0",
});
// ---------- 工具:全文检索 ----------
server.tool(
"search_notes",
"对本地笔记做不区分大小写的全文检索,返回命中的文件名与上下文片段",
{ query: z.string().describe("关键词"), limit: z.number().int().positive().default(5) },
async ({ query, limit }) => {
const files = (await fs.readdir(KB_DIR)).filter((f) => f.endsWith(".md"));
const q = query.toLowerCase();
const hits: { file: string; snippet: string }[] = [];
for (const f of files) {
const text = await fs.readFile(path.join(KB_DIR, f), "utf8");
const idx = text.toLowerCase().indexOf(q);
if (idx >= 0) {
const start = Math.max(0, idx - 40);
hits.push({ file: f, snippet: text.slice(start, idx + q.length + 60) });
}
if (hits.length >= limit) break;
}
return { content: [{ type: "text", text: JSON.stringify(hits, null, 2) }] };
}
);
// ---------- 工具:新增笔记 ----------
server.tool(
"add_note",
"向知识库新增一篇笔记,标签写入 frontmatter",
{
title: z.string().describe("文件名(不含 .md)"),
body: z.string().describe("Markdown 正文"),
tags: z.array(z.string()).default([]).describe("标签列表"),
},
async ({ title, body, tags }) => {
const fm = tags.length ? `---\ntags: [${tags.join(", ")}]\n---\n\n` : "";
const file = path.join(KB_DIR, `${title}.md`);
await fs.writeFile(file, fm + body, "utf8");
return { content: [{ type: "text", text: `已写入:${file}` }] };
}
);
npm run build 后用 inspector 运行:
npx @modelcontextprotocol/inspector node dist/index.js
在弹出的 inspector 面板里调用 search_notes,入参 {"query":"回滚"},应返回:
[
{ "file": "release-checklist.md", "snippet": "上线前确认:回滚方案、灰度比例、告警阈值。" }
]
node dist/index.js 卡住不动:这是正常的。stdio 传输在等 JSON-RPC 输入,不是死循环。要用 inspector 或一个真正的 MCP 客户端去"对话"。z.number().default(5) 的参数可以省略;如果你写了 z.number() 又不传,模型会被协议约束为必填。这是大多数教程跳过的部分,也是本教程的重头戏。我们要做两件事:暴露一个静态资源 notes://index(笔记索引),以及一个动态资源模板 notes://{filename}(按 URI 读任意一篇笔记,并支持列出全部)。
继续在 src/index.ts 里追加:
// ---------- 资源:静态索引 ----------
server.resource(
"kb-index",
"notes://index",
async (uri) => {
const files = (await fs.readdir(KB_DIR)).filter((f) => f.endsWith(".md"));
const list = files.map((f) => `- [${f.replace(/\.md$/, "")}](notes://${f})`).join("\n");
return {
contents: [
{ uri: uri.href, mimeType: "text/markdown", text: `# 笔记索引\n\n${list}` },
],
};
}
);
// ---------- 资源:动态模板 notes://{filename} ----------
const noteTemplate = new ResourceTemplate("notes://{filename}", {
// list 回调:让客户端能"发现"有哪些笔记
list: async () => {
const files = (await fs.readdir(KB_DIR)).filter((f) => f.endsWith(".md"));
return {
resources: files.map((f) => ({
uri: `notes://${f}`,
name: f.replace(/\.md$/, ""),
mimeType: "text/markdown",
})),
};
},
});
server.resource("note", noteTemplate, async (uri, { filename }) => {
const abs = path.join(KB_DIR, `${filename}.md`);
const text = await fs.readFile(abs, "utf8");
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
});
"notes://index"),回调签名是 (uri) => {contents}。ResourceTemplate,模板变量 {filename} 会作为第二参数注入回调:(uri, { filename }) => {...}。list 回调是动态资源的灵魂。没有它,客户端就"看不见"你有哪些资源,只能瞎猜 URI。MCP 客户端(如 inspector、Claude Desktop)会调用 resources/list 来枚举,列表就来自这里。uri 是一个 URL 对象,用 uri.href 取字符串形式。在 inspector 的 Resources 面板,应能看到:
notes://index 资源,内容是"笔记索引"列表;notes://hello.md、notes://release-checklist.md(来自 list 回调),点击即可读取正文。💡 客户端支持差异(重要):Tools 是所有 MCP 客户端都会让模型自动调用的;但 Resources 与 Prompts 的 UI 呈现因客户端而异。Claude Desktop、inspector 会把 Resources 列在附件/资源面板,把 Prompts 列为可挑选的模板;Claude Code CLI 主要自动驱动工具调用,Resources/Prompts 的可见性取决于版本。因此调试 Resources/Prompts 时,请优先用 inspector 验证协议层正确性,再回到你的目标客户端确认展现。
Prompts 解决的是"我希望 AI 每次都用同一套姿势干某件事"。它返回的是一个 messages 数组(结构化消息),客户端会把它作为一次新对话的起点。我们写两个模板:一个把笔记总结成"要点/待办/术语",一个带来源标注做问答。
// ---------- Prompt:结构化总结一篇笔记 ----------
server.prompt(
"summarize-note",
"把一篇笔记总结为:三句话摘要 / 待办清单 / 术语表",
{ filename: z.string().describe("笔记文件名,如 release-checklist") },
({ filename }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `请阅读资源 notes://${filename}.md,然后输出:\n1. 三句话摘要\n2. 待办事项清单\n3. 文中出现的专业术语表`,
},
},
],
})
);
// ---------- Prompt:带来源标注的问答 ----------
server.prompt(
"qa-with-context",
"基于若干笔记回答问题,每条结论后用 [文件名] 标注来源",
{
question: z.string().describe("要回答的问题"),
notes: z.array(z.string()).describe("相关笔记文件名数组"),
},
({ question, notes }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `依据以下笔记回答问题,每条结论后用 [文件名] 标注来源。\n\n相关笔记:\n${notes
.map((n) => `- notes://${n}.md`)
.join("\n")}\n\n问题:${question}`,
},
},
],
})
);
qa-with-context 的模板文本里写的是资源 URI(notes://xxx.md),而不是把内容直接塞进去。这正是在"借力"第二原语:模板给出意图和指针,真正的数据由客户端按需读取相应 Resource。这就是三原语协同的精髓。最后把服务器接上 stdio 传输:
const transport = new StdioServerTransport();
await server.connect(transport);
npm run build,编译产物在 dist/index.js。完整工程到此就齐了——一个文件、三原语、可直接运行。
方式 A:命令行注册
claude mcp add kb-server -e KB_DIR=$HOME/notes -- node "$(pwd)/dist/index.js"
claude mcp list # 应能看到 kb-server
方式 B:项目级 .mcp.json(适合随仓库分发)
{
"mcpServers": {
"kb-server": {
"command": "node",
"args": ["./dist/index.js"],
"env": { "KB_DIR": "/home/me/notes" }
}
}
}
-e KB_DIR=...把环境变量注入子进程。如果你忘了设,服务器会回退到~/notes——这是KB_DIR ?? path.join(HOME, "notes")的兜底逻辑,避免直接崩。
进入 Claude Code 会话后输入 /mcp,应看到 kb-server 已连接,并列出其工具/资源/提示。试着下指令:
搜一下我笔记里关于"回滚"的内容
模型会自主调用 search_notes 工具并返回片段。再试:
帮我新建一篇笔记,标题 oncall,正文写"夜间值班三件事",标签 ops
模型会调用 add_note,文件立刻出现在 ~/notes。
npx @modelcontextprotocol/inspector node dist/index.js
在三个面板分别核对:Tools 能否调用成功、Resources 的 list 是否枚举出全部笔记、Prompts 选定后能否正确渲染出 messages。这是定位"到底是服务器没暴露,还是客户端没渲染"的最快手段。
| 现象 | 原因 | 解决 |
|---|---|---|
Cannot find module .../mcp.js |
模块解析配置错 | moduleResolution: "NodeNext" |
| 资源在客户端不可见 | 客户端未实现 Resources,或未调用 resources/list |
先用 inspector 确认 list 回调有返回;再确认目标客户端支持 |
add_note 写到了 ~/notes 而非预期目录 |
KB_DIR 未注入子进程 |
.mcp.json 的 env 或 claude mcp add -e 显式设置 |
| Prompt 输出像机器人 | 模板写得太死 | 只规定"分析什么/什么格式",不逐字规定措辞 |
| 动态资源读取 404 | 文件名带了 .md 后缀又被模板再拼 |
模板 URI 用 notes://{filename}.md 或回调里统一去后缀,二选一别重复 |
flowchart LR
subgraph Client["MCP 客户端 (Claude Code / Inspector / Desktop)"]
UI[用户输入]
end
UI -- "stdio (JSON-RPC)" --> Server
subgraph Server["kb-server (Node + TS SDK)"]
T[Tools<br/>search_notes · add_note]
R[Resources<br/>notes://index · notes://{filename}]
P[Prompts<br/>summarize-note · qa-with-context]
end
T --> FS[("~/notes/*.md")]
R --> FS
P -. "引用 URI" .-> R
style T fill:#e1f5ff,stroke:#039
style R fill:#e8f5e9,stroke:#2e7d32
style P fill:#fff4e1,stroke:#ef6c00
三种原语用不同颜色区分:Tools(蓝)写、Resources(绿)读、Prompts(橙)指挥,三者最终都落在同一个文件系统知识库上,且 Prompts 通过 URI 指针复用 Resources——这正是"合一"的含义。
再看一条典型的三原语协同时序:当你问"发布检查清单能直接上线吗",理想链路是——客户端先用 qa-with-context 这个 Prompt 给出"带来源标注回答"的意图框架,框架里引用了 notes://release-checklist.md 这个 Resource,模型按需读取该资源拿到数据,必要时再调 search_notes 这个 Tool 补充检索其他相关笔记,最后给出每条结论都带 [文件名] 的结构化答案。
我们用一个 kb-server 串起了 MCP 的全部三原语:
search_notes(读但带检索逻辑,模型按需触发)、add_note(写,带副作用)。notes://index(静态索引)+ notes://{filename}(动态模板,带 list 枚举),把整个知识库变成可寻址、可缓存、可被索引的数据面。summarize-note、qa-with-context,把"我每次都想要的固定分析姿势"沉淀成可复用模板,且模板通过 URI 指针与 Resources 协同。把它接入 Claude Code 后,你就拥有了一个真正"懂你的笔记"的本地知识助手——而且它的能力边界由协议三原语清晰界定,而不是一个什么都往里塞的臃肿工具集。
McpServer 高层 API、ResourceTemplate 的参考实现与示例。/mcp 查看已注册服务器,或查阅官方 MCP 配置章节了解 .mcp.json 的全部字段。~/notes 换成 API 文档目录、内部 Runbook、或某个微服务的 README 集合,三原语的结构完全复用,适合做结业项目。McpServer、ResourceTemplate、StdioServerTransport 的官方实现与用法。[!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 等场景全覆盖。
