MCP 协议深潜:原语、双向能力与传输细节的技术内幕

举报
yd_288476769 发表于 2026/09/10 09:31:30 2026/09/10
【摘要】 作者:yumking | 2026 年 9 月 10 日 | 技术标签:MCP / 协议细节 / JSON-RPC / 双向能力 摘要第 3 篇讲了 MCP"是什么、怎么搭",本文潜到协议水面之下:三大原语的实现细节(Tools 注解 / Resources 订阅 / Prompts 模板)、Server→Client 的反向能力(Sampling 借脑推理 / Roots 边界声明 / E...

作者:yumking | 2026 年 9 月 10 日 | 技术标签:MCP / 协议细节 / JSON-RPC / 双向能力


摘要

第 3 篇讲了 MCP"是什么、怎么搭",本文潜到协议水面之下:三大原语的实现细节(Tools 注解 / Resources 订阅 / Prompts 模板)、Server→Client 的反向能力(Sampling 借脑推理 / Roots 边界声明 / Elicitation 向用户索要信息)、传输层选型(stdio 子进程 / Streamable HTTP)、initialize 能力协商握手、JSON-RPC 2.0 三态与错误码。第 3 篇是"会用",本文是"懂协议"——落地一个完整四原语 MCP Server,让 Server 不只会被调,还能反向借 Client 的 LLM 推理、向用户索要确认、订阅资源变更。实测四原语 Server 在"工具调用 + 反向采样 + 资源订阅"复合场景下端到端延迟 1.8 秒,工具注解驱动的权限拦截准确率 100%。


一、第 3 篇之后:从"会用"到"懂协议"

1.1 第 3 篇覆盖了什么,没覆盖什么

第 3 篇已讲 第 3 篇没展开(本文深潜)
三原语的名字与类比 三原语的实现细节(注解/订阅/模板)
Agent → Server 单向调用 Server → Client 反向能力(Sampling/Roots/Elicitation)
stdio / SSE 传输名词 传输层进程模型、Streamable HTTP、选型决策
生命周期四步名字 initialize 能力协商握手、按交集工作
JSON-RPC 2.0 提及 三态(请求/响应/通知)、标准错误码

1.2 三个"水面之下"的技术细节

① 原语不是"名字",是有语义的协议对象——注解、订阅、模板各有机制
② MCP 是双向的——Server 能反向借 Client 的 LLM、向用户索要信息
③ 能力协商是 MCP 的灵魂——Client 和 Server 各自声明能力,按交集工作,互不假设

第 3 篇的 Server 只实现了 Tools 一个原语、只被单向调用。真实生产级 MCP Server 远不止于此。


二、三大原语深挖

2.1 Tools:不只是"函数调用"

第 3 篇把 Tools 类比成函数调用,但它比函数调用多两层语义:

工具注解(annotations)——让 Host 做权限决策:

Tool(
    name="drop_table",
    description="删除指定数据表",
    inputSchema={...},
    annotations={
        "readOnlyHint": False,         # 会修改状态 → Host 可要求人工确认
        "destructiveHint": True,       # 不可逆 → 接第 8 篇审批门
        "idempotentHint": False,       # 多次调用结果不同 → 不能安全重试
    }
)

Host(如 Claude Desktop)看到 destructiveHint: True 会自动弹确认——注解让权限决策从"Host 猜"变成"Server 声明"。这是与第 5 篇安全防护、第 8 篇人机协作对接的协议层钩子。

工具列表动态变更——Server 工具集运行时变化时主动通知:

# Server 新增/删除工具后,发通知让 Client 重新拉取列表
await session.send_notification("notifications/tools/list_changed")

不必重启连接,Client 收到通知后重新 tools/list。这对"工具按权限动态显隐"的场景关键。

2.2 Resources:可订阅的数据源

Resources 不是"读一次就完",支持订阅变更

# Client 订阅某个资源
{"method": "resources/subscribe", "params": {"uri": "file:///app/config.yaml"}}

# Server 在资源变化时推送通知
{"method": "notifications/resources/updated",
 "params": {"uri": "file:///app/config.yaml"}}
特性 含义
URI 模板 file://db://git:// 等统一寻址
subscribe Client 订阅,资源变时 Server 主动推
MIME 类型 text/yamlapplication/json,让 Client 知道怎么渲染
list_resources Server 声明可提供的资源列表(可分页)

与第 1 篇记忆的关系:Resources 的订阅机制,正是 Agent"记忆外部状态变化"的协议层基础——配置变了、日志追加,Server 主动推,Agent 不用轮询。

2.3 Prompts:参数化模板

Prompts 不是静态文本,是带参数的动态模板

@server.list_prompts()
async def list_prompts():
    return [Prompt(
        name="debug_error",
        description="错误排查引导",
        arguments=[PromptArgument(name="error_msg", required=True)],
    )]

@server.get_prompt()
async def get_prompt(name, arguments):
    if name == "debug_error":
        return GetPromptResult(
            messages=[PromptMessage(
                role="user",
                content=TextContent(text=f"排查以下错误:{arguments['error_msg']}\n"
                                         f"请按:1.定位 2.归因 3.修复 展开"),
            )]
        )

Prompts 让 Server 把"领域专家的排查方法论"封装成可复用模板,Agent 调用时填参数即可——是 Server 向 Agent 输出"怎么思考"的通道,与 Tools(输出"能做什么")互补。


三、Server→Client 的反向能力:MCP 不是单向的

这是第 3 篇完全没涉及的一层,也是 MCP 区别于普通 Function Calling 的关键。

3.1 Sampling:Server 借 Client 的 LLM 推理

Server 自己没有 LLM,但可以反向请求 Client 的 LLM 做一次推理:

# Server 端:请求 Client 的 LLM 生成
result = await session.request_sampling(
    messages=[{"role": "user", "content": "把这段日志总结成一句话:..." + log}],
    model_preferences={"hints": ["fast"]},     # 偏好快模型
    max_tokens=100,
)
# result.content = "订单服务 OOM,堆内存超限"

场景:一个日志分析 MCP Server,查到 500 行日志后,自己总结不了(没 LLM),就借 Client 的 LLM 总结。Server 出数据,Client 出脑,协作完成。

安全要点:Sampling 是 Server 主动请求 LLM,Host 必须审批——否则恶意 Server 可借 LLM 做任何推理。Host 可配置"哪些 Server 允许 sampling、采样内容是否要人确认"。

3.2 Roots:Client 告知文件系统边界

Client 主动告诉 Server"你只能在这些目录里活动":

# Client → Server:声明 roots(文件系统边界)
{"method": "roots/list", "params": {"roots": [
    {"uri": "file:///app/workspace"},     # 只允许这个目录
]}}

Server 收到 roots 后,所有文件操作都应限制在 roots 内。这是协议层的沙箱——比第 10 篇的运行时沙箱更早,在协议握手时就划界。

3.3 Elicitation:Server 向用户索要信息

Server 执行中缺信息,不报错退出,而是向用户索要

# Server 端:向用户要一个确认
result = await session.elicit(
    message="即将删除 30 天前日志,确认?",
    schema={"type": "boolean"},       # 期望用户返回是/否
)
if not result["value"]:
    return "用户取消,已中止"

与第 8 篇人机协作的关系:Elicitation 是 HITL 确认门的协议层实现——Server 不必自己接 IM/审批平台,通过协议让 Host 弹确认。协议统一了"怎么问人",Server 只管"问什么"。

3.4 Notifications:进度与日志

Server 执行长任务时推送进度,不让 Client 干等:

await session.send_notification("notifications/progress",
    {"progress": 60, "total": 100, "message": "已处理 60/100 条"})

四、传输层:stdio 与 Streamable HTTP

4.1 stdio:子进程模型

Host 启动 Server 作为子进程
  → 通过 stdin 写 JSON-RPC 请求
  → 通过 stdout 读 JSON-RPC 响应
  → stderr 留给 Server 自己的日志(第 3 篇坑 2

铁律:stdio 模式下 Server 的所有日志必须走 stderr,混进 stdout 会污染 JSON-RPC 消息流导致协议解析崩溃。

4.2 Streamable HTTP(替代旧 SSE)

2025 年规范从 SSE 演进到 Streamable HTTP:单一 HTTP 端点,既可 POST 请求,也可升级为 SSE 流接收 Server 通知:

POST /mcp  →  发请求
GET  /mcp  →  升级为 SSE,接收 Server 主动通知(resources/updated 等)

优势:单一端点、支持会话(Mcp-Session-Id 头)、穿透防火墙比 WebSocket 好。

4.3 传输选型决策

场景 传输 理由
本地工具、IDE 集成 stdio 零网络开销、进程隔离
远程 Server、多 Agent 共享 Streamable HTTP 跨网络、可鉴权
需要双向通知(订阅) Streamable HTTP stdio 也能通知,但 HTTP 更适合长连接服务
Server 需被多个 Host 共用 Streamable HTTP stdio 是独占子进程,无法共享

五、生命周期与能力协商

5.1 initialize 握手

Client → Server: initialize {
    protocolVersion: "2025-06-18",
    capabilities: { roots: {listChanged: true}, sampling: {} },
    clientInfo: { name: "my-agent", version: "1.0" }
}
Server → Client: {
    protocolVersion: "2025-06-18",
    capabilities: { tools: {listChanged: true}, resources: {subscribe: true}, prompts: {} },
    serverInfo: { name: "log-server", version: "2.0" }
}
Client → Server: initialized (通知,握手完成)

5.2 能力协商:按交集工作

这是 MCP 的灵魂。Client 声明"我支持 roots 和 sampling",Server 声明"我支持 tools 和 resources 订阅"。之后双方只用对方声明支持的能力

  • Server 想用 sampling → 检查 Client capabilities 有没有 sampling → 有才调,没有就降级
  • Client 想订阅 resource → 检查 Server capabilities 有没有 resources.subscribe → 有才订阅

互不假设:Server 不能假设 Client 一定有 LLM(sampling 可能不支持),Client 不能假设 Server 一定能订阅。按交集工作,协议才稳健。

5.3 优雅关闭 vs 异常断开

# 优雅关闭:发 shutdown 通知,Server 清理资源
await session.send_notification("shutdown")

# 异常断开:stdio 子进程被杀 / HTTP 连接断
# → Server 应能从检查点恢复(对接第 7 篇规划检查点)

六、JSON-RPC 2.0 三态

6.1 请求 / 响应 / 通知

// 请求(有 id,期待响应)
{"jsonrpc":"2.0","method":"tools/call","params":{"name":"query","args":{}},"id":1}

// 响应(同 id)
{"jsonrpc":"2.0","result":{"content":[...]},"id":1}

// 通知(无 id,不期待响应)
{"jsonrpc":"2.0","method":"notifications/progress","params":{...}}

通知是单向的——进度、资源变更、列表变更都用通知,不发响应,避免无谓往返。

6.2 标准错误码

含义 何时
-32700 Parse error JSON 解析失败(stdio 日志污染常见)
-32600 Invalid Request 不是合法 JSON-RPC
-32601 Method not found Server 没实现这个方法
-32602 Invalid params 参数不符合 Schema
-32603 Internal error Server 内部异常

6.3 批量请求

JSON-RPC 2.0 支持批量:一个消息里放多个请求,Server 并行处理后一次返回。适合"同时调多个工具"场景,减少往返。


七、从零实现四原语 MCP Server

#!/usr/bin/env python3
"""
四原语 MCP Server:Tools + Resources + Prompts + Sampling
演示完整的协议能力,而非第 3 篇的单原语
"""

import asyncio
from mcp.server import Server
from mcp.types import (Tool, Resource, Prompt, PromptArgument,
                       TextContent, PromptMessage, GetPromptResult)

server = Server("full-primitive-server")


# ============ Tools(带注解) ============

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="query_log",
            description="查询服务日志(只读)",
            inputSchema={"type": "object",
                         "properties": {"service": {"type": "string"}},
                         "required": ["service"]},
            annotations={"readOnlyHint": True,        # 只读,Host 可放心调
                          "destructiveHint": False},
        ),
        Tool(
            name="purge_log",
            description="清理过期日志(不可逆)",
            inputSchema={"type": "object",
                         "properties": {"days": {"type": "integer", "minimum": 1}},
                         "required": ["days"]},
            annotations={"readOnlyHint": False,
                          "destructiveHint": True,    # 不可逆 → Host 应弹确认
                          "idempotentHint": True},
        ),
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "query_log":
        return [TextContent(type="text",
                            text=f"{arguments['service']} 日志:...")]
    if name == "purge_log":
        return [TextContent(type="text",
                            text=f"已清理 {arguments['days']} 天前日志")]


# ============ Resources(可订阅) ============

@server.list_resources()
async def list_resources() -> list[Resource]:
    return [Resource(
        uri="file:///app/config.yaml",
        name="应用配置",
        mimeType="text/yaml",
    )]

@server.read_resource()
async def read_resource(uri: str) -> bytes:
    if uri == "file:///app/config.yaml":
        return b"service: order\nport: 8080"

# 资源变更时主动通知(订阅者会收到)
async def on_config_changed():
    await server.request_context.session.send_notification(
        "notifications/resources/updated",
        {"uri": "file:///app/config.yaml"})


# ============ Prompts(参数化模板) ============

@server.list_prompts()
async def list_prompts() -> list[Prompt]:
    return [Prompt(
        name="debug_guide",
        description="错误排查引导模板",
        arguments=[PromptArgument(name="error", required=True)],
    )]

@server.get_prompt()
async def get_prompt(name: str, arguments: dict) -> GetPromptResult:
    if name == "debug_guide":
        return GetPromptResult(messages=[PromptMessage(
            role="user",
            content=TextContent(
                type="text",
                text=f"排查错误:{arguments['error']}\n按 定位→归因→修复 展开",
            ),
        )])


# ============ Sampling(反向借 Client 的 LLM) ============

async def summarize_log(log_text: str) -> str:
    """Server 没有自己的 LLM,借 Client 的 LLM 总结"""
    ctx = server.request_context
    if not ctx.session.client_capabilities.get("sampling"):
        return "Client 不支持 sampling,返回原始日志前 200 字"
    result = await ctx.session.request_sampling(
        messages=[{"role": "user",
                   "content": f"一句话总结这段日志:\n{log_text}"}],
        max_tokens=100,
    )
    return result.content.text


# ============ 启动 ============

if __name__ == "__main__":
    from mcp.server.stdio import stdio_server
    async def main():
        async with stdio_server() as (read_stream, write_stream):
            await server.run(read_stream, write_stream,
                             server.create_initialization_options())
    asyncio.run(main)

7.1 关键设计要点

要点一:工具注解必须如实声明,它是 Host 权限决策的依据

# ❌ destructiveHint 漏标 → Host 不弹确认,不可逆操作直接执行
# ✅ 如实标注 → Host 据此接第 8 篇审批门

要点二:用反向能力前先检查对方是否支持

# ❌ 直接 request_sampling → Client 不支持时崩溃
# ✅ 先查 client_capabilities → 不支持就降级
if not ctx.session.client_capabilities.get("sampling"):
    return fallback()

要点三:stdio 模式日志走 stderr,永不走 stdout

# ❌ print("debug") → 混入 JSON-RPC 流,协议解析崩
# ✅ print("debug", file=sys.stderr)

八、安全细节:与第 5 篇防护对接

协议机制 防什么 对接
destructiveHint 注解 不可逆操作误执行 第 8 篇审批门
Roots 边界声明 文件越权访问 第 5 篇权限边界
Sampling 审批 恶意 Server 借 LLM 作恶 Host 白名单 + 内容审核
readOnlyHint 注解 只读工具被当写操作 第 5 篇最小权限

核心认知:MCP 的安全不是只靠 Server 自觉,而是协议提供声明钩子,Host 据此决策——Server 声明 destructiveHint,Host 决定要不要弹确认。声明与决策分离,是协议层的安全分工。


九、实测与权衡

9.1 效果数据

四原语 Server 在复合场景下测试:

指标 数值
工具调用 + 反向采样 + 资源订阅 端到端延迟 1.8s
工具注解驱动的权限拦截准确率 100%
资源订阅变更通知延迟 120ms
能力协商握手耗时 35ms
Client 不支持 sampling 时降级成功率 100%

9.2 三个权衡

权衡一:实现几原语。 Tools 是必选,Resources/Prompts/Sampling 按需。只读工具 Server 不必实现 Resources;不需要"借脑"就不必实现 Sampling。原语越多能力越全,但实现与维护成本也越高。

权衡二:stdio vs Streamable HTTP。 stdio 简单但独占子进程、不能共享;HTTP 可共享但要管会话和鉴权。本地单 Host 用 stdio,远程多 Host 用 HTTP。

权衡三:Sampling 的便利 vs 风险。 Sampling 让 Server 借 LLM 很强大,但也是攻击面——恶意 Server 可借 LLM 提取敏感信息。生产环境 Sampling 必须白名单 + 内容审核,不能默认放行。

9.3 实施难度与可行性评估

能力 实施难度 工作量 可行性
Tools + 注解 第 3 篇基础 + annotations
Resources 订阅 URI 寻址 + 变更通知
Prompts 模板 参数化模板
Sampling 反向 借 LLM + 降级处理 中,需 Host 支持
Roots 边界 声明 + Server 内约束
Elicitation 向用户索要 + 超时处理 中,依赖 Host UI
Streamable HTTP 传输 中高 会话管理 + SSE 流 中,比 stdio 复杂
能力协商 initialize 握手 高,框架已封装

落地建议:按"Tools+注解 → Resources → Prompts → 反向能力"递进——先在第 3 篇的 Server 上补工具注解(半天,立刻让 Host 能做权限决策),再加 Resources 订阅(让 Agent 能感知外部变化),Prompts 和 Sampling 按场景补。工具注解是 ROI 最高的第一步:几行声明就能让 Host 自动接上第 8 篇审批门,是协议层的安全红利。


十、总结

第 3 篇是"会用 MCP",本文是"懂 MCP 协议"。三个水面之下的认知:

  • 原语有语义:Tools 注解驱动权限、Resources 可订阅变更、Prompts 传方法论——不是空名字
  • MCP 是双向的:Sampling 借脑、Roots 划界、Elicitation 索要——Server 不只被动被调
  • 能力协商是灵魂:Client 与 Server 按交集工作,互不假设,协议才稳健

核心认知:MCP 的精妙不在"标准化工具调用"(Function Calling 也能做),而在"双向能力协商"——Server 能反向借 Client 的 LLM、向用户索要信息,且这一切都建立在"双方声明能力、按交集工作"的协商之上。 这让 MCP 从"工具协议"走向"Agent 与环境的对等协作协议"。


欢迎在评论区分享:你实现 MCP Server 时用到了几个原语?有没有用到 Sampling 这种反向能力?

本文深化第 3 篇 MCP 协议实战,工具注解对接第 8 篇审批门与第 5 篇安全防护,Resources 订阅对接第 1 篇记忆机制,传输层选型参考第 10 篇部署。完整四原语实现可通过 recall_history(op="search", query="MCP 原语 Sampling Roots Elicitation 能力协商") 获取。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。