为什么本地完美运行的代码,部署后 50% 功能消失?一篇文章讲透 MCP 的 Sampling、Progress、Roots 和 Stateless HTTP 的残酷真相
开篇:一个看似简单的部署灾难
想象这样一个场景:你花了两周时间开发了一个 MCP 服务器,功能包括从 Wikipedia 抓取数据、用 Claude 生成摘要、显示实时进度条。本地测试时一切完美,日志清晰,进度条流畅,AI 摘要质量也很高。
然后你把它部署到云端,准备给用户使用。启动服务器,配置负载均衡,测试第一个请求------然后你惊恐地发现:
- 进度条不见了
- 日志消息全部消失
- 最要命的是,AI 摘要功能直接报错,整个核心功能瘫痪
你检查代码,逻辑没问题。检查网络,连接正常。检查配置文件,发现了一个不起眼的配置项:
py
mcp.run(transport="http", stateless_http=True)
就是这一个 stateless_http=True,让你的服务器功能损失了一半。为什么?
这不是 bug,这是设计。这是 MCP 协议在面对现实世界的残酷妥协------当你需要横向扩展、负载均衡、多实例部署时,你必须付出功能上的代价。
这篇文章会深入剖析 MCP 的五大高级特性:Sampling、Logging & Progress、Roots、stdio Transport 机制、以及 Streamable HTTP 的双 SSE 架构。更重要的是,我会告诉你为什么某些配置会破坏这些特性,以及这背后的技术原因。 zzzzzz
Sampling:当服务器需要借用客户端的 AI 大脑
问题的起源
假设你正在构建一个 MCP 服务器,提供一个研究工具。工作流程是这样的:
- 用户问:"总结一下量子计算的最新进展"
- 你的服务器调用工具,从 Wikipedia、arXiv 抓取相关文章
- 服务器拿到一堆原始文本,现在需要生成一个连贯的摘要
问题来了:谁来做这个摘要?
方案 A:服务器自己集成 Claude
py
# 服务器需要自己的 API 密钥
anthropic_client = AsyncAnthropic(api_key="sk-ant-...")
@mcp.tool()
async def research(topic: str):
# 抓取数据
articles = fetch_wikipedia(topic)
# 服务器调用 Claude
summary = await anthropic_client.messages.create(
model="claude-sonnet-4-0",
messages=[{"role": "user", "content": f"总结这些文章:{articles}"}]
)
return summary
这个方案看起来很直接,但有几个致命问题:
成本爆炸 。如果你的服务器是公开的,任何人都可以调用你的工具。假设你的服务器每天有 1000 个用户,每个用户平均调用 10 次研究工具,每次摘要消耗 5000 tokens(输入 4000 + 输出 1000)。按照 Claude Sonnet 的定价(输入 <math xmlns="http://www.w3.org/1998/Math/MathML"> 3 / M T o k ,输出 3/MTok,输出 </math>3/MTok,输出15/MTok):
bash
每日成本 = 1000 用户 × 10 次 × (4000 × $3 + 1000 × $15) / 1,000,000
= 1000 × 10 × ($0.012 + $0.015)
= 1000 × 10 × $0.027
= $270/天
= $8,100/月
一个月 8000 多美元,这对于一个开源项目或个人项目来说是灾难性的。
安全风险。你需要在服务器代码或环境变量中存储 API 密钥。一旦代码泄漏、服务器被入侵、配置文件被误提交到 GitHub,你的密钥就暴露了。攻击者可以用你的密钥无限制地调用 Claude,直到你的信用卡被刷爆。
复杂度上升。你需要处理 Claude API 的错误处理、速率限制、重试逻辑、流式响应。如果你的服务器还支持其他 AI 模型(GPT-4、Gemini),你需要为每个模型写集成代码。
Sampling 的优雅解决方案
MCP 提供了一个反直觉但极其聪明的设计:让服务器向客户端请求 AI 服务。
流程变成这样:
arduino
用户: "总结量子计算进展"
↓
Client (Claude Desktop) → Server: 调用 research 工具
↓
Server: 抓取 Wikipedia 文章
Server: 生成 prompt = "总结这些文章:[文章内容]"
↓
Server → Client: sampling 请求("帮我用 Claude 生成摘要")
↓
Client: 调用 Claude API(用客户端自己的 API 密钥)
Client: 拿到摘要结果
↓
Client → Server: 返回摘要
↓
Server → Client: 工具最终结果
↓
Client: 显示给用户
这个设计的精妙之处在于责任转移:
- 服务器不需要 API 密钥:客户端负责调用 Claude
- 成本由用户承担:每个用户用自己的 API 配额
- 服务器逻辑简化:服务器只需要生成 prompt,不需要处理 AI API 的复杂性
实现细节:双端代码
服务器端代码(使用 FastMCP):
py
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import SamplingMessage, TextContent
mcp = FastMCP(name="Research Server")
@mcp.tool()
async def research(topic: str, ctx: Context):
# 第一步:抓取数据
articles = fetch_wikipedia(topic)
# 第二步:构造 prompt
prompt = f"""
以下是关于 {topic} 的文章摘录:
{articles}
请生成一个 500 字的摘要,重点关注最新进展。
"""
# 第三步:请求客户端调用 Claude
result = await ctx.session.create_message(
messages=[
SamplingMessage(
role="user",
content=TextContent(type="text", text=prompt)
)
],
max_tokens=4000,
system_prompt="你是一个专业的科研助手,擅长总结学术文献。"
)
# 第四步:返回结果
if result.content.type == "text":
return result.content.text
else:
raise ValueError("Sampling 失败:客户端未返回文本")
关键点:
ctx.session.create_message()是发起 sampling 请求的方法SamplingMessage定义了发送给 Claude 的消息(可以是多轮对话)max_tokens限制生成长度system_prompt设置 Claude 的角色
客户端代码:
py
from anthropic import AsyncAnthropic
from mcp import ClientSession
from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent
anthropic_client = AsyncAnthropic() # 使用客户端的 API 密钥
async def sampling_callback(context, params: CreateMessageRequestParams):
"""处理服务器的 sampling 请求"""
# 提取消息
messages = []
for msg in params.messages:
if msg.role == "user" and msg.content.type == "text":
messages.append({
"role": "user",
"content": msg.content.text
})
# 调用 Claude
response = await anthropic_client.messages.create(
model="claude-sonnet-4-0",
messages=messages,
max_tokens=params.max_tokens,
system=params.system_prompt if hasattr(params, 'system_prompt') else None
)
# 提取文本
text = "".join([block.text for block in response.content if block.type == "text"])
# 返回结果给服务器
return CreateMessageResult(
role="assistant",
model="claude-sonnet-4-0",
content=TextContent(type="text", text=text)
)
# 创建客户端会话时注册 callback
async with ClientSession(read, write, sampling_callback=sampling_callback) as session:
await session.initialize()
# 调用工具
result = await session.call_tool(
name="research",
arguments={"topic": "quantum computing"}
)
关键点:
sampling_callback是客户端注册的回调函数- 服务器调用
create_message()时,客户端的这个 callback 会被触发 - Callback 负责实际调用 Claude API,然后把结果返回给服务器
- 整个过程对最终用户透明
Sampling 的使用场景
这个特性不是为所有场景设计的,它最适合以下情况:
公开服务器。如果你想把 MCP 服务器部署成公共服务(比如 mcp.stripe.com),你绝对不想让随机用户消耗你的 AI 配额。Sampling 让每个用户用自己的配额,你只需要维护服务器的计算资源(CPU、内存、带宽),这些成本通常远低于 AI token 成本。
需要 AI 增强的工具。很多 MCP 工具本身是数据处理(搜索、抓取、格式转换),但最终输出需要 AI 润色。比如代码搜索工具抓取了 100 个代码片段,需要 AI 总结成一个清晰的答案。
降低服务器复杂度。如果你的团队没有 AI 集成经验,不想处理 prompt 工程、上下文管理、流式响应等问题,Sampling 让你把这些复杂度推给客户端(通常是 Claude Desktop 或其他成熟的 MCP 客户端,它们已经处理好了这些问题)。
Sampling 的限制
不是所有客户端都支持 。Sampling 是可选特性,客户端需要实现 sampling_callback 才能处理服务器的请求。如果客户端不支持,create_message() 会失败。你需要在文档中明确说明"此服务器需要支持 Sampling 的客户端"。
增加延迟。正常的工具调用是:Client → Server → Client(一次往返)。启用 Sampling 后变成:Client → Server → Client → Claude API → Client → Server → Client(三次往返加上 AI 推理时间)。对于延迟敏感的应用,这可能是个问题。
调试困难。当 Sampling 失败时,你需要检查三个地方:服务器的 prompt 是否正确?客户端的 callback 是否正确实现?Claude API 是否返回了预期结果?错误可能发生在任何一个环节。
Logging & Progress:让长时间运行的工具不再像黑盒
想象一下这个场景:
用户: "分析这个 10GB 的日志文件,找出所有错误" Claude: 调用 analyze_logs 工具 用户: (等待) 用户: (继续等待) 用户: (30 秒后)这东西是卡住了还是在工作? 用户: (60 秒后)要不要重启一下? Claude: (90 秒后突然出现)分析完成,找到 1247 个错误
这是典型的黑盒操作。用户完全不知道工具在做什么,只能焦虑地等待。如果工具真的卡住了,用户也不知道;如果工具正常运行,用户也不放心。
MCP 的解决方案:Context 对象
MCP 的每个工具函数都会收到一个 Context 对象(通常命名为 ctx),这个对象提供了两个关键方法:
ctx.info(message)- 发送日志消息ctx.report_progress(current, total)- 报告进度
服务器端实现:
py
from mcp.server.fastmcp import FastMCP, Context
import asyncio
mcp = FastMCP(name="Log Analyzer")
@mcp.tool()
async def analyze_logs(file_path: str, ctx: Context):
# 阶段 1:打开文件
await ctx.info(f"正在打开文件:{file_path}")
await ctx.report_progress(0, 100)
file_size = get_file_size(file_path)
await ctx.info(f"文件大小:{file_size / 1024 / 1024:.2f} MB")
# 阶段 2:读取和解析
await ctx.info("开始解析日志...")
await ctx.report_progress(10, 100)
errors = []
lines_processed = 0
total_lines = count_lines(file_path)
with open(file_path, 'r') as f:
for line in f:
lines_processed += 1
# 每处理 1000 行更新一次进度
if lines_processed % 1000 == 0:
progress = 10 + (lines_processed / total_lines * 80)
await ctx.report_progress(int(progress), 100)
await ctx.info(f"已处理 {lines_processed}/{total_lines} 行")
# 解析逻辑
if "ERROR" in line or "FATAL" in line:
errors.append(parse_error(line))
# 模拟处理时间
if lines_processed % 10000 == 0:
await asyncio.sleep(0.1)
# 阶段 3:生成报告
await ctx.info("生成分析报告...")
await ctx.report_progress(95, 100)
report = generate_report(errors)
await ctx.info(f"分析完成,找到 {len(errors)} 个错误")
await ctx.report_progress(100, 100)
return report
关键设计原则:
日志消息要有信息量。不要写"正在处理"这种废话,要写"已处理 50000/100000 行,找到 12 个错误"。用户需要知道具体进展,而不是模糊的状态。
进度值要合理划分。不要线性映射进度(比如处理 50% 的行就报告 50% 进度),因为不同阶段的耗时不同。上面的例子中,文件打开是 0-10%,解析是 10-90%,生成报告是 90-100%,这反映了实际的时间分配。
必须是 async 函数 。ctx.info() 和 ctx.report_progress() 都是 async 方法,必须用 await 调用。
频率控制。不要每处理一行就发送一次进度(会产生数千条消息)。上面的例子每 1000 行发送一次,是一个合理的平衡。
客户端实现:灵活的展示策略
客户端需要注册回调函数来处理这些通知:
py
from mcp import ClientSession
from mcp.types import LoggingMessageNotificationParams
async def logging_callback(params: LoggingMessageNotificationParams):
"""处理日志消息"""
print(f"[LOG] {params.data}")
async def progress_callback(progress: float, total: float | None, message: str | None):
"""处理进度更新"""
if total is not None:
percentage = (progress / total) * 100
print(f"[PROGRESS] {percentage:.1f}% ({progress}/{total})")
else:
print(f"[PROGRESS] {progress} 步完成")
# 创建会话
async with ClientSession(
read, write,
logging_callback=logging_callback
) as session:
await session.initialize()
await session.call_tool(
name="analyze_logs",
arguments={"file_path": "/var/log/app.log"},
progress_callback=progress_callback
)
注意架构设计:
- Logging callback 注册在 ClientSession 级别:所有工具的日志都会发到这个 callback
- Progress callback 注册在 call_tool 级别:每个工具调用可以有不同的进度处理逻辑
关键限制:这些特性依赖双向通信
Logging 和 Progress 的工作原理是:服务器主动发送通知给客户端。
在 stdio transport 中,这没问题,因为服务器可以随时写入 stdout。
在 Streamable HTTP transport 中,这需要 SSE 连接。
但如果你启用了 stateless_http=True,这些特性会完全失效 。服务器调用 ctx.info() 和 ctx.report_progress() 不会报错,但消息会被悄悄丢弃,客户端永远收不到。
这是部署时的最大陷阱之一。
Roots:解决"文件在哪里"的难题
问题场景
假设你开发了一个视频转换工具:
py
@mcp.tool()
async def convert_video(input_path: str, output_format: str):
"""将视频转换为指定格式"""
# ffmpeg -i input_path -c:v libx264 output_path
...
用户的对话可能是这样的:
py
用户: "把 biking.mp4 转换成 MOV 格式"
Claude: 好的,我需要文件的完整路径。请提供完整路径。
用户: 呃... /Users/alice/Movies/biking.mp4
Claude: 调用 convert_video 工具
这个体验很糟糕。用户知道文件在 Movies 文件夹,但必须手动输入完整路径。
更糟糕的是,Claude 无法帮助用户。即使用户说"在 Movies 文件夹里",Claude 也无法遍历文件系统去找文件。
Roots 的解决方案
Roots 是一个权限和导航系统。客户端告诉服务器:"你可以访问这些目录",然后服务器(以及 Claude)可以在这些目录中搜索文件。
工作流程:
py
1. 用户启动 MCP 客户端(如 Claude Desktop)
2. 用户配置 Roots:["/Users/alice/Movies", "/Users/alice/Desktop"]
3. 用户:"把 biking.mp4 转换成 MOV"
4. Claude: 调用 list_roots() → ["/Users/alice/Movies", "/Users/alice/Desktop"]
5. Claude: 调用 read_dir("/Users/alice/Movies") → ["biking.mp4", "vacation.mov", ...]
6. Claude: 找到了!文件在 /Users/alice/Movies/biking.mp4
7. Claude: 调用 convert_video("/Users/alice/Movies/biking.mp4", "mov")
用户只需要说文件名,Claude 会自动搜索 Roots 目录找到完整路径。
服务器端实现:手动权限检查
MCP SDK 不会自动执行权限检查。你需要自己实现:
py
from pathlib import Path
from mcp.server.fastmcp import FastMCP, Context
mcp = FastMCP(name="File Tools")
async def is_path_allowed(path: str, ctx: Context) -> bool:
"""检查路径是否在允许的 Roots 内"""
requested_path = Path(path).resolve()
# 获取客户端配置的 Roots
roots = await ctx.session.list_roots()
for root in roots.roots:
root_path = Path(root.uri.replace("file://", "")).resolve()
try:
requested_path.relative_to(root_path)
return True
except ValueError:
continue
return False
@mcp.tool()
async def convert_video(input_path: str, output_format: str, ctx: Context):
# 权限检查
if not await is_path_allowed(input_path, ctx):
raise PermissionError(
f"无法访问 {input_path}。此路径不在允许的目录范围内。"
)
output_path = input_path.replace(Path(input_path).suffix, f".{output_format}")
if not await is_path_allowed(output_path, ctx):
raise PermissionError(f"无法写入 {output_path}")
# 实际转换逻辑
await run_ffmpeg(input_path, output_path, output_format)
return f"转换完成:{output_path}"
关键点:
使用 .resolve() 解析路径。这会处理符号链接、.. 和 .,防止路径遍历攻击。
检查相对路径关系 。requested_path.relative_to(root_path) 会检查 requested_path 是否在 root_path 的子树中。
读写都要检查。不仅输入文件要在 Roots 内,输出文件也要在 Roots 内。
安全考量:Roots 不是沙箱
需要明确的是:Roots 是一个约定,不是强制的安全边界。
MCP SDK 不会阻止你的代码访问 Roots 之外的文件。
Roots 的作用是:
用户意图明确化。用户通过配置 Roots 告诉服务器:"我只想让你访问这些目录"。
Claude 的导航指引。Claude 知道可以在哪些目录搜索文件。
错误提示友好化。当用户尝试访问 Roots 之外的文件时,服务器可以给出清晰的错误信息。
stdio Transport 深度解析:最纯粹的双向通信
底层机制:进程、管道、文件描述符
当你在 MCP 客户端配置一个 stdio 服务器时:
json
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["server.py"]
}
}
}
客户端会执行以下操作:
py
import subprocess
# 启动子进程
process = subprocess.Popen(
["python", "server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
操作系统层面,这创建了三个匿名管道:
arduino
Client Process Server Process
┌──────────────┐ ┌──────────────┐
│ stdin (FD 10)│────────────>│ stdin (FD 0) │
│stdout (FD 11)│<────────────│stdout (FD 1) │
│stderr (FD 12)│<────────────│stderr (FD 2) │
└──────────────┘ └──────────────┘
关键特性:
管道是双向的(从逻辑上讲)。虽然每个管道本身是单向的,但通过两个管道实现了双向通信。
阻塞 I/O。当管道为空时,读取操作会阻塞。当管道满时,写入操作会阻塞。
进程生命周期绑定。子进程的生命周期完全由父进程控制。
消息格式:JSON-RPC over newlines
MCP 使用 JSON-RPC 2.0 作为消息格式,但需要解决一个问题:管道是字节流,没有消息边界。
解决方案是:每条消息占一行,用 \n 分隔。
py
# 服务器发送消息
import json
import sys
message = {
"jsonrpc": "2.0",
"result": {"tools": [...]},
"id": 1
}
json_str = json.dumps(message, separators=(',', ':'))
sys.stdout.write(json_str + '\n')
sys.stdout.flush()
关键约束:
- JSON 必须是紧凑格式,不能包含换行符
- 每条消息后必须立即 flush 缓冲区
- 服务器的 stdout 只能用于 MCP 消息
这就是为什么服务器代码中不能有 print() 语句(除非写到 stderr)。
MCP 连接的三步握手
每个 MCP 连接必须以固定的三步握手开始:
步骤 1:Initialize Request(客户端 → 服务器)
json
{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {
"roots": {"listChanged": true},
"sampling": {}
},
"clientInfo": {
"name": "Claude Desktop",
"version": "1.0.0"
}
},
"id": 1
}
步骤 2:Initialize Result(服务器 → 客户端)
json
{
"jsonrpc": "2.0",
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {},
"resources": {},
"prompts": {},
"logging": {}
},
"serverInfo": {
"name": "My MCP Server",
"version": "0.1.0"
}
},
"id": 1
}
步骤 3:Initialized Notification(客户端 → 服务器)
json
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
这是一个通知(notification),不需要响应。
四种通信场景的实现
stdio transport 支持 MCP 的所有四种通信模式:
场景 1:客户端请求 → 服务器响应(最常见)
json
Client: {"jsonrpc":"2.0","method":"tools/call",...,"id":2}
Server: {"jsonrpc":"2.0","result":{...},"id":2}
场景 2:服务器通知 → 客户端
json
Server: {"jsonrpc":"2.0","method":"notifications/progress","params":{...}}
这就是 ctx.report_progress() 的底层实现。
场景 3:服务器请求 → 客户端响应
json
Server: {"jsonrpc":"2.0","method":"sampling/createMessage",...,"id":100}
Client: {"jsonrpc":"2.0","result":{...},"id":100}
这就是 ctx.session.create_message() 的底层实现。
场景 4:客户端通知 → 服务器
json
Client: {"jsonrpc":"2.0","method":"notifications/cancelled",...}
为什么 stdio 是"理想状态"
stdio transport 支持所有四种通信模式,没有任何限制。这是因为:
真正的双向通道。客户端和服务器都可以随时发起请求或通知。
低延迟。管道是内核管理的内存缓冲区,延迟通常在微秒级别。
简单的实现。不需要处理网络协议、连接管理、超时、重连。
完美的生命周期管理。父进程终止时,子进程自动收到信号。
但 stdio 的致命限制是:只能在同一台机器上运行。如果你想把 MCP 服务器部署成云服务,stdio 就不够了。
Streamable HTTP:绕过 HTTP 限制的精巧设计
HTTP 的根本问题
HTTP 是为客户端-服务器模型设计的:
arduino
Client ──── Request ───→ Server
Client ←─── Response ──── Server
客户端主动发起请求,服务器被动响应。但 MCP 需要:服务器主动向客户端发送消息(Sampling 请求、Progress 通知、Logging 消息)。
但在 HTTP 中,服务器没有客户端的 URL。服务器无法向客户端发起 HTTP 请求。
SSE:单向流式推送的救星
Server-Sent Events (SSE) 是 HTML5 引入的技术,允许服务器通过 HTTP 连接向客户端推送事件流。
SSE 的工作原理:
yaml
Client: GET /events HTTP/1.1
Accept: text/event-stream
Server: HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"message": "hello"}
data: {"message": "world"}
(连接保持打开)
关键点:
长连接。HTTP 响应不会立即结束,而是保持打开状态。
流式数据。服务器可以随时写入数据,客户端实时接收。
事件格式 。每个事件以 data: 开头,用空行分隔。
SSE 解决了"服务器如何向客户端推送消息"的问题。但还有一个问题:客户端如何向服务器发送消息?
答案是:用另一个 HTTP 请求。
Streamable HTTP 的双连接架构
MCP 的 Streamable HTTP transport 使用两个独立的 HTTP 连接:
连接 1:Primary SSE 连接(可选的 GET 请求)
yaml
Client: GET /mcp HTTP/1.1
Accept: text/event-stream
Mcp-Session-Id: abc123
Server: HTTP/1.1 200 OK
Content-Type: text/event-stream
(长期保持打开)
这个连接用于:
- 服务器主动发起的请求(Sampling、List Roots)
- Progress 通知
- 服务器 → 客户端的其他通信
连接 2:Tool-Specific SSE 连接(POST 请求)
yaml
Client: POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: abc123
{"jsonrpc":"2.0","method":"tools/call",...}
Server: HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"jsonrpc":"2.0","method":"notifications/message",...}
data: {"jsonrpc":"2.0","result":{...}}
(连接关闭)
这个连接用于:
- 客户端发起的请求(Tools、Resources、Prompts)
- Logging 消息(在处理请求过程中)
- 请求的最终结果
为什么需要两个连接?
消息路由。不同类型的消息需要通过不同的连接传输:
- Progress 通知 → Primary SSE(可能在工具执行期间的任何时候发送)
- Logging 消息 → Tool-Specific SSE(与特定的工具调用相关)
- Sampling 请求 → Primary SSE(服务器主动发起)
- 工具结果 → Tool-Specific SSE(对 POST 请求的响应)
连接管理。Primary SSE 连接是长期的(可能保持数小时),而 Tool-Specific SSE 连接是短暂的(请求完成后立即关闭)。
完整的通信流程示例
让我们跟踪一个完整的工具调用:
初始化阶段:
bash
Step 1: Client → Server (POST)
POST /mcp
{"jsonrpc":"2.0","method":"initialize",...,"id":1}
Step 2: Server → Client (HTTP Response)
HTTP/1.1 200 OK
Mcp-Session-Id: abc123
{"jsonrpc":"2.0","result":{...},"id":1}
Step 3: Client → Server (POST)
POST /mcp
Mcp-Session-Id: abc123
{"jsonrpc":"2.0","method":"notifications/initialized"}
Server: HTTP/1.1 202 Accepted
建立 Primary SSE 连接:
bash
Step 4: Client → Server (GET)
GET /mcp
Mcp-Session-Id: abc123
Server: HTTP/1.1 200 OK
Content-Type: text/event-stream
(连接保持打开)
调用工具(带 Progress 和 Sampling) :
bash
Step 5: Client → Server (POST)
POST /mcp
Mcp-Session-Id: abc123
{"jsonrpc":"2.0","method":"tools/call",...,"id":2}
Server: HTTP/1.1 200 OK
Content-Type: text/event-stream
Step 6: Server → Client (通过 Primary SSE)
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":10,"total":100}}
Step 7: Server → Client (通过 Tool-Specific SSE)
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"正在抓取..."}}
Step 8: Server → Client (通过 Primary SSE,Sampling 请求)
data: {"jsonrpc":"2.0","method":"sampling/createMessage",...,"id":100}
Step 9: Client → Server (POST,响应 Sampling)
POST /mcp
Mcp-Session-Id: abc123
{"jsonrpc":"2.0","result":{...},"id":100}
Step 10: Server → Client (通过 Tool-Specific SSE,最终结果)
data: {"jsonrpc":"2.0","result":{...},"id":2}
(Tool-Specific SSE 连接关闭)
从这个流程可以看到:
- Primary SSE 始终保持打开
- 每个工具调用创建一个新的 Tool-Specific SSE 连接
- Progress 通知通过 Primary SSE,Logging 通过 Tool-Specific SSE
- Sampling 请求通过 Primary SSE,响应通过 POST
Session ID:连接状态的关键
所有 HTTP 请求都携带 Mcp-Session-Id 头。这个 ID 用于:
关联多个连接。服务器可能同时处理数千个客户端,每个客户端有 1-2 个 SSE 连接。Session ID 让服务器知道哪些连接属于同一个客户端。
状态管理。服务器需要跟踪每个客户端的状态(已初始化?支持哪些能力?)。
安全隔离。不同客户端的消息不能混淆。
Session ID 的生成通常是:
py
import uuid
session_id = str(uuid.uuid4())
或者使用 JWT(包含加密签名和过期时间)。
协议版本协商
每个 HTTP 请求还携带 MCP-Protocol-Version 头:
yaml
MCP-Protocol-Version: 2024-11-05
这让服务器知道客户端支持哪个版本的协议。如果服务器不支持这个版本,应该返回 400 Bad Request。
stateless_http 和 json_response:功能与扩展性的权衡
横向扩展的难题
假设你的 MCP 服务器很成功,用户量暴涨。单个服务器实例无法处理所有请求,你需要横向扩展:
arduino
┌─── Server A
Client ─── LB ──────┼─── Server B
└─── Server C
但 MCP 的双连接架构有个问题:
同一个客户端的两个连接可能被路由到不同的服务器。
arduino
Client 的 Primary SSE ──→ LB ──→ Server A
Client 的 POST 请求 ──→ LB ──→ Server B
现在问题来了:
场景 1:工具需要 Sampling
arduino
1. Client POST 到 Server B:调用 research 工具
2. Server B 需要调用 Claude(Sampling)
3. Server B 尝试通过 Primary SSE 发送 Sampling 请求
4. 但 Primary SSE 连接在 Server A!
5. Server B 无法向 Client 发送请求
场景 2:Progress 通知
arduino
1. Client POST 到 Server C:调用长时间运行的工具
2. Server C 调用 ctx.report_progress()
3. Server C 尝试通过 Primary SSE 发送通知
4. 但 Primary SSE 连接在 Server A!
5. Client 收不到进度更新
解决方案 1:Sticky Sessions
一个常见的解决方案是粘性会话:负载均衡器根据 Session ID 将同一客户端的所有请求路由到同一服务器。
arduino
Client (Session abc123) 的所有请求 ──→ LB ──→ Server A
Client (Session def456) 的所有请求 ──→ LB ──→ Server B
这解决了连接分散的问题,但引入了新问题:
负载不均衡。如果 Server A 的客户端都是重度用户,Server A 会过载。
Session 亲和性的复杂性。需要配置负载均衡器支持粘性会话。
无法真正扩展。如果 Server A 崩溃,它的所有客户端会话都丢失。
解决方案 2:stateless_http=True
MCP 提供了一个激进的解决方案:完全放弃状态管理和双向通信。
ini
mcp.run(transport="http", stateless_http=True)
启用后,行为变化:
不再生成 Session ID 。服务器在 Initialize Result 中不返回 Mcp-Session-Id 头。客户端后续请求也不需要(也不应该)携带 Session ID。
不支持 Primary SSE 连接 。客户端尝试 GET /mcp 会返回 405 Method Not Allowed。这意味着服务器无法主动向客户端发送任何消息。
所有服务器 → 客户端的请求失效:
ctx.session.create_message()会立即抛出异常(无法 Sampling,因为无法向客户端发送请求)ctx.session.list_roots()会失败(无法请求客户端提供 Roots 列表)- 服务器无法发送任何需要客户端响应的请求
所有通知被悄悄丢弃:
ctx.report_progress(50, 100)不会报错,但消息会被丢弃,客户端永远收不到ctx.info("日志消息")同样被丢弃(除非启用 json_response=False,可以通过 Tool-Specific SSE 发送)- 服务器发送的 Resource Updated、Tool List Changed 等通知全部失效
但获得了完全的无状态性:
每个 POST 请求都是独立的、原子的事务:
bash
# 每个请求都是这样的流程:
# 1. 客户端 POST 请求
# 2. 服务器处理(不依赖任何会话状态)
# 3. 服务器返回结果
# 4. 连接关闭,服务器释放所有资源
请求可以被路由到任何服务器实例,任何实例都能处理任何请求,因为没有状态需要共享。
还有一个意外的好处:跳过初始化握手。
在无状态模式下,客户端可以直接发送工具调用请求,不需要先进行三步握手:
json
// 可以直接发送,无需初始化
POST /mcp
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "add",
"arguments": {"a": 1, "b": 2}
},
"id": 1
}
服务器会假设所有请求都来自匿名客户端,直接处理并返回结果。
json_response:禁用流式响应
json_response=True 是一个更温和的限制,只影响 POST 请求的响应格式。
正常模式(json_response=False):
yaml
Client: POST /mcp
{"jsonrpc":"2.0","method":"tools/call",...}
Server: HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"jsonrpc":"2.0","method":"notifications/message",...}
data: {"jsonrpc":"2.0","result":{...}}
JSON 模式(json_response=True):
yaml
Client: POST /mcp
{"jsonrpc":"2.0","method":"tools/call",...}
Server: HTTP/1.1 200 OK
Content-Type: application/json
{"jsonrpc":"2.0","result":{...}}
失去的功能:
- 工具执行期间的日志消息(
ctx.info())不会发送 - 客户端只能等待最终结果
保留的功能:
- Progress 通知仍然可以工作(如果没有启用 stateless_http,仍通过 Primary SSE 发送)
- Sampling 仍然可以工作
- 最终结果正常返回
功能损失对比表
| 功能 | 默认配置 | json_response=True | stateless_http=True |
|---|---|---|---|
| 基础工具调用 | ✅ | ✅ | ✅ |
| Resources & Prompts | ✅ | ✅ | ✅ |
| Session ID | ✅ | ✅ | ❌ |
| 初始化握手 | 必须 | 必须 | 可选 |
| Sampling | ✅ | ✅ | ❌ |
| Progress 通知 | ✅ | ✅ | ❌ |
| Logging(工具内) | ✅ | ❌ | ❌ |
| 服务器 → 客户端请求 | ✅ | ✅ | ❌ |
| 横向扩展 | ❌ 需要 Sticky | ❌ 需要 Sticky | ✅ |