MCP 协议深潜:原语、双向能力与传输细节的技术内幕
作者: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/yaml、application/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 能力协商")获取。
- 点赞
- 收藏
- 关注作者
评论(0)