2512 字
约 8 分钟
18
S21-X04 · MCP 服务从本地走向线上:传输、鉴权与部署

本地 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. 自测

  1. Mcp-Session-Id 由谁生成、放在哪里、失效时服务端应返回什么状态码?
  2. Streamable HTTP 的 Accept 头为什么要求两个媒体类型?
  3. 反向代理上导致"流式变批量"的是哪个参数?
  4. sticky session 与共享存储,各自牺牲了什么?
  5. 健康检查为什么不能直接打 /mcp?
  6. 什么情况下你应当主动放弃 HTTP 传输、回到 stdio?
S21-X04 · MCP 服务从本地走向线上:传输、鉴权与部署
https://www.clxhxhhr.top/posts/5687/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。