首页 › tutorials › 一次造齐MCP三原语

概述

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

前置条件

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

绝大多数 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:

# 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 更干净。

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

用 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" 才会监听端口。

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

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。

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

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,模型解析更稳。

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

当你发现反复在打同一套提示词("先读这篇笔记,再用三句话总结…"),就该做成 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 导入)。

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

先用 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 汇总成一份行动清单,再给最长的笔记出个摘要。

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

  1. 读 Resource kb://index → 拿到笔记清单与标签;
  2. 调 Tool search_notes(query="TODO") → 抓出所有待办行;
  3. 选 Prompt 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: 汇总行动清单 + 摘要

进阶:把三原语用得更深

三原语选型决策表

遇到新需求时,按这张表对号入座:

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

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

训练营适用性评估

参考来源

  1. FastMCP · Resources & Templates — Resource 装饰器、RFC 6570 URI 模板与通配符参数的官方说明
  2. FastMCP · Prompts — @mcp.prompt 参数类型、docstring 解析与多消息返回
  3. Model Context Protocol — Python SDK — 官方 SDK(含 FastMCP)源码与示例
  4. MCP Server Tutorial: 12 Steps Python FastMCP (2026) — 基于 1.27 SDK 的端到端实战参考
  5. Building a Simple MCP Server in Python — Machine Learning Mastery — 三原语对照讲解

[!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学通信知识星球海报-扫码加入