本教程面向已用 MCP 写过一两个工具、却始终只用到 @mcp.tool 的开发者。学完你将掌握 MCP 的三大核心原语——Tools(工具)、Resources(资源)、Prompts(提示词)——理解它们各自"由谁控制、是否有副作用"的本质区别,并用 Python FastMCP 构建一个同时暴露三原语的项目文档知识库服务器,接入 Claude Code 后让资源被引用、提示词变成斜杠命令、工具被模型自主调用。
claude --version 验证uv(pip install uv 或 curl -LsSf https://astral.sh/uv/install.sh | sh)大多数 MCP 入门教程止步于 @mcp.tool,于是很多人误以为"MCP 就是给大模型挂函数"。这是对协议的严重低估。MCP 的精妙之处在于用三个原语 + 三种控制权把"上下文"切成三种不同形态:
| 原语 | 控制方 | 是否有副作用 | 类比 | 典型用途 |
|---|---|---|---|---|
| Resources 资源 | 应用 / 用户控制 | 无(只读) | HTTP GET | 暴露文件、数据库 schema、配置、日志 |
| Prompts 提示词 | 用户控制 | 无(生成文本) | 斜杠命令模板 | 标准化工作流:代码评审、日报、故障排查 |
| Tools 工具 | 模型控制 | 可有(动作) | HTTP POST | 真正执行:查询、写入、调用 API |
关键洞察是"控制方不同":
把这三者混为一谈(比如把只读数据也做成 Tool)会让你失去协议给你的安全分层:只读数据本不该让模型"想读就读",而应让用户显式引用。下面我们用一个完整项目把三者摆到正确的位置上。
我们构建一个项目文档知识库 MCP 服务器 docs-kb,目录结构如下:
docs-kb/
├── server.py # FastMCP 服务器,三原语齐发
├── pyproject.toml # 依赖声明(依赖 mcp)
└── docs/ # 被 Resources 暴露的只读数据源
├── getting-started.md
├── api-design.md
└── troubleshooting.md
三原语的落点设计得很刻意,帮你记住分工:
docs://index(笔记目录)、docs://note/{slug}(单篇内容)、docs://config(配置)——全是只读、由用户显式引用。summarize-note(总结一篇笔记)、review-checklist(代码评审清单)、standup(生成站会汇报)——用户主动触发的模板。search_docs(全文检索)、create_note(写文件,有副作用)、delete_note(删文件,带 Elicitation 确认)——模型按需调用的动作。mkdir docs-kb && cd docs-kb
uv init
uv add "mcp[cli]"
在 pyproject.toml 里确认依赖包含 mcp。准备两三个示例笔记,例如 docs/getting-started.md 写几行项目说明。
# server.py
from pathlib import Path
from mcp.server.fastmcp import FastMCP, Context
from pydantic import BaseModel
DOCS_DIR = Path(__file__).parent / "docs"
mcp = FastMCP("docs-kb")
# ───────────────────────── 1. RESOURCES(应用控制·只读)─────────────────────────
@mcp.resource("docs://index")
def list_notes() -> str:
"""返回所有笔记的标题与 slug 清单(只读目录)。"""
items = []
for p in sorted(DOCS_DIR.glob("*.md")):
title = p.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()
items.append(f"- {p.stem}: {title}")
return "文档清单:\n" + "\n".join(items)
@mcp.resource("docs://note://{slug}") # ← URI 模板,可参数化
def read_note(slug: str) -> str:
"""按 slug 读取单篇笔记全文。"""
f = DOCS_DIR / f"{slug}.md"
if not f.exists():
return f"找不到笔记:{slug}"
return f.read_text(encoding="utf-8")
@mcp.resource("docs://config")
def kb_config() -> str:
"""知识库配置(只读)。"""
return '{"version":"1.0","language":"zh","owner":"team-platform"}'
# ───────────────────────── 2. PROMPTS(用户控制·可复用模板)─────────────────────────
@mcp.prompt()
def summarize_note(slug: str) -> str:
"""生成『总结指定笔记』的提示词模板。"""
return (
f"请阅读 docs://note/{slug} 的内容,"
"用三句话总结要点,并列出 3 个可执行的行动项。"
)
@mcp.prompt()
def review_checklist() -> str:
"""返回团队统一的代码评审清单。"""
return (
"请按以下清单评审即将提交的 diff:\n"
"1. 是否有硬编码密钥或凭据;\n"
"2. 错误处理是否覆盖边界;\n"
"3. 是否新增了未测试的分支;\n"
"4. 命名与现有风格是否一致。"
)
# ───────────────────────── 3. TOOLS(模型控制·可有副作用)─────────────────────────
@mcp.tool()
def search_docs(query: str) -> str:
"""全文检索文档,返回命中的笔记标题与片段。"""
hits = []
for p in DOCS_DIR.glob("*.md"):
text = p.read_text(encoding="utf-8")
if query.lower() in text.lower():
i = text.lower().index(query.lower())
hits.append(f"[{p.stem}] ...{text[max(0,i-30):i+50]}...")
return "\n".join(hits) if hits else "无匹配结果"
@mcp.tool()
def create_note(title: str, body: str) -> str:
"""创建一篇新笔记(有副作用:写文件)。"""
slug = title.strip().lower().replace(" ", "-")
(DOCS_DIR / f"{slug}.md").write_text(f"# {title}\n\n{body}\n", encoding="utf-8")
return f"已创建 docs://note/{slug}"
class Confirm(BaseModel):
confirm: bool
@mcp.tool()
async def delete_note(slug: str, ctx: Context) -> str:
"""删除笔记——通过 Elicitation 在执行前向用户确认。"""
result = await ctx.elicit(
message=f"确定要删除笔记 {slug} 吗?此操作不可撤销。",
schema=Confirm,
)
if result.action != "accept" or not result.data.confirm:
return "已取消删除。"
f = DOCS_DIR / f"{slug}.md"
f.unlink(missing_ok=True)
return f"已删除 {slug}"
if __name__ == "__main__":
mcp.run() # 默认 stdio 传输
设计要点解读:
list_notes 返回的是"目录",不返回每篇全文——避免把大量只读数据一股脑塞进上下文,这正是 Resource 相比"一个返回所有内容的 Tool"的优势:可被引用、按需加载。docs://note://{slug} 让单篇笔记可被精确引用,客户端按 slug 取数。summarize_note 这个 Prompt 内部引用了 Resource 的 URI(docs://note/{slug}),体现了三原语的协作:用户选模板 → Prompt 生成指令 → 指令里引用 Resource → 模型据此读取数据 → 如需改动再调 Tool。delete_note 用 Elicitation(MCP 较新的人类在环原语)在执行副作用前暂停,向用户要一个确认。这把"模型可能误删"的风险交还给了人。本地进程型服务器用 stdio 传输最合适:
# 在项目根目录执行;-- 之后的参数原样传给服务器进程
claude mcp add --transport stdio docs-kb -- uv run server.py
-- 是关键:它把 -- 之后的 uv run server.py 与 claude mcp add 自身的参数隔开,否则 Claude Code 会尝试解析 uv 的 flag。若想配置环境变量,加 --env KEY=value:
claude mcp add --env LOG_LEVEL=debug --transport stdio docs-kb -- uv run server.py
要让团队成员共享同一服务器配置,用项目作用域,它会写入仓库根目录的 .mcp.json:
claude mcp add --scope project --transport stdio docs-kb -- uv run server.py
生成的 .mcp.json:
{
"mcpServers": {
"docs-kb": {
"command": "uv",
"args": ["run", "server.py"]
}
}
}
三种作用域的区别(影响团队协作):
| 作用域 | 存储位置 | 是否共享 | 适用场景 |
|---|---|---|---|
| local(默认) | 本机私有 | 否 | 个人临时调试 |
| project | 仓库 .mcp.json |
是(随 git) | 团队统一工具链 |
| user | 用户全局 | 否(跨项目) | 个人常用服务器 |
提示:Claude Code 会把项目根目录注入到 stdio 服务器的
CLAUDE_PROJECT_DIR环境变量,你的服务器可用它解析项目相对路径,不依赖当前工作目录。
进入 Claude Code 后输入 /mcp 查看连接状态,应看到 docs-kb 显示工具数量、已连接。
然后分别验证三种控制权:
/,应能从命令列表里看到 summarize-note、review-checklist。选 summarize-note 并填入 slug,Claude 会收到填好的提示词。docs://index",或用 Claude Code 的资源引用入口选取 docs://note/getting-started,模型会读到对应内容。search_docs;说"创建一篇标题为『部署手册』的笔记"会触发 create_note;要求删除时会先弹出 Elicitation 确认。Q1:提示词没出现在 / 命令列表里?
确认服务器声明了 prompts 能力(FastMCP 自动声明),且 /mcp 里服务器为已连接状态。若服务器连接中,Claude 会等待它就绪再继续;用 /mcp 面板可看每个服务器旁的工具数量。
Q2:Resource 该用 Tool 暴露吗?
看是否有副作用与控制权需求。只读、希望由用户/应用决定何时注入的数据用 Resource;需要模型主动检索的"动作"用 Tool。本例中 search_docs 故意做成 Tool(模型按需检索),而单篇笔记做成 Resource(用户显式引用),分工即安全。
Q3:stdio 服务器改了代码不生效?
stdio 服务器是本地进程,改代码后需重启会话或重新连接。项目作用域下改 .mcp.json 后也要重连。可发送 notifications/tools/list_changed 让 Claude Code 动态刷新能力,无需断开重连。
Q4:远程服务器该用哪种传输? 官方推荐 Streamable HTTP(最广支持、支持 OAuth);SSE 已废弃;只有需要服务器主动推送事件时才考虑 WebSocket。本地优先 stdio。
Q5:Elicitation 弹不出来?
Elicitation 是较新原语,需较新版的 mcp SDK 与支持该能力的客户端。若客户端不支持,服务器会拿到 decline 或报能力缺失——对核心逻辑做兼容降级(如默认拒绝危险操作)。
下图展示一次"用户触发 Prompt → Prompt 引用 Resource → 模型读取 → 模型按需调 Tool → 副作用前 Elicitation 确认"的端到端控制流:
flowchart LR
U([用户]) -->|1 选 Prompt 模板| P[mcp.prompt<br/>summarize-note]
P -->|2 填充参数生成指令| M((Claude 模型))
U -.->|引用 Resource<br/>docs://note/slug| M
R[(mcp.resource<br/>只读笔记数据)] -->|3 返回内容| M
M -->|4 需要改动时| T[mcp.tool<br/>create_note/delete_note]
T -->|5 副作用前| E{Elicitation<br/>向用户确认}
E -->|confirm| FS[(文件系统<br/>docs/*.md)]
E -->|decline| CANCEL[取消操作]
classDef ctrl fill:#eef,stroke:#88f
classDef data fill:#efe,stroke:#8c8
classDef act fill:#fee,stroke:#e88
class P ctrl
class R data
class T act
读图要点:蓝色为用户/应用控制(Prompt、Resource),绿色为只读数据,红色为模型控制且有副作用(Tool)——颜色即对应三原语的控制权分层,这正是把"只读"与"动作"用不同原语隔离的安全价值。
当服务器稳定后,可标准化打包供他人一行接入:
pyproject.toml 配置 [project.scripts],让 docs-kb 成为控制台命令,他人无需关心 uv run server.py 的细节。uv publish 后,他人用 claude mcp add --transport stdio docs-kb -- uvx docs-kb 一行接入(uvx 会临时拉取并运行)。/plugin install mcp-server-dev@claude-plugins-official,再 /mcp-server-dev:build-mcp-server,让 Claude 根据用途为你脚手架一个远程 HTTP 或本地 stdio 服务器骨架。注意发布前剥离任何硬编码密钥与本地绝对路径,改用 CLAUDE_PROJECT_DIR 与环境变量配置。
@mcp.tool/resource/prompt 装饰器模式的逐步演示@mcp.tool/resource/prompt 装饰器与 ctx.read_resource 协作的实战代码[!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 等场景全覆盖。
