本地 stdio 跑得顺,一挂到公网就三件事同时出问题:连接被代理掐断、会话找不到、鉴权没地方放。原因不玄——stdio 的前提是"一个宿主 spawn 一个子进程,生命周期由父进程管",换成 HTTP 之后这层前提全部消失,会话、心跳、鉴权、超时都得自己补。
这篇按上线顺序拆:选传输 → 开会话 → 定鉴权 → 过代理 → 容器化 → 接观测。协议基线 2024-11-05,HTTP 传输按 2025-03-26 修订版(Streamable HTTP)描述。
1. stdio 的边界:五条假设同时失效
| 维度 | stdio | Streamable HTTP |
|---|---|---|
| 生命周期 | 宿主 spawn,宿主退出即死 | 服务自己活着,会话随时可断 |
| 并发 | 一宿主一进程,天然隔离 | 多客户端共享同一进程 |
| 鉴权 | 继承 OS 用户权限 | 必须自己实现,没有隐含身份 |
| 部署 | 与宿主同机 | 独立容器、独立域名、独立扩缩容 |
| 观测 | 日志混在宿主 stderr 里 | 可接统一日志与指标 |
一旦"进程由我 spawn"变成"客户端随时连进来",最直接的两个后果是:不知道对面是谁(鉴权),不知道上一次说到哪(会话)。下面两节专治这两件事。
2. 会话:Mcp-Session-Id 是服务端自造的
会话不靠 cookie,靠显式响应头:客户端发 initialize,服务端在响应头回 Mcp-Session-Id,此后每个请求都要带回;会话失效回 404,客户端重新握手。
# 1. 握手,从响应头捞 session id
curl -si -X POST https://mcp.example.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"0.1"}}}'
# 响应头里会有: mcp-session-id: 3f9a-...
# 2. 声明已初始化(通知,无 id、无响应)
curl -s -X POST https://mcp.example.com/mcp \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-H 'mcp-session-id: 3f9a-...' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. 调用
curl -s -X POST https://mcp.example.com/mcp \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-H 'mcp-session-id: 3f9a-...' -H "authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"disk_usage","arguments":{"path":"/"}}}'
三个必知细节:
| 细节 | 后果 |
|---|---|
Accept 必须同时含 application/json 与 text/event-stream |
只写前者,部分实现直接 406 |
notifications/* 没有 id,服务端不回 body |
回 200 空响应才正确,回 JSON 反而可能被客户端判为非法 |
会话失效返回 404,不是 400/401 |
客户端据此决定重握手;返回码错了会陷入死循环 |
3. 鉴权:三层,别混着做
| 层 | 做法 | 适合 | 代价 |
|---|---|---|---|
| 网关层 | API Key、mTLS、IP 白名单 | 内部服务间、单团队 | 到不了 per-user 身份 |
| Bearer Token | 自签 JWT,server 内校验 scope |
单租户、少量客户端 | 轮换与吊销要自己搭 |
| OAuth 2.1 | 授权服务器 + Protected Resource Metadata | 多租户、第三方客户端 | 链路长,要处理 resource 指示器 |
2025-03-26 起,规范要求 HTTP 传输按 OAuth 2.1 做,并引入 RFC 9728 的受保护资源元数据,让客户端能自动发现授权服务器;2024-11-05 版没有这套规定,当时大家各写各的 header。落地时最常见的组合是"网关 Bearer + server 内校验 scope":网关挡住未认证流量,server 用 token 里的 sub 决定能调哪些工具。
# FastMCP 侧取请求上下文里的身份,做 per-tool 授权
from mcp.server.fastmcp import Context
@mcp.tool()
async def restart_service(name: str, ctx: Context) -> str:
"""重启后端服务。需要 ops:write scope。"""
token = ctx.request_context.request.headers.get("authorization", "")
if "ops:write" not in decode_scopes(token):
return "拒绝:当前 token 缺少 ops:write scope"
...
返回文本错误而不是抛异常:工具级拒绝属于业务结果,抛异常会变成 JSON-RPC 错误,宿主措辞会难懂得多。
4. 反向代理:SSE 被掐断的四种方式
| 参数 | 默认值 | 长连接需要 | 症状 |
|---|---|---|---|
proxy_read_timeout |
60s | 300s 以上,或服务端定期发心跳 | 每分钟断一次,客户端静默重连,工具结果丢失 |
proxy_buffering |
on | off | 事件被攒着,流式变批量,前端"卡一下全出来" |
proxy_http_version |
1.0 | 1.1 | 分块传输失效,SSE 根本推不出来 |
gzip |
on | 对 text/event-stream 关闭 |
压缩缓冲引入额外延迟 |
location /mcp {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
gzip off;
chunked_transfer_encoding on;
}
再叠一层 Cloudflare 还有一条:边缘对空闲请求有硬上限,靠心跳保活比靠调大超时稳。心跳间隔给 15–25 秒,短于任何一层代理的默认超时。
5. 无状态化与多实例
| 方案 | 会话存哪 | 能否随意扩容 | 代价 |
|---|---|---|---|
| sticky session | 实例内存 | 不能,LB 要按 session 粘 | 实例重启,会话全丢 |
| 共享存储 | Redis | 可以 | 每请求多一次 RTT |
| 真无状态 | 不存 | 可以 | 只能做纯工具,多步交互靠客户端带上下文 |
判断标准只有一条:工具之间有没有"上一步的结果"这种隐含依赖。全是幂等查询就无状态;有"先建任务再轮询"的链路,任务状态放 Redis,session id 只当索引。
# 无状态服务的正确姿势:状态全在客户端或外部存储
@mcp.tool()
def get_job(job_id: str) -> str:
"""按 job_id 查询任务进度。job_id 由 submit_job 返回,服务端不保存会话状态。"""
return redis.hget(f"job:{job_id}", "status") or "unknown"
sticky session 把"实例可随时替换"这个假设废掉了,省下的 Redis 调用不值这个价。
6. 容器化:五个要点
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv && uv sync --frozen --no-dev
COPY app ./app
RUN useradd -m -u 10001 mcp && chown -R mcp /app
USER mcp
ENV PYTHONUNBUFFERED=1 MCP_TRANSPORT=streamable-http MCP_PORT=8000
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/healthz')"
CMD ["uv","run","python","-m","app.server"]
| 要点 | 不做的后果 |
|---|---|
PYTHONUNBUFFERED=1 |
日志卡在缓冲区,出事看不到最后几行 |
单独开 /healthz,不要拿 /mcp 当健康检查 |
/mcp 只接受 POST + 特定 Accept,探测永远失败 → 容器被反复重启 |
| 非 root 用户运行 | 容器逃逸后直接拿宿主权限 |
--no-dev + 锁文件 |
镜像里混进测试依赖,启动慢一半 |
不要把 .env 打进镜像 |
凭证进镜像层,等于永久泄露 |
启动时端口只绑回环(-p 127.0.0.1:8000:8000),交给前置代理暴露,别直接 -p 8000:8000 听全地址。
7. 可观测性:每个工具一行日志
宿主的报错永远只有一句"工具失败",所以日志至少要能回答"谁、调了哪个、多久、成没成":
| 字段 | 用途 |
|---|---|
session_id |
把一次会话里的所有调用串起来 |
tool |
聚合成功率、按工具算 P95 |
ms |
定位是慢工具还是慢模型 |
ok / err_type |
区分业务失败、超时、参数错误 |
client |
区分是桌面端还是自动化在打 |
import functools, time, logging
log = logging.getLogger("mcp.tools")
def observed(fn):
@functools.wraps(fn)
def inner(*args, **kwargs):
t0 = time.perf_counter()
try:
out = fn(*args, **kwargs)
log.info("tool=%s ok=true ms=%.1f", fn.__name__, (time.perf_counter()-t0)*1000)
return out
except Exception as e:
log.warning("tool=%s ok=false err=%s ms=%.1f",
fn.__name__, type(e).__name__, (time.perf_counter()-t0)*1000)
raise
return inner
@mcp.tool()
@observed
def disk_usage(path: str = "/") -> str:
"""返回指定挂载点的磁盘使用率。"""
...
顺序不能反:@mcp.tool() 在最外层,否则注册进去的是未包装的原函数。
8. 上线检查清单
| 项 | 检查方式 | 通过标准 |
|---|---|---|
| 传输 | 用 curl 走完 initialize → initialized → tools/call | 三段都有正确响应 |
| 会话 | 人为让会话过期,看客户端行为 | 收 404 后重新握手,不报死循环 |
| 鉴权 | 去掉 token 直连 | 401,且不泄露内部错误栈 |
| 代理 | curl -N 观察 5 分钟 |
无 60 秒周期性断流 |
| 并发 | 20 客户端同时 initialize | 无串话,会话各自独立 |
| 心跳 | 抓包看事件间隔 | 小于 25s |
| 健康检查 | 停掉依赖再探活 | /healthz 反映依赖而不是恒 200 |
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 客户端每分钟重连一次 | proxy_read_timeout 默认 60s |
调大 + 服务端心跳 |
| 工具结果"丢了" | 断流后重连,stateful 会话已失效 | 工具幂等 + 返回可重放 id |
406 |
Accept 缺 text/event-stream |
客户端补全两个类型 |
| 多实例下随机 404 | 会话在 A 实例,请求打到 B | 上 Redis 或改无状态 |
| 响应"憋一下全出来" | proxy_buffering on |
该 location 关闭 |
10. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 单人本机工具 | 留 stdio | HTTP 只增加攻击面和运维成本 |
| 团队内共享只读数据源 | 网关 Bearer + 无状态 HTTP | 简单、可水平扩容 |
| 第三方客户端接入 | OAuth 2.1 + 资源元数据 | 只有标准授权流能让外部方自助接入 |
| 单次超过 60 秒的任务 | 异步任务 + 轮询工具 | 任何反代的超时都不该被硬扛 |
11. 自测
Mcp-Session-Id由谁生成、放在哪里、失效时服务端应返回什么状态码?- Streamable HTTP 的
Accept头为什么要求两个媒体类型? - 反向代理上导致"流式变批量"的是哪个参数?
- sticky session 与共享存储,各自牺牲了什么?
- 健康检查为什么不能直接打
/mcp? - 什么情况下你应当主动放弃 HTTP 传输、回到 stdio?