MCP 协议刚升级了传输层,我用 Python 跑通了 Streamable HTTP 全流程(附完整代码 + 3 个坑)
如果你在用 MCP 给 AI Agent 接工具,大概率还在用 SSE(Server-Sent Events)做传输。坏消息是:MCP 官方已经在 2025-03-26 版本的规范里把 SSE 标记为 deprecated,推荐的新方案叫 Streamable HTTP。
好消息是:迁移并不复杂,核心改动就是把原来的两个端点合并成一个 /mcp,客户端和服务端的代码量反而更少了。
这篇文章我会从零搭一个 Streamable HTTP 的 MCP Server + Client,用 Python 跑通完整流程,包括工具注册、工具调用、流式响应。最后记录 3 个我实际踩过的坑。
先搞清楚:Streamable HTTP 和 SSE 到底有什么区别
一句话总结:SSE 需要两个端点(一个发请求、一个收推送),Streamable HTTP 只需要一个 /mcp 端点搞定所有事情。
| 对比项 | SSE(旧方案) | Streamable HTTP(新方案) |
|---|---|---|
| 端点数量 | 2 个(POST + SSE 长连接) | 1 个(/mcp) |
| 连接方式 | 必须维持长连接 | 按需连接,支持无状态 |
| 部署友好度 | 差(长连接对 Serverless 不友好) | 好(标准 HTTP,能跑在 Lambda 上) |
| 流式响应 | 通过 SSE 推送 | Content-Type 协商,支持 SSE 或直接 JSON |
| 会话管理 | 隐式(靠连接) | 显式(Mcp-Session-Id Header) |
| 规范状态 | deprecated | 推荐 |
关键区别在于连接模型。SSE 要求客户端先建一个长连接来接收服务端推送,这在 Serverless 环境(AWS Lambda、Cloudflare Workers)里基本没法用。Streamable HTTP 改成了标准的 HTTP 请求-响应模式:客户端 POST 一个 JSON-RPC 请求到 /mcp,服务端可以直接返回 JSON(快速响应),也可以返回 SSE 流(长任务)。
服务端通过响应的 Content-Type 来告诉客户端用哪种模式:
application/json:一次性返回结果text/event-stream:流式推送中间进度 + 最终结果
这个设计很聪明——简单的工具调用直接返回 JSON,耗时的任务用流式推送进度,同一个端点两种模式自动切换。
搭一个最小的 Streamable HTTP MCP Server
我们用 Python 官方 SDK mcp 来搭。先装依赖:
pip install "mcp[cli]>=1.12.0" httpx uvicorn
注意版本号,1.12.0 之前的 SDK 不支持 Streamable HTTP。
下面是一个完整的天气查询 MCP Server,暴露两个工具:get_weather 和 get_forecast。
# server.py
from mcp.server.fastmcp import FastMCP
import json
mcp = FastMCP(
"WeatherServer",
host="0.0.0.0",
port=8123,
)
# 模拟天气数据
WEATHER_DATA = {
"beijing": {"temp": 22, "condition": "晴", "humidity": 35},
"shanghai": {"temp": 26, "condition": "多云", "humidity": 72},
"guangzhou": {"temp": 30, "condition": "雷阵雨", "humidity": 88},
"shenzhen": {"temp": 29, "condition": "阴", "humidity": 80},
}
FORECAST_DATA = {
"beijing": [
{"date": "明天", "temp_high": 24, "temp_low": 14, "condition": "晴转多云"},
{"date": "后天", "temp_high": 20, "temp_low": 12, "condition": "小雨"},
{"date": "大后天", "temp_high": 18, "temp_low": 10, "condition": "多云"},
],
"shanghai": [
{"date": "明天", "temp_high": 28, "temp_low": 20, "condition": "多云"},
{"date": "后天", "temp_high": 25, "temp_low": 18, "condition": "中雨"},
{"date": "大后天", "temp_high": 23, "temp_low": 17, "condition": "阴"},
],
}
@mcp.tool()
def get_weather(city: str) -> str:
"""Get current weather for a city. City name should be in pinyin, e.g. beijing, shanghai."""
city_lower = city.lower().strip()
data = WEATHER_DATA.get(city_lower)
if not data:
return json.dumps({"error": f"No weather data for {city}"}, ensure_ascii=False)
return json.dumps({
"city": city,
"temperature": f"{data['temp']}°C",
"condition": data["condition"],
"humidity": f"{data['humidity']}%",
}, ensure_ascii=False)
@mcp.tool()
def get_forecast(city: str, days: int = 3) -> str:
"""Get weather forecast for a city. Returns forecast for the next N days (max 3)."""
city_lower = city.lower().strip()
forecasts = FORECAST_DATA.get(city_lower)
if not forecasts:
return json.dumps({"error": f"No forecast data for {city}"}, ensure_ascii=False)
result = forecasts[:min(days, len(forecasts))]
return json.dumps({"city": city, "forecast": result}, ensure_ascii=False)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
这段代码做了几件事:
FastMCP是 Python SDK 提供的高层封装,用装饰器@mcp.tool()注册工具,SDK 自动从函数签名和 docstring 生成 JSON Schema。transport="streamable-http"是关键参数,告诉 SDK 用 Streamable HTTP 而不是默认的 stdio。- 服务启动后会在
http://0.0.0.0:8123/mcp暴露端点。
跑起来:
python server.py
看到 Uvicorn running on http://0.0.0.0:8123 就说明启动成功了。
写一个 Streamable HTTP MCP Client
Server 跑起来了,接下来写 Client。Client 的职责是:连接 Server、发现可用工具、把工具信息传给 LLM、执行 LLM 选择的工具、把结果返回给 LLM。
# client.py
import asyncio
import json
import os
from dotenv import load_dotenv
from anthropic import Anthropic
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
load_dotenv()
SERVER_URL = "http://localhost:8123/mcp"
async def run_agent():
"""Main agent loop: connect to MCP server, chat with Claude, call tools."""
anthropic = Anthropic()
# 1. 连接 MCP Server
async with streamablehttp_client(SERVER_URL) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# 2. 发现可用工具
tools_result = await session.list_tools()
print(f"Connected! Available tools: {[t.name for t in tools_result.tools]}")
# 3. 把 MCP 工具转成 Anthropic API 格式
tools_for_llm = []
for tool in tools_result.tools:
tools_for_llm.append({
"name": tool.name,
"description": tool.description,
"input_schema": tool.inputSchema,
})
# 4. 对话循环
messages = []
print("\nWeather Agent Ready! Type 'quit' to exit.\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("quit", "exit", "q"):
break
messages.append({"role": "user", "content": user_input})
# 5. 调用 Claude,带上工具定义
response = anthropic.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
system="You are a helpful weather assistant. Use the available tools to answer weather questions. Reply in Chinese.",
tools=tools_for_llm,
messages=messages,
)
# 6. 处理工具调用循环
while response.stop_reason == "tool_use":
tool_calls = [b for b in response.content if b.type == "tool_use"]
tool_results = []
for tc in tool_calls:
print(f" [Calling tool: {tc.name}({tc.input})]")
result = await session.call_tool(tc.name, arguments=tc.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": tc.id,
"content": result.content[0].text if result.content else "",
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
response = anthropic.messages.create(
model="claude-sonnet-4-5-20250514",
max_tokens=1024,
system="You are a helpful weather assistant. Use the available tools to answer weather questions. Reply in Chinese.",
tools=tools_for_llm,
messages=messages,
)
# 7. 输出最终回复
final_text = "".join(
b.text for b in response.content if hasattr(b, "text")
)
print(f"Agent: {final_text}\n")
messages.append({"role": "assistant", "content": response.content})
if __name__ == "__main__":
asyncio.run(run_agent())
这段代码的核心流程:
streamablehttp_client(SERVER_URL)建立 Streamable HTTP 连接,返回读写流。session.list_tools()自动发现 Server 暴露的所有工具。- 把工具定义转成 Anthropic API 的格式,传给 Claude。
- Claude 决定调用哪个工具 → Client 通过
session.call_tool()调用 MCP Server → 把结果返回给 Claude → Claude 生成最终回复。
跑之前先配好 .env:
ANTHROPIC_API_KEY=your_key_here
然后开两个终端:
# 终端 1
python server.py
# 终端 2
python client.py
效果:
Connected! Available tools: ['get_weather', 'get_forecast']
Weather Agent Ready! Type 'quit' to exit.
You: 北京今天天气怎么样?
[Calling tool: get_weather({"city": "beijing"})]
Agent: 北京今天天气晴朗,气温22°C,湿度35%,非常适合户外活动!
You: 上海未来两天呢?
[Calling tool: get_forecast({"city": "shanghai", "days": 2})]
Agent: 上海未来两天的天气预报:明天多云,最高28°C/最低20°C;后天中雨,最高25°C/最低18°C。建议后天出门带伞。
进阶:给 Server 加上会话管理和进度推送
上面的基础版本够用了,但生产环境还需要两个东西:会话管理(让 Server 知道请求来自同一个客户端)和进度推送(长任务时告诉客户端"我还在干活")。
# server_advanced.py
from mcp.server.fastmcp import FastMCP, Context
import asyncio
import json
mcp = FastMCP(
"AdvancedWeatherServer",
host="0.0.0.0",
port=8123,
stateful=True, # 启用会话管理
)
CITIES = ["beijing", "shanghai", "guangzhou", "shenzhen", "chengdu",
"hangzhou", "wuhan", "nanjing", "xian", "chongqing"]
WEATHER_DB = {
"beijing": {"temp": 22, "condition": "晴", "aqi": 85},
"shanghai": {"temp": 26, "condition": "多云", "aqi": 62},
"guangzhou": {"temp": 30, "condition": "雷阵雨", "aqi": 45},
"shenzhen": {"temp": 29, "condition": "阴", "aqi": 50},
"chengdu": {"temp": 20, "condition": "阴", "aqi": 110},
"hangzhou": {"temp": 25, "condition": "晴", "aqi": 55},
"wuhan": {"temp": 27, "condition": "多云", "aqi": 78},
"nanjing": {"temp": 24, "condition": "小雨", "aqi": 68},
"xian": {"temp": 21, "condition": "晴", "aqi": 95},
"chongqing": {"temp": 28, "condition": "多云", "aqi": 88},
}
@mcp.tool()
async def batch_weather_report(ctx: Context) -> str:
"""Generate a weather report for all major cities. This is a long-running task with progress updates."""
results = []
total = len(CITIES)
for i, city in enumerate(CITIES):
# 推送进度通知
await ctx.report_progress(
progress=i,
total=total,
message=f"Fetching weather for {city}..."
)
# 模拟网络延迟
await asyncio.sleep(0.3)
data = WEATHER_DB.get(city, {})
results.append({
"city": city,
"temp": f"{data.get('temp', 'N/A')}°C",
"condition": data.get("condition", "unknown"),
"aqi": data.get("aqi", "N/A"),
})
await ctx.report_progress(progress=total, total=total, message="Done!")
return json.dumps({"report": results, "total_cities": total}, ensure_ascii=False)
@mcp.tool()
def get_air_quality(city: str) -> str:
"""Get air quality index for a city."""
data = WEATHER_DB.get(city.lower().strip())
if not data:
return json.dumps({"error": f"No data for {city}"})
aqi = data["aqi"]
level = "优" if aqi <= 50 else "良" if aqi <= 100 else "轻度污染"
return json.dumps({
"city": city, "aqi": aqi, "level": level
}, ensure_ascii=False)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
关键改动:
stateful=True启用会话管理。Server 会在响应头里返回Mcp-Session-Id,Client 后续请求带上这个 ID,Server 就能识别是同一个会话。ctx.report_progress()在长任务执行过程中推送进度。这时候 Server 会用 SSE 流式返回,Client 能实时看到进度更新。batch_weather_report是一个耗时任务(模拟了 10 个城市的查询),每查完一个城市就推送一次进度。
这就是 Streamable HTTP 的精髓:简单调用直接返回 JSON,长任务自动切换到 SSE 流式推送,同一个端点两种模式无缝切换。
原理简述:Streamable HTTP 的请求流程
核心流程分 4 步:
- 初始化:Client POST 一个
initializeJSON-RPC 请求到/mcp。Server 返回协议版本、能力声明,以及一个Mcp-Session-IdHeader。 - 工具发现:Client POST
tools/list请求(带上 Session ID),Server 返回所有注册的工具及其 JSON Schema。 - 工具调用:Client POST
tools/call请求,Server 根据任务复杂度选择返回方式——简单任务直接返回application/json,长任务返回text/event-stream流式推送。 - 会话结束:Client 发送 DELETE 请求到
/mcp,Server 清理会话资源。
和 SSE 方案最大的区别是:没有长连接。每次请求都是独立的 HTTP 调用,Server 可以是无状态的(不开 stateful 的话),这意味着你可以把 MCP Server 部署到 AWS Lambda、Cloudflare Workers 这类 Serverless 平台上。
另一个重要的设计是 Content-Type 协商。Client 在请求头里带 Accept: application/json, text/event-stream,Server 根据实际情况选择用哪种格式响应。这比 SSE 方案灵活得多——SSE 方案里所有响应都走长连接推送,即使是一个 10ms 就能返回的简单查询。
踩坑记录
坑 1:SDK 版本不对,streamablehttp_client 导入报错
第一次跑的时候报了这个错:
ImportError: cannot import name 'streamablehttp_client' from 'mcp.client'
原因是我装的 mcp 版本是 1.8.0,太旧了。Streamable HTTP 的客户端支持是在 1.12.0 才加进去的。
# 检查版本
pip show mcp
# 升级到最新
pip install --upgrade "mcp[cli]>=1.12.0"
升级后就好了。这个坑很隐蔽,因为 mcp 包本身能装上,只是缺少 Streamable HTTP 相关的模块。
坑 2:Server 启动后 Client 连不上,报 404
Server 用 python server.py 启动了,Client 连接 http://localhost:8123 报 404。
原因是 Streamable HTTP 的端点路径是 /mcp,不是根路径。Client 必须连接 http://localhost:8123/mcp。
# ❌ 错误
SERVER_URL = "http://localhost:8123"
# ✅ 正确
SERVER_URL = "http://localhost:8123/mcp"
这个在 SSE 方案里不是问题(SSE 有单独的端点路径配置),但 Streamable HTTP 固定用 /mcp。如果你用 FastMCP 的话,这个路径是写死的,改不了。如果用底层 API 自己搭 Server,可以自定义路径,但没必要——/mcp 已经是约定俗成的标准了。
坑 3:stateful=True 时跨进程请求会 403
我在测试会话管理时,先用一个 Client 连接拿到了 Session ID,然后想在另一个脚本里复用这个 ID 继续请求。结果 Server 返回了 403。
原因是 stateful=True 模式下,Server 会把 Session ID 和具体的传输实例绑定。你不能把一个会话的 ID 拿到另一个连接里用——Server 会认为这是非法请求。
# ❌ 不能跨连接复用 Session ID
# 连接 A 拿到 session_id = "abc123"
# 连接 B 带上 Mcp-Session-Id: abc123 → 403 Forbidden
解决方案:每个 Client 连接维护自己的会话。如果你需要无状态部署(比如 Serverless),就不要开 stateful=True,让每次请求都是独立的。
还有一个相关的坑:如果 Server 重启了,之前的 Session ID 全部失效。Client 需要有重连逻辑——检测到 403 或连接断开时,重新走一遍 initialize 流程。
坑 4(额外赠送):report_progress 在非 async 工具里不能用
如果你的工具函数不是 async def,调用 ctx.report_progress() 会报错:
RuntimeError: cannot call async method from sync context
进度推送本质上是往 SSE 流里写数据,这是一个异步操作。所以需要进度推送的工具必须定义为 async 函数:
# ❌ 同步函数不能用 report_progress
@mcp.tool()
def slow_task(ctx: Context) -> str:
ctx.report_progress(...) # RuntimeError!
# ✅ 异步函数才行
@mcp.tool()
async def slow_task(ctx: Context) -> str:
await ctx.report_progress(...) # OK
和其他传输方案的选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 本地 IDE 插件(Cursor、Claude Desktop) | stdio | 最简单,进程间通信,零配置 |
| 团队内部共享 MCP Server | Streamable HTTP | 一个 Server 多人用,HTTP 天然支持 |
| 部署到 Serverless(Lambda、Workers) | Streamable HTTP(无状态模式) | 不需要长连接,按请求计费 |
| 需要实时推送大量数据 | Streamable HTTP(有状态模式) | 支持 SSE 流式推送 + 进度通知 |
| 旧项目还在用 SSE | 尽快迁移 | SSE 已 deprecated,SDK 未来版本可能移除 |
总结
MCP 的 Streamable HTTP 传输层是一个务实的升级:一个端点搞定所有通信,支持无状态部署,长任务自动切流式推送。对于正在用 MCP 的项目,迁移成本很低——Server 端改一个参数,Client 端换一个连接方式。
如果你还没用过 MCP,现在是个好时机。Streamable HTTP 让 MCP Server 的部署方式和普通 HTTP API 一样灵活,不再受限于 stdio 的本地模式或 SSE 的长连接要求。
完整代码我放在文章里了,复制就能跑。有问题欢迎评论区交流。
你在项目中用 MCP 接过什么工具?评论区聊聊。