本教程带你用 Python 的 FastMCP,从零构建一个本地知识库 MCP 服务器,在一次实战中同时落地 MCP 的三大原语:Resources(资源)、Tools(工具)、Prompts(提示词)。完成后,Claude Code 既能按标签/标题读取你的 Markdown 笔记,能全文搜索,也能一键套用「摘要」「提取待办」等预设工作流。适合已跑通过至少一个 MCP 工具、想系统掌握协议全貌的开发者,预计 60–90 分钟。
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。 本文的知识库三原语恰好各占其一,正好把三种心智模型一次练透。
新建项目目录,结构如下:
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:
# git bisect 二分定位坏提交
tags: git, 调试, 自动化
## 核心思路
用二分搜索在提交历史中找出引入 bug 的那次提交。
## TODO
- [ ] 把 bisect 流程封装成 Claude Code 子代理
- [ ] 为每个候选提交自动跑测试套件
notes/mcp-three-primitives.md:
# MCP 三原语速记
tags: mcp, 架构, claude-code
## 要点
- Tools:模型控制,可执行
- Resources:应用控制,只读
- Prompts:用户控制,模板化
## TODO
- [ ] 给三原语各写一个最小示例
预期结果:notes/ 下有两篇带 tags: 行的笔记,后续服务器会自动扫描它们。
常见问题:
- 笔记里没有 tags: 行会怎样? 后面的解析函数会返回空标签列表,不影响运行,但按标签筛选会漏掉它——保持元数据统一即可。
- 文件名带空格? 建议用连字符命名(my-note.md),URI 更干净。
用 uv 一行装好(也可以 pip install mcp):
cd local-kb-mcp
uv init --no-readme
uv add "mcp[cli]"
写出最小可运行骨架 kb_mcp/server.py:
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):
uv run python -m kb_mcp.server
预期结果:进程挂起等待 JSON-RPC 消息,按 Ctrl+C 退出即说明骨架正常。
常见问题:
- ModuleNotFoundError: mcp:确认用的是 uv run 而非裸 python,或已激活虚拟环境。
- 端口/连接相关报错:mcp.run() 默认 stdio,不需要端口;若你显式传了 transport="sse" 才会监听端口。
Resources 让客户端按 URI 把只读数据"贴"进上下文。我们做三类资源:目录索引(静态)、单篇笔记(模板)、按标签筛选(模板)。
在 server.py 顶部补一个解析助手,再定义资源:
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 []
静态资源——知识库目录:
@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 模板)——按标题读、按标签筛:
@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。
Resources 是"被动给数据",但模型想主动搜时,需要 Tool。这里加一个全文检索工具(带行号,方便定位):
@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})"
再加一个轻量统计工具,演示"有计算、但无副作用"的工具:
@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,模型解析更稳。
当你发现反复在打同一套提示词("先读这篇笔记,再用三句话总结…"),就该做成 Prompt。它是参数化的提示词模板,用户从斜杠菜单一键触发。
@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 导入)。
先用 CLI 调试器跑一遍,确认三原语都被识别:
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:
claude mcp add local-kb -- uv run --directory $(pwd) python -m kb_mcp.server
或直接编辑 .mcp.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 汇总成一份行动清单,再给最长的笔记出个摘要。
模型的实际执行路径正是三原语各司其职:
kb://index → 拿到笔记清单与标签;search_notes(query="TODO") → 抓出所有待办行;summarize_note(或直接读 kb://note/{title})→ 产出摘要。这就是三原语联用的威力:Resource 提供"在哪/有什么",Tool 提供"怎么找/怎么算",Prompt 提供"按什么套路干"。三者职责清晰,模型不会越界,行为也更可预测。
下面这张图展示三原语如何在一次会话中协同:
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 由用户选。
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,笔记一改就通知客户端刷新,免去轮询。@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) 告诉客户端这个工具只读、无副作用、可安全预览——search_notes、stats 都该挂上。[UserMessage(...), AssistantMessage(...)] 可预设一段"角色设定 + 任务",把少样本示例固化进模板。ctx: Context 参数即可拿到日志、进度上报、采样(让服务器反向调宿主大模型)能力——这是把"借宿主大脑"做进任意原语的统一入口。遇到新需求时,按这张表对号入座:
| 你的需求 | 选谁 | 例子 |
|---|---|---|
| 执行动作、有副作用、需模型主动判断时机 | Tool | 搜索、发请求、改文件 |
| 只读数据,希望按需附进上下文 | Resource | 笔记全文、配置、schema |
| 固定的多步提示词套路,供用户一键复用 | Prompt | 摘要、提取待办、代码审查 |
| 数据要随参数动态生成 | Resource Template | kb://tag/{tag} |
| 想让服务器反过来调用宿主 LLM | Context.sampling | 跨原语的"借脑" |
记住反模式:别把只读数据做成 Tool(浪费一次模型决策、且无法被资源菜单发现),别把工作流硬塞进 Tool 描述(用户无法从菜单一键触发)。
notes/ 换成 SQLite,Resource 暴露 schema、Tool 做参数化查询、Prompt 封装"自然语言转 SQL"工作流,即升级为数据库知识库 MCP@mcp.prompt 参数类型、docstring 解析与多消息返回[!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 等场景全覆盖。
