首页 › tutorials › 造会借宿主大脑的MCP服务器

造会借宿主大脑的MCP服务器:MCP 采样与根目录原语实战,附 SEP-2577 弃用迁移

概述

大多数 MCP 教程只讲一个方向:宿主(client)调用服务器暴露的 tools/resources/prompts。本教程聚焦被冷落的反向原语——服务器反过来向宿主发起请求。你将掌握两个反向原语:Sampling(采样) 让服务器"借用宿主大脑"完成总结、分类、评审等 LLM 推理;Roots(根目录) 让服务器知道它被允许操作哪些目录。教程最后给出 2026 年 6 月的重大变化:MCP 官方 SEP-2577 已正式弃用 Roots、Sampling、Logging 三个原语,并提供一年迁移窗口。我们将构建一个完整的"代码评审小犬"服务器,再把它迁移到不会被弃用的等价实现。

适用人群:已能写基本 MCP 服务器(Tools/Resources/Prompts)、想进阶到协议高级能力、并希望写出"面向未来"代码的开发者。

前置条件

核心概念:MCP 的双向请求

MCP 建立在 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

理解反向原语的关键在于能力协商:客户端必须在初始化握手时声明"我支持 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(采样)

采样的协议报文很简洁。服务器通过 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 传输

关键点解读:

常见问题:

报错现象 原因 解决
McpError: client does not support sampling 宿主没声明 sampling 能力 换支持采样的客户端(见步骤五)
工具一直转圈不返回 宿主弹了确认框等用户操作 在宿主 UI 里批准该采样请求
返回空或被截断 max_tokens 太小 调大 max_tokens
pyright: reportDeprecated 提示 这是 SEP-2577 弃用标记(见后文) 暂时忽略,或按迁移章节改造

步骤三:理解 Roots(根目录)

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 接入文件操作,做"目录边界检查":

# 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")

常见问题:

实战案例:代码评审小犬(Sampling + Roots 合体)

把两个反向原语组合成一个真实可用的小工具:它先取 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 评审(智能)。但它有个现实问题——谁来回答采样请求? 这正是反向原语最棘手的一环。

步骤五:如何验证(自定义 client 是关键)

采样的硬伤在于:服务器发起的请求,必须有一个声明了 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 构造参数:宿主正是通过它们把"反向请求"的实现委托给你的代码。至此,双向握手的两侧你都掌握了。

重大变化:SEP-2577 弃用通知(2026 年 6 月)

这是本教程最重要的部分。 2026 年 4 月 14 日,MCP 核心维护者 Kurtis Van Gent 提交了 SEP-2577《Deprecate Roots, Sampling, and Logging》,状态已为 Final。它正式弃用我们刚学的三个能力:

时间线(务必记住):

为什么是这三个? 官方给出三条共性理由:

  1. 采用率低:feature 支持矩阵显示,真正实现 roots/sampling 的客户端很少;sampling 自 2024 年 11 月就在规范里了,仍未普及。
  2. 语义模糊或实现过重:roots 只是"信息性"指引、服务器可不遵守;sampling 要正确实现需 human-in-the-loop、模型选择、安全考量,还要(自 SEP-1577)支持工具循环,成本极高。
  3. 有更成熟的重叠替代品:每个原语都有更直接、更通用的等价做法。

理解了"为什么弃用",就理解了"该迁向哪里"。

迁移实战:如何把这三个原语换掉

弃用不是世界末日,而是一次"瘦身"。三者都有清晰的替代方案。

迁移 1:Sampling → 直接调用 LLM Provider API

采样被弃用的核心理由是:服务器若需要 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 参数被去掉了——不再需要它来"借脑"。需要密钥是采样的固有优势,也是迁移的固有代价;可通过服务器配置或密钥管理服务注入。

迁移 2:Roots → 工具参数 / 配置 / 环境变量

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 因语义模糊而欠缺的。

迁移 3:Logging → stderr / Python logging / OpenTelemetry

协议级日志被弃用,因为标准日志基础设施更成熟: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 月前继续支持这三个能力,并给出迁移文档

一句话总结策略:新代码走替代方案,旧代码做适配层,关键路径上提前迁移。

训练营适用性评估

延伸阅读

参考来源

  1. Sampling — Model Context Protocol Specification (2025-06-18) — 采样原语的权威规范:sampling/createMessage 报文格式、能力协商与 human-in-the-loop 安全要求
  2. SEP-2577: Deprecate Roots, Sampling, and Logging (Final, 2026-04-14) — 官方弃用提案全文:弃用范围、一年迁移窗口、三大原语的弃用理由与替代方案
  3. Roots — Model Context Protocol Specification — 根目录原语规范:roots/list、notifications/roots/list_changed 与 file:// URI 约束
  4. modelcontextprotocol/python-sdk — session.py (v1.28.1) — create_message 与 list_roots 的真实方法签名,本教程 API 示例的来源
  5. MCP Is Deprecating Sampling, Roots, and Logging (社区解读) — 弃用对生态的影响与一年迁移窗口的工程化解读

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