MCP 协议刚升级了传输层,我用 Python 跑通了 Streamable HTTP 全流程

10 阅读11分钟

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

这段代码做了几件事:

  1. FastMCP 是 Python SDK 提供的高层封装,用装饰器 @mcp.tool() 注册工具,SDK 自动从函数签名和 docstring 生成 JSON Schema。
  2. transport="streamable-http" 是关键参数,告诉 SDK 用 Streamable HTTP 而不是默认的 stdio。
  3. 服务启动后会在 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())

这段代码的核心流程:

  1. streamablehttp_client(SERVER_URL) 建立 Streamable HTTP 连接,返回读写流。
  2. session.list_tools() 自动发现 Server 暴露的所有工具。
  3. 把工具定义转成 Anthropic API 的格式,传给 Claude。
  4. 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")

关键改动:

  1. stateful=True 启用会话管理。Server 会在响应头里返回 Mcp-Session-Id,Client 后续请求带上这个 ID,Server 就能识别是同一个会话。
  2. ctx.report_progress() 在长任务执行过程中推送进度。这时候 Server 会用 SSE 流式返回,Client 能实时看到进度更新。
  3. batch_weather_report 是一个耗时任务(模拟了 10 个城市的查询),每查完一个城市就推送一次进度。

这就是 Streamable HTTP 的精髓:简单调用直接返回 JSON,长任务自动切换到 SSE 流式推送,同一个端点两种模式无缝切换。

原理简述:Streamable HTTP 的请求流程

核心流程分 4 步:

  1. 初始化:Client POST 一个 initialize JSON-RPC 请求到 /mcp。Server 返回协议版本、能力声明,以及一个 Mcp-Session-Id Header。
  2. 工具发现:Client POST tools/list 请求(带上 Session ID),Server 返回所有注册的工具及其 JSON Schema。
  3. 工具调用:Client POST tools/call 请求,Server 根据任务复杂度选择返回方式——简单任务直接返回 application/json,长任务返回 text/event-stream 流式推送。
  4. 会话结束: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 ServerStreamable 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 接过什么工具?评论区聊聊。