<!-- title: 三原语合一的MCP服务器实战 -->

## 概述

本教程面向已经能写出"只会暴露 Tools"的 MCP 服务器、却总觉得还差点意思的开发者。我们将把 MCP 协议里最被忽视的两个原语——**Resources（资源）**与**Prompts（提示）**——和 Tools 一起，装进同一个本地知识库服务器里，让你彻底搞懂"什么时候该用哪一种"，并跑通一个可直接接入 Claude Code 的完整工程。

## 前置条件

- Node.js ≥ 18，能熟练使用 `npm`、`npx`
- 已安装 Claude Code CLI，并跑通过至少一个 MCP 服务器（能在会话里看到 `/mcp`）
- 了解 TypeScript 基本语法与 ESM 模块
- 一台能联网、能 `npx` 拉包的机器

如果你还没写过任何 MCP 服务器，建议先补一节"用 FastMCP 构建首个 Tool-only 服务器"，再回到本篇。

---

## 一、为什么只懂 Tools 还不够：三原语的心智模型

绝大多数 MCP 教程止步于 Tools，于是很多开发者形成了一个误区：**MCP = 给大模型挂一堆可调用的函数**。这其实只用了协议的三分之一。MCP 把服务器能力显式分成了三种"原语"，控制权归属各不相同：

| 原语 | 控制方 | 类比 | 语义 |
|---|---|---|---|
| **Tools** | 模型控制（Model-controlled） | `POST /action` | 做一件事，可能带副作用，返回结果 |
| **Resources** | 应用控制（Application-controlled） | `GET /data` | 通过稳定 URI 暴露可读数据 |
| **Prompts** | 用户控制（User-controlled） | 模板库 | 用参数化的消息模板结构化一次请求 |

一句话记忆：**Tools 是动作，Resources 是数据，Prompts 是意图。**

为什么这个区分重要？因为它决定了"谁在什么时候触发"：

- Tools 由**模型**自主决定何时调用——所以带副作用、调外部 API、写文件的操作放这里。
- Resources 由**客户端应用**按 URI 去读，常常被缓存、被索引——所以"模型需要知道的事实"（代码库、配置、文档）放这里，而不是塞进一个 `get_file` 工具里。
- Prompts 由**用户**主动挑选——所以"我每次都希望 AI 按同一套清单来做某件事"的固定套路放这里。

把这三者搞混，集成体验就会很别扭。最常见的两个错误：把只读数据做成 Tool（白白放弃了缓存与可寻址性，且每次都要模型主动去"取"）；把 Prompt 写得像死命令（太死板，输出像机器人）。本教程的工程会同时演示三者的正确用法。

## 二、工程骨架：用 TypeScript SDK 搭起服务器

我们用官方 `@modelcontextprotocol/sdk` 的高层 `McpServer` API，它把三种原语都收敛成一行注册，最省样板代码。

### 2.1 初始化项目

```bash
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）：

```json
{
  "name": "kb-server",
  "version": "1.0.0",
  "type": "module",
  "bin": { "kb-server": "dist/index.js" },
  "scripts": { "build": "tsc", "start": "node dist/index.js" }
}
```

### 2.2 tsconfig.json

```json
{
  "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`。

### 2.3 准备数据目录

```bash
mkdir -p ~/notes
printf -- '---\ntags: [release, ops]\n---\n\n# 发布检查清单\n上线前确认：回滚方案、灰度比例、告警阈值。\n' > ~/notes/release-checklist.md
printf -- '# 欢迎笔记\n这是知识库的第一篇笔记。\n' > ~/notes/hello.md
```

这个 `~/notes` 目录就是我们的"知识库"，服务器会把里面的 Markdown 暴露成资源。

## 三、第一原语 Tools：检索与写笔记

新建 `src/index.ts`，先写骨架和两个工具。注意高层 API 的签名是 `server.tool(名称, 描述, zodSchema, 回调)`。

```typescript
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 运行：

```bash
npx @modelcontextprotocol/inspector node dist/index.js
```

在弹出的 inspector 面板里调用 `search_notes`，入参 `{"query":"回滚"}`，应返回：

```json
[
  { "file": "release-checklist.md", "snippet": "上线前确认：回滚方案、灰度比例、告警阈值。" }
]
```

### 常见问题

- **直接 `node dist/index.js` 卡住不动**：这是正常的。stdio 传输在等 JSON-RPC 输入，不是死循环。要用 inspector 或一个真正的 MCP 客户端去"对话"。
- **工具入参类型报错**：SDK 用 zod 推断类型，`z.number().default(5)` 的参数可以省略；如果你写了 `z.number()` 又不传，模型会被协议约束为必填。

## 四、第二原语 Resources：把笔记目录变成可寻址资源

这是大多数教程跳过的部分，也是本教程的重头戏。我们要做两件事：暴露一个**静态资源** `notes://index`（笔记索引），以及一个**动态资源模板** `notes://{filename}`（按 URI 读任意一篇笔记，并支持列出全部）。

继续在 `src/index.ts` 里追加：

```typescript
// ---------- 资源：静态索引 ----------
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 }] };
});
```

### 关键点解读

- **静态资源**用字符串 URI（`"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：可复用的分析模板

Prompts 解决的是"我希望 AI 每次都用同一套姿势干某件事"。它返回的是一个 `messages` 数组（结构化消息），客户端会把它作为一次新对话的起点。我们写两个模板：一个把笔记总结成"要点/待办/术语"，一个带来源标注做问答。

```typescript
// ---------- 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。这就是三原语协同的精髓。
- Prompt 给的是**结构**（分析什么、按什么格式），而不是**死指令**（逐字规定每个字）。留出"怎么做"的空间，输出才不会僵化。

## 六、启动入口

最后把服务器接上 stdio 传输：

```typescript
const transport = new StdioServerTransport();
await server.connect(transport);
```

`npm run build`，编译产物在 `dist/index.js`。完整工程到此就齐了——一个文件、三原语、可直接运行。

## 七、接入 Claude Code 并验证

### 7.1 注册服务器（两种等价方式）

**方式 A：命令行注册**

```bash
claude mcp add kb-server -e KB_DIR=$HOME/notes -- node "$(pwd)/dist/index.js"
claude mcp list          # 应能看到 kb-server
```

**方式 B：项目级 `.mcp.json`**（适合随仓库分发）

```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")` 的兜底逻辑，避免直接崩。

### 7.2 在会话里验证

进入 Claude Code 会话后输入 `/mcp`，应看到 `kb-server` 已连接，并列出其工具/资源/提示。试着下指令：

```
搜一下我笔记里关于"回滚"的内容
```

模型会自主调用 `search_notes` 工具并返回片段。再试：

```
帮我新建一篇笔记，标题 oncall，正文写"夜间值班三件事"，标签 ops
```

模型会调用 `add_note`，文件立刻出现在 `~/notes`。

### 7.3 用 inspector 做协议级体检

```bash
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` 或回调里统一去后缀，二选一别重复 |

## 九、整体工作流

```text
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 的全部三原语：

- **Tools**：`search_notes`（读但带检索逻辑，模型按需触发）、`add_note`（写，带副作用）。
- **Resources**：`notes://index`（静态索引）+ `notes://{filename}`（动态模板，带 `list` 枚举），把整个知识库变成可寻址、可缓存、可被索引的数据面。
- **Prompts**：`summarize-note`、`qa-with-context`，把"我每次都想要的固定分析姿势"沉淀成可复用模板，且模板通过 URI 指针与 Resources 协同。

把它接入 Claude Code 后，你就拥有了一个真正"懂你的笔记"的本地知识助手——而且它的能力边界由协议三原语清晰界定，而不是一个什么都往里塞的臃肿工具集。

## 延伸阅读

- **MCP 官方·理解 MCP 服务器**：<https://modelcontextprotocol.io/docs/learn/server-concepts>——三种原语与控制权归属的权威定义。
- **TypeScript SDK 仓库**：<https://github.com/modelcontextprotocol/typescript-sdk>——`McpServer` 高层 API、`ResourceTemplate` 的参考实现与示例。
- **MCP Inspector**：<https://github.com/modelcontextprotocol/inspector>——本地调试 Resources/Prompts 的必备体检工具。
- **MCP Resources 与 Tools 的区别（Medium）**：<https://medium.com/@laurentkubaski/mcp-resources-explained-and-how-they-differ-from-mcp-tools-096f9d15f767>——专门讲为什么只读数据应该做成 Resource。
- **Claude Code MCP 文档**：在 CLI 内运行 `/mcp` 查看已注册服务器，或查阅官方 MCP 配置章节了解 `.mcp.json` 的全部字段。

## 训练营适用性评估

- **适合的营型**：MCP/Agent 开发训练营的**中后段进阶课**。学员应已能独立写出一个 Tool-only 服务器，本节负责把认知从"挂函数"升级到"设计能力面"。
- **不适合**：纯零基础营。三原语的控制权差异需要先有"模型自主调用工具"的直觉打底。
- **建议教学时长**：3–4 课时。1 课时讲心智模型与取舍判断，2 课时手敲三原语编码，1 课时用 inspector 做协议级调试与小组互评。
- **可替换的实战选题**：把 `~/notes` 换成 API 文档目录、内部 Runbook、或某个微服务的 README 集合，三原语的结构完全复用，适合做结业项目。

## 参考来源

1. [MCP Server Patterns: Tools vs Resources vs Prompts (When to Use Each) — DEV Community](https://dev.to/webbywisp/mcp-server-patterns-tools-vs-resources-vs-prompts-when-to-use-each-5bgp) — 三原语"动作/数据/意图"心智模型与 TypeScript API 示例，本文代码骨架的主要依据。
2. [Understanding MCP features: Tools, Resources, Prompts, Sampling, Roots, and Elicitation — WorkOS](https://workos.com/blog/mcp-features-guide) — MCP 六大特性的系统讲解，含控制权归属与安全考量。
3. [Understanding MCP servers — Model Context Protocol 官方文档](https://modelcontextprotocol.io/docs/learn/server-concepts) — 服务器概念、原语定义与上下文感知 Prompt 的权威来源。
4. [Model Context Protocol TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) — `McpServer`、`ResourceTemplate`、`StdioServerTransport` 的官方实现与用法。
5. [MCP Resources explained (and how they differ from MCP Tools) — Medium](https://medium.com/@laurentkubaski/mcp-resources-explained-and-how-they-differ-from-mcp-tools-096f9d15f767) — 专论"只读数据为何应建模为 Resource 而非 Tool"。

> [!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)
