<!-- title: 一次造齐MCP三原语 -->

## 概述

本教程带你用 Python 的 FastMCP，从零构建一个**本地知识库 MCP 服务器**，在一次实战中同时落地 MCP 的三大原语：**Resources（资源）、Tools（工具）、Prompts（提示词）**。完成后，Claude Code 既能按标签/标题读取你的 Markdown 笔记，能全文搜索，也能一键套用「摘要」「提取待办」等预设工作流。适合已跑通过至少一个 MCP 工具、想系统掌握协议全貌的开发者，预计 60–90 分钟。

## 前置条件

- **基础**：会写简单 Python 函数，了解 JSON 与命令行；装过 Claude Code 并至少配过一个 MCP 服务器
- **环境**：Python 3.10+、`pip` 或 `uv`（推荐 uv，免装虚拟环境）
- **依赖**：`mcp` 官方 Python SDK（内含 FastMCP），无需额外框架
- **可选**：`mcp-cli` 或 MCP Inspector 用于本地调试

## 为什么是"三原语"，而不是又一个工具教程

绝大多数 MCP 教程只演示 `@mcp.tool()`。这没问题——Tools 是最常用、最直观的原语。但 MCP 协议的核心洞察是：**用"谁控制"来划分三种能力**，而不是用"功能类型"。理解这条轴，你才知道一个需求该做成 Tool 还是 Resource，避免把只读数据硬塞进工具、或把工作流塞进工具描述里。

| 原语 | 控制方 | 何时触发 | 本质 | 类比 |
|------|--------|----------|------|------|
| **Tools** | 模型（LLM） | LLM 自主决定调用 | 可执行函数，可有副作用 | 函数调用 / API |
| **Resources** | 应用（客户端/用户） | 用户或客户端主动附加到上下文 | 只读数据，按 URI 寻址 | 文件 / REST GET |
| **Prompts** | 用户 | 用户从斜杠菜单选择 | 参数化提示词模板 | 快捷指令 / Snippet |

一句话决策法：**要执行动作 → Tool；只是给数据 → Resource；要复用提示词 → Prompt。** 本文的知识库三原语恰好各占其一，正好把三种心智模型一次练透。

## 步骤 1：搭项目骨架与样本数据

新建项目目录，结构如下：

```
local-kb-mcp/
├── kb_mcp/
│   ├── __init__.py
│   └── server.py        # 服务器主文件
├── notes/               # 知识库样本（Markdown）
│   ├── git-bisect.md
│   └── mcp-three-primitives.md
└── pyproject.toml
```

先准备两篇样本笔记（带 `tags:` 元数据行，后面解析要用）：

`notes/git-bisect.md`：
```markdown
# git bisect 二分定位坏提交

tags: git, 调试, 自动化

## 核心思路
用二分搜索在提交历史中找出引入 bug 的那次提交。

## TODO
- [ ] 把 bisect 流程封装成 Claude Code 子代理
- [ ] 为每个候选提交自动跑测试套件
```

`notes/mcp-three-primitives.md`：
```markdown
# MCP 三原语速记

tags: mcp, 架构, claude-code

## 要点
- Tools：模型控制，可执行
- Resources：应用控制，只读
- Prompts：用户控制，模板化

## TODO
- [ ] 给三原语各写一个最小示例
```

**预期结果**：`notes/` 下有两篇带 `tags:` 行的笔记，后续服务器会自动扫描它们。

**常见问题**：
- *笔记里没有 `tags:` 行会怎样？* 后面的解析函数会返回空标签列表，不影响运行，但按标签筛选会漏掉它——保持元数据统一即可。
- *文件名带空格？* 建议用连字符命名（`my-note.md`），URI 更干净。

## 步骤 2：安装依赖与最小骨架

用 uv 一行装好（也可以 `pip install mcp`）：

```bash
cd local-kb-mcp
uv init --no-readme
uv add "mcp[cli]"
```

写出最小可运行骨架 `kb_mcp/server.py`：

```python
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("local-kb")
KB_DIR = Path(__file__).resolve().parent.parent / "notes"

if __name__ == "__main__":
    mcp.run()        # 默认走 stdio 传输，适合 Claude Code
```

启动验证（应无报错地等待 stdin）：

```bash
uv run python -m kb_mcp.server
```

**预期结果**：进程挂起等待 JSON-RPC 消息，按 `Ctrl+C` 退出即说明骨架正常。

**常见问题**：
- *`ModuleNotFoundError: mcp`*：确认用的是 `uv run` 而非裸 `python`，或已激活虚拟环境。
- *端口/连接相关报错*：`mcp.run()` 默认 stdio，不需要端口；若你显式传了 `transport="sse"` 才会监听端口。

## 步骤 3：用 Resources 把知识库暴露成可寻址数据

Resources 让客户端按 URI 把只读数据"贴"进上下文。我们做三类资源：**目录索引**（静态）、**单篇笔记**（模板）、**按标签筛选**（模板）。

在 `server.py` 顶部补一个解析助手，再定义资源：

```python
import re

def _load_all() -> dict[str, str]:
    """扫描 notes 目录，返回 {标题: 正文}"""
    return {p.stem: p.read_text(encoding="utf-8")
            for p in KB_DIR.glob("*.md")}

def _extract_tags(body: str) -> list[str]:
    """从 `tags: a, b, c` 元数据行提取标签"""
    m = re.search(r"^tags:\s*(.+)$", body, re.MULTILINE)
    return [x.strip() for x in m.group(1).split(",")] if m else []
```

静态资源——知识库目录：

```python
@mcp.resource("kb://index")
def index() -> str:
    """知识库目录：列出所有笔记标题及其标签"""
    lines = [f"# 知识库目录（共 {len(_load_all())} 篇）"]
    for title, body in _load_all().items():
        tags = _extract_tags(body)
        lines.append(f"- **{title}**  标签: {', '.join(tags) or '无'}")
    return "\n".join(lines)
```

资源模板（RFC 6570 URI 模板）——按标题读、按标签筛：

```python
@mcp.resource("kb://note/{title}")
def read_note(title: str) -> str:
    """按标题读取笔记全文"""
    p = KB_DIR / f"{title}.md"
    if not p.exists():
        return f"# 未找到笔记: {title}\n可用：{', '.join(_load_all())}"
    return p.read_text(encoding="utf-8")

@mcp.resource("kb://tag/{tag}")
def notes_by_tag(tag: str) -> str:
    """按标签筛选笔记，返回拼接全文"""
    matched = [(t, b) for t, b in _load_all().items()
               if tag in _extract_tags(b)]
    if not matched:
        return f"# 标签 '{tag}' 下暂无笔记"
    return "\n\n---\n\n".join(f"## {t}\n{b}" for t, b in matched)
```

**预期结果**：客户端列出资源时会看到 `kb://index`，以及两个模板 `kb://note/{title}`、`kb://tag/{tag}`。读取 `kb://note/git-bisect` 返回该笔记全文；读取 `kb://tag/mcp` 返回所有打了 `mcp` 标签的笔记。

**关键点**：`{title}` 这种花括号占位符会被自动映射成函数参数，这就是 Resource Template——**一个模板能动态生成任意多个资源**，不必为每篇笔记写一个函数。

**常见问题**：
- *模板不生效，客户端只看到静态资源？* 部分旧客户端对资源模板支持不全；Claude Code 与 Inspector 均已支持。读不到模板时，退化为在 `index` 里列出全部 URI 让用户/模型选。
- *资源返回 dict 想给 JSON？* Resources 的返回会被当作纯文本。要返回结构化数据，用 `json.dumps(...)` 序列化成字符串，并最好显式声明 MIME。

## 步骤 4：用 Tools 赋能模型主动检索

Resources 是"被动给数据"，但模型想**主动搜**时，需要 Tool。这里加一个全文检索工具（带行号，方便定位）：

```python
@mcp.tool()
def search_notes(query: str, limit: int = 20) -> str:
    """在知识库全文中搜索关键词，返回命中的 `文件:行号: 内容` 列表。

    Args:
        query: 要搜索的关键词（不区分大小写）
        limit: 最多返回多少条命中，默认 20
    """
    q = query.lower()
    hits = []
    for title, body in _load_all().items():
        for i, line in enumerate(body.splitlines(), 1):
            if q in line.lower():
                hits.append(f"{title}.md:{i}: {line.strip()}")
            if len(hits) >= limit:
                break
    return "\n".join(hits) if hits else f"无匹配结果（query={query}）"
```

再加一个轻量统计工具，演示"有计算、但无副作用"的工具：

```python
@mcp.tool()
def stats() -> str:
    """返回知识库统计：篇数、总词数、标签云"""
    notes = _load_all()
    total_words = sum(len(b.split()) for b in notes.values())
    from collections import Counter
    tag_cloud = Counter(t for b in notes.values() for t in _extract_tags(b))
    return (f"篇数: {len(notes)}\n总词数: {total_words}\n"
            f"标签云: {dict(tag_cloud)}")
```

**预期结果**：模型可在对话中调用 `search_notes(query="bisect")`，得到 `git-bisect.md:1: # git bisect 二分定位坏提交` 等命中行；调用 `stats()` 得到篇数与标签云。

**关键点**：Tool 的 docstring **就是模型的说明书**——它会进上下文。写得越具体（参数含义、返回格式、边界行为），模型调用越准。这和 Resource 的 docstring（给人看用途）定位不同。

**常见问题**：
- *模型不调工具，自己瞎编？* 多半是描述太泛。把"搜索笔记"改成"在知识库全文中搜索关键词，返回 `文件:行号: 内容`"，命中率会明显上升。
- *想让结果更结构化？* 返回 JSON 字符串并在 docstring 里说明 schema，模型解析更稳。

## 步骤 5：用 Prompts 封装固定工作流

当你发现**反复在打同一套提示词**（"先读这篇笔记，再用三句话总结…"），就该做成 Prompt。它是参数化的提示词模板，用户从斜杠菜单一键触发。

```python
@mcp.prompt()
def summarize_note(title: str) -> str:
    """生成一篇笔记的三句话摘要。

    Args:
        title: 要摘要的笔记标题
    """
    return (f"请为笔记《{title}》生成三句话摘要。"
            f"先通过资源 kb://note/{title} 读取全文，再输出摘要。")


@mcp.prompt()
def extract_action_items(title: str) -> str:
    """从一篇笔记中提取所有待办/行动项。"""
    return (f"阅读笔记《{title}》（资源 kb://note/{title}），"
            f"提取全部 TODO 与行动项，用 Markdown 清单输出，并按优先级排序。")
```

**预期结果**：Claude Code 里输入 `/` 会看到 `summarize-note`、`extract-action-items` 两个命令；选中后填入标题，模型即按模板执行——读资源、再产出。

**关键点**：Prompt 是**用户控制**的原语——它出现在菜单里等用户点，而不是模型自己决定要不要用。这恰好补足了 Tools（模型主动）与 Resources（应用附加）之外的第三种交互方式。注意 Prompt 内部仍可**指引模型去读 Resource 或调 Tool**，三原语在此刻联动。

**常见问题**：
- *Prompt 没出现在斜杠菜单？* 确认用的是支持 Prompts 的客户端；Claude Code 与 Inspector 都支持。若返回的是纯字符串，FastMCP 会自动包成一条 user message。
- *想要多轮/多消息 Prompt？* 返回消息列表即可：`return [UserMessage(...), AssistantMessage(...), UserMessage(...)]`（从 `mcp.types` 导入）。

## 步骤 6：接入 Claude Code 并端到端测试

先用 CLI 调试器跑一遍，确认三原语都被识别：

```bash
uv run mcp dev kb_mcp/server.py
# 或：mcp dev kb_mcp/server.py:mcp
```

在弹出的 Inspector 界面里应能看到：Tools 区有 `search_notes`、`stats`；Resources 区有 `kb://index` 与两个模板；Prompts 区有两个模板。

确认无误后，注册到 Claude Code：

```bash
claude mcp add local-kb -- uv run --directory $(pwd) python -m kb_mcp.server
```

或直接编辑 `.mcp.json`（项目级）：

```json
{
  "mcpServers": {
    "local-kb": {
      "command": "uv",
      "args": ["run", "--directory", "/abs/path/to/local-kb-mcp",
               "python", "-m", "kb_mcp.server"]
    }
  }
}
```

**预期结果**：`claude mcp list` 看到 `local-kb ... ✓ Connected`；会话内 `/mcp` 能看到其工具/资源/提示词清单。

**常见问题**：
- *连接失败 / 启动超时*：99% 是工作目录或解释器问题。用绝对路径的 `--directory`，并确保 `uv run` 能找到 `mcp` 依赖。
- *改了代码不生效*：MCP 服务器是独立子进程，需重启 Claude Code 会话（或 `/mcp` 里重连）才会重新加载。
- *Windows 下 `uv` 找不到*：用 `where uv` 拿到全路径，填进 `command`。

## 实战案例：一次"周报复盘"完整跑通

把上面所有片段拼进同一个 `server.py`，启动后用下面这条用户指令触发三原语协同：

> 帮我复盘本周知识库：先看目录，把所有 TODO 汇总成一份行动清单，再给最长的笔记出个摘要。

模型的实际执行路径正是三原语各司其职：

1. 读 **Resource** `kb://index` → 拿到笔记清单与标签；
2. 调 **Tool** `search_notes(query="TODO")` → 抓出所有待办行；
3. 选 **Prompt** `summarize_note`（或直接读 `kb://note/{title}`）→ 产出摘要。

这就是三原语联用的威力：**Resource 提供"在哪/有什么"，Tool 提供"怎么找/怎么算"，Prompt 提供"按什么套路干"**。三者职责清晰，模型不会越界，行为也更可预测。

## 架构与请求流程

下面这张图展示三原语如何在一次会话中协同：

```mermaid
flowchart LR
    U([用户]) -->|输入指令| CC[Claude Code 宿主]
    subgraph Srv[local-kb MCP 服务器 - stdio]
        R[Resources\nkb://index, kb://note/x, kb://tag/x]
        T[Tools\nsearch_notes, stats]
        P[Prompts\nsummarize_note, extract_action_items]
        FS[(notes/*.md)]
        R --> FS
        T --> FS
    end
    CC -->|resources/read| R
    CC -->|tools/call 模型自主| T
    U -->|/ 选菜单 选 Prompt| P
    P -.指引.-> CC
    CC -->|JSON-RPC over stdio| Srv
```

三原语的"控制方"差异，决定了消息从谁发起：Resource 由客户端读、Tool 由模型调、Prompt 由用户选。

```mermaid
sequenceDiagram
    participant U as 用户
    participant M as 模型(Claude)
    participant C as 客户端(Claude Code)
    participant S as local-kb MCP
    U->>C: 复盘本周知识库
    C->>S: resources/read kb://index
    S-->>C: 笔记目录
    M->>S: tools/call search_notes {query:"TODO"}
    S-->>M: 命中的待办行
    U->>C: /summarize-note 最长笔记
    C->>S: prompts/get summarize_note {title}
    S-->>C: 展开的提示词
    C->>S: resources/read kb://note/xxx
    S-->>C: 笔记全文
    M-->>U: 汇总行动清单 + 摘要
```

## 进阶：把三原语用得更深

- **资源模板的通配符**：`kb://path/{filename+}` 的 `+` 可跨多段路径匹配，适合做目录树式浏览。
- **动态资源订阅**：用 `@mcp.resource` 配合 `notifications/resources/updated`，笔记一改就通知客户端刷新，免去轮询。
- **给 Tool 挂注解**：`@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))` 告诉客户端这个工具只读、无副作用、可安全预览——`search_notes`、`stats` 都该挂上。
- **Prompt 返回多消息**：返回 `[UserMessage(...), AssistantMessage(...)]` 可预设一段"角色设定 + 任务"，把少样本示例固化进模板。
- **Context 注入**：函数加 `ctx: Context` 参数即可拿到日志、进度上报、采样（让服务器反向调宿主大模型）能力——这是把"借宿主大脑"做进任意原语的统一入口。

## 三原语选型决策表

遇到新需求时，按这张表对号入座：

| 你的需求 | 选谁 | 例子 |
|----------|------|------|
| 执行动作、有副作用、需模型主动判断时机 | **Tool** | 搜索、发请求、改文件 |
| 只读数据，希望按需附进上下文 | **Resource** | 笔记全文、配置、schema |
| 固定的多步提示词套路，供用户一键复用 | **Prompt** | 摘要、提取待办、代码审查 |
| 数据要随参数动态生成 | **Resource Template** | `kb://tag/{tag}` |
| 想让服务器反过来调用宿主 LLM | **Context.sampling** | 跨原语的"借脑" |

记住反模式：**别把只读数据做成 Tool**（浪费一次模型决策、且无法被资源菜单发现），**别把工作流硬塞进 Tool 描述**（用户无法从菜单一键触发）。

## 训练营适用性评估

- **适合**：MCP 进阶营、AI 工程实战营、开发者工具训练营的中段课程（假设学员已跑通过单一 Tool 的 MCP）
- **教学时长**：讲授 45 分钟 + 动手 45 分钟，共约 1.5 课时；可作为"协议全貌"章节的核心实验
- **难度**：中等。难点不在代码（FastMCP 极简），而在三原语的**心智模型**——建议先用决策表做 10 分钟概念澄清，再动手
- **延伸作业**：把 `notes/` 换成 SQLite，Resource 暴露 schema、Tool 做参数化查询、Prompt 封装"自然语言转 SQL"工作流，即升级为数据库知识库 MCP

## 参考来源

1. [FastMCP · Resources & Templates](https://gofastmcp.com/servers/resources) — Resource 装饰器、RFC 6570 URI 模板与通配符参数的官方说明
2. [FastMCP · Prompts](https://gofastmcp.com/servers/prompts) — `@mcp.prompt` 参数类型、docstring 解析与多消息返回
3. [Model Context Protocol — Python SDK](https://github.com/modelcontextprotocol/python-sdk) — 官方 SDK（含 FastMCP）源码与示例
4. [MCP Server Tutorial: 12 Steps Python FastMCP (2026)](https://tech-insider.org/mcp-server-tutorial-python-fastmcp-claude-2026/) — 基于 1.27 SDK 的端到端实战参考
5. [Building a Simple MCP Server in Python — Machine Learning Mastery](https://machinelearningmastery.com/building-a-simple-mcp-server-in-python/) — 三原语对照讲解

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