大多数 MCP 教程只讲一个方向:宿主(client)调用服务器暴露的 tools/resources/prompts。本教程聚焦被冷落的反向原语——服务器反过来向宿主发起请求。你将掌握两个反向原语:Sampling(采样) 让服务器"借用宿主大脑"完成总结、分类、评审等 LLM 推理;Roots(根目录) 让服务器知道它被允许操作哪些目录。教程最后给出 2026 年 6 月的重大变化:MCP 官方 SEP-2577 已正式弃用 Roots、Sampling、Logging 三个原语,并提供一年迁移窗口。我们将构建一个完整的"代码评审小犬"服务器,再把它迁移到不会被弃用的等价实现。
适用人群:已能写基本 MCP 服务器(Tools/Resources/Prompts)、想进阶到协议高级能力、并希望写出"面向未来"代码的开发者。
@mcp.tool() 基本用法)pip install "mcp>=1.28" anthropicnpx @modelcontextprotocol/inspectorMCP 建立在 JSON-RPC 之上,而 JSON-RPC 天然支持双向请求。客户端能调用服务器方法,服务器也能反过来调用客户端方法。这三类原语恰好分两个方向:
flowchart LR
subgraph 客户端["宿主 / Client(如 Claude Code、Inspector)"]
C["MCP Host\n+ 用户 + LLM"]
end
subgraph 服务器["MCP Server(你写的工具)"]
S["FastMCP 服务"]
end
C -- "正向:tools/call、resources/read、prompts/get" --> S
S -. "反向:roots/list(你在哪)、\nsampling/createMessage(帮我想想)、\nelicitation/create(问用户)" .-> C
Tools、Resources、Prompts。这是绝大多数教程和实际项目的主力。Roots、Sampling、Elicitation。它们让服务器"反客为主",向宿主要信息或要算力。理解反向原语的关键在于能力协商:客户端必须在初始化握手时声明"我支持 sampling/roots",否则服务器发起这些请求会被拒绝。这也是它们采用率低、最终被弃用的根源之一——能完整实现这套反向握手的客户端不多。
新建目录并安装依赖:
mkdir mcp-reverse && cd mcp-reverse
python -m venv .venv && source .venv/bin/activate
pip install "mcp>=1.28" anthropic
export ANTHROPIC_API_KEY=sk-ant-xxxxx # 迁移章节与自定义 client 需要
确认版本后,我们先把"采样"这一最性感的能力跑通。
采样的协议报文很简洁。服务器通过 sampling/createMessage 向客户端要一次 LLM 生成:
服务器 → 客户端的请求:
{
"jsonrpc": "2.0", "id": 1, "method": "sampling/createMessage",
"params": {
"messages": [{ "role": "user", "content": { "type": "text", "text": "总结下面这段文字" } }],
"systemPrompt": "你是简洁的助手。",
"maxTokens": 100,
"modelPreferences": { "hints": [{ "name": "claude" }], "intelligencePriority": 0.8 }
}
}
客户端 → 服务器的响应:
{
"jsonrpc": "2.0", "id": 1,
"result": {
"role": "assistant",
"content": { "type": "text", "text": "(生成结果)" },
"model": "claude-sonnet-4-6",
"stopReason": "endTurn"
}
}
它的精妙之处在于控制权仍在宿主:宿主可在转发给真正的 LLM 前,让用户审查、修改、甚至拒绝这次采样请求(规范要求 SHOULD 始终保留 human-in-the-loop)。服务器因此不需要自带 API Key,零密钥就能获得 LLM 能力。
sequenceDiagram
participant S as 服务器(tool 执行中)
participant C as 客户端/宿主
participant U as 用户
participant L as LLM
S->>C: sampling/createMessage
C->>U: 展示请求,请求确认/修改
U-->>C: 批准
C->>L: 转发为真实模型调用
L-->>C: 生成文本
C-->>S: CreateMessageResult
用 FastMCP(v1 稳定版)写一个工具:把传入的文本交给宿主大模型总结。注意采样要拿到 ctx.session,再调用 create_message。
# sampler_min.py
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent, SamplingMessage
mcp = FastMCP("sampler-min")
@mcp.tool()
async def summarize(text: str, ctx: Context) -> str:
"""让宿主大模型用一句话总结 text。"""
result = await ctx.session.create_message(
messages=[
SamplingMessage(
role="user",
content=TextContent(text=f"用一句中文总结:\n\n{text}"),
)
],
system_prompt="你是一句话总结器,不超过 30 字。",
max_tokens=120,
)
assert isinstance(result.content, TextContent)
return result.content.text
if __name__ == "__main__":
mcp.run() # 默认 stdio 传输
关键点解读:
ctx: Context 是 FastMCP 自动注入的请求上下文,标注 Context 类型即可获得。ctx.session.create_message(...) 是发起采样的入口。messages 必须是 SamplingMessage 列表,content 用 TextContent(也支持 ImageContent、AudioContent)。result.content 可能是文本或图像,因此 isinstance 断言后再取 .text 最稳妥。max_tokens 是必填项——采样没有默认上限,必须显式给出。常见问题:
| 报错现象 | 原因 | 解决 |
|---|---|---|
McpError: client does not support sampling |
宿主没声明 sampling 能力 |
换支持采样的客户端(见步骤五) |
| 工具一直转圈不返回 | 宿主弹了确认框等用户操作 | 在宿主 UI 里批准该采样请求 |
| 返回空或被截断 | max_tokens 太小 |
调大 max_tokens |
pyright: reportDeprecated 提示 |
这是 SEP-2577 弃用标记(见后文) | 暂时忽略,或按迁移章节改造 |
Roots 解决的是"服务器应该操作哪些目录"的问题。客户端声明 roots 能力后,服务器用 roots/list 取回一个 file:// URI 列表,并在列表变化时收到 notifications/roots/list_changed。
// 服务器 -> 客户端
{ "jsonrpc": "2.0", "id": 2, "method": "roots/list" }
// 客户端 -> 服务器
{ "jsonrpc": "2.0", "id": 2,
"result": { "roots": [
{ "uri": "file:///home/u/myproject", "name": "myproject" }
]}}
注意规范的模糊语义:Roots 是"信息性指引"——服务器不强制遵守。这既是它灵活之处,也是它最终被弃用的原因之一(语义太弱)。一个负责任的服务器应当把 roots 当作软边界,配合路径规范化防止 ../ 越界,但不要把它当成安全防线。
下面把 roots 接入文件操作,做"目录边界检查":
# roots_guard.py
from pathlib import Path
from urllib.parse import urlparse, unquote
from mcp.server.fastmcp import FastMCP, Context
mcp = FastMCP("roots-guard")
def _uri_to_path(uri: str) -> Path:
# file:///home/u/proj -> /home/u/proj
return Path(unquote(urlparse(uri).path))
def _within(candidate: Path, roots: list[Path]) -> bool:
real = candidate.resolve()
for root in roots:
try:
real.relative_to(root) # 能算出相对路径 → 在该根内
return True
except ValueError:
continue
return False
@mcp.tool()
async def read_file(rel_path: str, ctx: Context) -> str:
"""读取 roots 第一个根目录下的相对文件;越界则拒绝。"""
roots_result = await ctx.session.list_roots()
roots = [_uri_to_path(r.uri) for r in roots_result.roots]
if not roots:
return "❌ 宿主未暴露任何 roots。"
candidate = (roots[0] / rel_path).resolve()
if not _within(candidate, roots):
return f"❌ 拒绝:{rel_path} 不在允许的根目录范围内。"
return candidate.read_text(encoding="utf-8", errors="replace")
常见问题:
roots/list 报 "not supported":宿主未声明 roots 能力。Claude Code 默认会把当前工作目录作为 root 暴露;纯 stdio 测试时需在自定义 client 里提供。.resolve() 再比较,避免符号链接或 ../ 绕过。把两个反向原语组合成一个真实可用的小工具:它先取 roots 锁定项目目录,读取指定文件,再用采样让宿主大模型审阅。
# review_puppy.py —— 代码评审小犬
from pathlib import Path
from urllib.parse import urlparse, unquote
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent, SamplingMessage
mcp = FastMCP("review-puppy")
def _uri_to_path(uri: str) -> Path:
return Path(unquote(urlparse(uri).path))
def _within(candidate: Path, roots: list[Path]) -> bool:
real = candidate.resolve()
return any(_safe_relative(real, r) for r in roots)
def _safe_relative(real: Path, root: Path) -> bool:
try:
real.relative_to(root)
return True
except ValueError:
return False
@mcp.tool()
async def review_diff(diff_text: str, ctx: Context) -> str:
"""让宿主大模型审阅一段 git diff,返回三条中文意见。"""
prompt = (
"你是严格的代码审查者。审阅下面的 diff,"
"用三句话指出最值得修改的问题,每句以『- 』开头:\n\n"
f"{diff_text}"
)
result = await ctx.session.create_message(
messages=[SamplingMessage(role="user", content=TextContent(text=prompt))],
system_prompt="你是资深工程师,评审简短、具体、可执行。",
max_tokens=500,
)
assert isinstance(result.content, TextContent)
return result.content.text
@mcp.tool()
async def review_file(rel_path: str, ctx: Context) -> str:
"""在 roots 范围内读取文件,并对其内容做评审;越界拒绝。"""
roots_result = await ctx.session.list_roots()
roots = [_uri_to_path(r.uri) for r in roots_result.roots]
if not roots:
return "❌ 宿主未暴露任何 roots。"
candidate = (roots[0] / rel_path).resolve()
if not _within(candidate, roots):
return f"❌ 拒绝:{rel_path} 越出允许的根目录。"
text = candidate.read_text(encoding="utf-8", errors="replace")
return await review_diff(f"(单文件 {candidate.name})\n{text}", ctx)
if __name__ == "__main__":
mcp.run()
这个服务器把"知道在哪(roots)"和"会思考(sampling)"组合起来:review_file 先做边界检查(安全),再做 LLM 评审(智能)。但它有个现实问题——谁来回答采样请求? 这正是反向原语最棘手的一环。
采样的硬伤在于:服务器发起的请求,必须有一个声明了 sampling 能力的客户端来回答。 截止 2026 年中,Claude Code 等宿主对采样的支持参差不齐(这也是它被弃用的直接原因)。最可靠的验证途径有两条。
途径 A:MCP Inspector
npx @modelcontextprotocol/inspector python review_puppy.py
Inspector 是带 UI 的调试台,能列出工具、手动触发调用、查看 JSON-RPC 报文。它对采样的支持取决于版本,遇到 not supported 时改用途径 B。
途径 B:自定义 client(推荐)
自己写一个客户端,声明 sampling + roots 能力,并用一个真实 LLM 回答服务器的 createMessage。这是反向原语唯一的"端到端"测法:
# test_client.py
import asyncio
from mcp import ClientSession, types
from mcp.client.stdio import stdio_client, StdioServerParameters
from anthropic import Anthropic
llm = Anthropic()
async def on_sampling(context, params: types.CreateMessageRequestParams):
"""回答服务器的 sampling/createMessage。"""
user_text = params.messages[-1].content.text
rsp = llm.messages.create(
model="claude-sonnet-4-6",
max_tokens=params.max_tokens,
system=params.system_prompt or None,
messages=[{"role": "user", "content": user_text}],
)
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text=rsp.content[0].text),
model=rsp.model,
stopReason="endTurn",
)
async def list_roots(context):
"""向服务器暴露工作目录作为 root。"""
return types.ListRootsResult(
roots=[types.Root(uri="file:///home/u/myproject", name="myproject")]
)
async def main():
params = StdioServerParameters(command="python", args=["review_puppy.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(
read, write,
sampling_callback=on_sampling,
list_roots_callback=list_roots,
) as session:
await session.initialize()
res = await session.call_tool(
"review_file",
{"rel_path": "src/utils.py"},
)
print(res.content[0].text)
asyncio.run(main())
预期输出(针对某个 utils.py)类似:
- 函数缺少类型注解与 docstring,可读性差。
- 异常被裸 except 吞掉,调试时难以定位。
- 用字符串拼接构造路径,存在跨平台风险。
sampling_callback 与 list_roots_callback 是 ClientSession 构造参数:宿主正是通过它们把"反向请求"的实现委托给你的代码。至此,双向握手的两侧你都掌握了。
这是本教程最重要的部分。 2026 年 4 月 14 日,MCP 核心维护者 Kurtis Van Gent 提交了 SEP-2577《Deprecate Roots, Sampling, and Logging》,状态已为 Final。它正式弃用我们刚学的三个能力:
roots/list、notifications/roots/list_changedsampling/createMessage、ClientCapabilities.tasks.requests.samplinglogging/setLevel、notifications/message时间线(务必记住):
为什么是这三个? 官方给出三条共性理由:
理解了"为什么弃用",就理解了"该迁向哪里"。
弃用不是世界末日,而是一次"瘦身"。三者都有清晰的替代方案。
采样被弃用的核心理由是:服务器若需要 LLM 能力,直接对接 LLM 提供商 API 更可控——你能自己选模型、设参数、拿流式输出,不必依赖宿主是否实现采样。代价是服务器要自带 API Key。
# review_puppy_migrated.py —— 采样改为直接调用 Anthropic API
import sys, logging
from anthropic import Anthropic
from mcp.server.fastmcp import FastMCP
logging.basicConfig(stream=sys.stderr, level=logging.INFO) # 见"迁移 3"
log = logging.getLogger("review-puppy")
llm = Anthropic() # 读 ANTHROPIC_API_KEY
mcp = FastMCP("review-puppy")
@mcp.tool()
async def review_diff(diff_text: str) -> str:
rsp = llm.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
system="你是资深工程师,评审简短、具体、可执行。",
messages=[{"role": "user",
"content": "审阅这段 diff,用三句话指出问题:\n\n" + diff_text}],
)
log.info("reviewed %d chars", len(diff_text))
return rsp.content[0].text
注意:ctx: Context 参数被去掉了——不再需要它来"借脑"。需要密钥是采样的固有优势,也是迁移的固有代价;可通过服务器配置或密钥管理服务注入。
roots 提供的"工作目录上下文",规范认为用显式手段更可靠。三种迁移路径,按推荐度排序:
# 路径 A(最推荐):把根目录作为显式工具参数
@mcp.tool()
async def review_file(project_root: str, rel_path: str) -> str:
base = Path(project_root).resolve()
candidate = (base / rel_path).resolve()
try:
candidate.relative_to(base) # 仍然是软边界,防 ../
except ValueError:
return f"❌ 拒绝:{rel_path} 越出 {project_root}"
text = candidate.read_text(encoding="utf-8", errors="replace")
return await review_diff(f"({candidate.name})\n{text}")
# 路径 B:从环境变量读
# export MCP_PROJECT_ROOT=/home/u/myproject
root = Path(os.environ["MCP_PROJECT_ROOT"])
# 路径 C:服务器配置文件 / Resource URI
# 让用户在 .mcp.json 或自定义 resource 里声明项目根
显式参数最大的好处是可测试、可审计、可复现——这正是 roots 因语义模糊而欠缺的。
协议级日志被弃用,因为标准日志基础设施更成熟:stdio 传输下用 stderr,HTTP 传输下用 OpenTelemetry。
import sys, logging
logging.basicConfig(
stream=sys.stderr, # 关键:stdio 服务器必须走 stderr,stdout 留给 JSON-RPC
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("review-puppy")
@mcp.tool()
async def review_diff(diff_text: str) -> str:
log.info("start review, %d chars", len(diff_text))
... # 业务逻辑
log.warning("diff 过长,已截断到 8000 字符")
return result
为什么不能往 stdout 写?因为 stdio 传输里 stdout 是 JSON-RPC 报文通道,混入日志会破坏协议解析——这是排错时最常踩的坑。结构化观测则交给 OpenTelemetry(官方另有 SEP-414 在推进 trace context 标准化)。
把三处迁移合并,得到一个不依赖任何反向原语、面向未来的版本:
# review_puppy_final.py
import os, sys, logging
from pathlib import Path
from anthropic import Anthropic
from mcp.server.fastmcp import FastMCP
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
log = logging.getLogger("review-puppy")
llm = Anthropic()
mcp = FastMCP("review-puppy")
async def _review(text: str) -> str:
rsp = llm.messages.create(
model="claude-sonnet-4-6", max_tokens=500,
system="你是资深工程师,评审简短、具体、可执行。",
messages=[{"role": "user",
"content": "审阅下面内容,三句话指出问题:\n\n" + text}],
)
return rsp.content[0].text
@mcp.tool()
async def review_diff(diff_text: str) -> str:
log.info("review_diff %d chars", len(diff_text))
return await _review(diff_text)
@mcp.tool()
async def review_file(project_root: str, rel_path: str) -> str:
base = Path(project_root).resolve()
candidate = (base / rel_path).resolve()
try:
candidate.relative_to(base)
except ValueError:
return f"❌ 拒绝:{rel_path} 越出 {project_root}"
log.info("review_file %s", candidate)
text = candidate.read_text(encoding="utf-8", errors="replace")
return await _review(f"({candidate.name})\n{text}")
if __name__ == "__main__":
mcp.run()
对比迁移前后:
flowchart TB
subgraph OLD["迁移前:依赖反向原语(已弃用)"]
A1["review_file"] -->|roots/list| A2["ctx.session.list_roots()"]
A1 -->|sampling/createMessage| A3["ctx.session.create_message()"]
A1 -.->|notifications/message| A4["ctx.log()(被弃用)"]
end
subgraph NEW["迁移后:自包含、面向未来"]
B1["review_file(project_root, rel_path)"] --> B2["Path 路径检查"]
B1 --> B3["Anthropic().messages.create()"]
B1 --> B4["logging → stderr"]
end
OLD == 一年后能力移除 ==> NEW
迁移窗口还有约一年,不必恐慌。给你的决策矩阵:
| 你的场景 | 建议 |
|---|---|
| 全新项目,2026 年下半年开始 | 直接用迁移后写法,别碰采样/roots |
| 现有项目重度依赖采样,且宿主确实支持 | 暂时保留,但立刻在内部抽象一层,方便一年后整体替换 |
| 需要零密钥(不能自带 API Key) | 短期仍可用采样,但尽早评估由用户侧提供密钥的方案 |
| 需要"知道项目在哪" | 永远优先工具参数,roots 仅作旧客户端兼容回退 |
| 需要结构化日志/可观测性 | 上 OpenTelemetry / stderr,别用协议级 logging |
| 维护面向公众的客户端 | 至少在 2027 年 6 月前继续支持这三个能力,并给出迁移文档 |
一句话总结策略:新代码走替代方案,旧代码做适配层,关键路径上提前迁移。
sampling/createMessage 的权威定义、能力协商、安全要求roots/list、listChanged 与 file:// 约束src/mcp/server/session.py——create_message、list_roots 的真实签名(v1.28.1)examples/stories/sampling/server.py——官方采样服务器最小范例sampling/createMessage 报文格式、能力协商与 human-in-the-loop 安全要求roots/list、notifications/roots/list_changed 与 file:// URI 约束create_message 与 list_roots 的真实方法签名,本教程 API 示例的来源[!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 等场景全覆盖。
