<!-- title: MCP服务器上云：Streamable HTTP远程部署实战 -->

## 概述

本教程聚焦一个尚未被广泛讲透的 MCP 维度：**把一个本地 stdio MCP 服务器改造成可远程访问的 Streamable HTTP 服务**，并部署到云端、供团队乃至移动端共用。学完你将掌握：stdio 与 Streamable HTTP 的本质差异、单一 `/mcp` 端点的请求/响应契约、有状态与无状态两种会话模式、Cloudflare Workers 与 Docker 两种部署路径，以及如何在 Claude Code 中用一行 `claude mcp add-json` 挂载远程服务器并加上认证。适用于已经能写出本地 MCP 工具、想把它升级为「团队共享服务」或「多端可用能力」的开发者。

## 前置条件

- 已写过至少一个能跑通的 stdio MCP 服务器（不限语言，本文示例用 Python 与 TypeScript 两种）
- Node.js ≥ 20 或 Python ≥ 3.10，对应安装 `@modelcontextprotocol/sdk` 或 `mcp`（FastMCP）
- 已安装 Claude Code ≥ 2.1.1（用于远程 `add-json` 命令格式）
- 一个可访问的部署目标：本地 Docker、或 Cloudflare 账号、或任意能跑 Node/Python 的 VPS

---

## 第一步：先看清 stdio 的天花板

绝大多数入门教程的第一台 MCP 服务器都是 stdio 形态：`claude mcp add xxx -- npx my-server`，宿主 spawn 一个子进程，双方用 stdin/stdout 收发换行分隔的 JSON-RPC 消息。这对「本机开发工具」足够，但一旦你要把它变成一个产品级能力，三堵墙立刻出现：

| 维度 | stdio（本地子进程） | Streamable HTTP（远程服务） |
|---|---|---|
| 客户端形态 | 仅限能 spawn 子进程的桌面/CLI | CLI、Web、移动端、CI 皆可 |
| 多用户共享 | 每人各自 `npx` 安装一份 | 一处部署，N 端连接 |
| 密钥归属 | 凭据散落在每台机器 | 集中托管在服务端 |
| 状态与扩展 | 单进程、难水平扩展 | 可无状态横向扩展、可加负载均衡 |
| 网络可达性 | 仅本机 | 任意网络位置，可做监控/审计 |

结论很直接：**当一个工具要被团队、被其他端、被线上 Agent 调用时，它必须从「进程」变成「服务」**。这正是 Streamable HTTP 传输的存在意义。

## 第二步：读懂 Streamable HTTP 的契约

MCP 在 2025-03-26 规范版本中引入 Streamable HTTP，取代了旧的「HTTP + SSE 双端点」设计。它的核心是**单一端点 + 内容协商**：

- **端点**：只有一个，通常约定为 `/mcp`。
- **请求**：客户端 `POST /mcp`，`Content-Type: application/json`，请求体是一条标准 JSON-RPC 2.0 消息；同时声明 `Accept: application/json, text/event-stream`，表示「我能收单条 JSON，也能收 SSE 流」。
- **响应**：服务器自己挑——
  - 若只回单条结果，用 `Content-Type: application/json` 直接返回；
  - 若需要流式（进度、分块、服务端主动通知），用 `Content-Type: text/event-stream`，按 SSE 帧推送，每帧 `event: message` + `data: {JSON-RPC}`。
- **会话**：初始化握手时，服务器可选择返回一个 `Mcp-Session-Id` 响应头；客户端在后续请求里把它原样带回。这就是「有状态」模式的会话句柄。
- **服务端→客户端流**：客户端可用 `GET /mcp`（带 `Accept: text/event-stream`）打开一条长连接，接收服务器主动推送的通知/请求。
- **优雅关闭**：`DELETE /mcp`（带会话头）即终止会话。

理解了这套契约，你就明白为什么它比旧方案优雅：旧的 HTTP+SSE 需要两个端点（一个 POST 收请求、一个 GET 开 SSE 流），还有复杂的会话重连问题；Streamable HTTP 用一个端点 + 协商，把「单次问答」和「长连接流」统一了。

## 第三步：准备一个可移植的 MCP 服务器

先写一个与传输无关的「纯逻辑」服务器，这样后面切换传输时业务代码一行都不用改。以 Python FastMCP 为例：

```python
# server.py —— 业务逻辑与传输解耦
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("snippet-vault")

@mcp.tool()
def add_snippet(title: str, language: str, code: str) -> str:
    """保存一段代码片段，返回它的 ID。"""
    # 这里省略持久化逻辑，返回伪 ID
    sid = f"{language}-{abs(hash(title)) % 100000}"
    return f"saved {sid}"

@mcp.tool()
def search_snippet(keyword: str) -> str:
    """按关键词检索代码片段。"""
    return f"hits for '{keyword}': (示例)"

if __name__ == "__main__":
    # 默认 stdio：本地开发期用
    mcp.run()
```

本地验证（stdio 阶段）：

```bash
# 在项目根目录
claude mcp add vault-local -- uv run server.py
claude mcp list        # 应能看到 vault-local ✔ Connected
```

**预期结果**：`claude mcp list` 显示该服务器 `Connected`；在会话里问「帮我存一段 hello world」，模型会调用 `add_snippet`。**常见问题**：若显示 `Failed to connect`，多半是依赖未安装或启动命令路径不对——用 `uv run` 或 `npx` 时要确保包确实能跑起来。

## 第四步：切换到 Streamable HTTP

FastMCP 让这一步几乎是「改一行」。把入口从 `mcp.run()` 换成显式指定传输：

```python
if __name__ == "__main__":
    mcp.run(
        transport="streamable-http",
        host="0.0.0.0",   # 上云必备：监听所有网卡
        port=8000,
    )
```

启动后，FastMCP 默认把端点挂在 `/mcp`。TypeScript SDK 的等价写法稍多一些显式装配，但表达力一致：

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

const app = express();
const server = new McpServer({ name: "snippet-vault", version: "1.0.0" });
// 注册 tool(...) 略

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await server.connect(transport);
  await transport.handleRequest(req, res);
});
app.listen(8000);
```

注意上面把 `sessionIdGenerator` 设为 `undefined`，意味着选择了**无状态模式**——每个请求自包含、不保留会话，最适合无状态横向扩展。

## 第五步：用 curl 验证端点契约

部署前先在本地把契约跑通，能省下大量云端调试时间。直接对着 `/mcp` 发一次 `initialize`：

```bash
curl -i -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{
      "protocolVersion":"2025-06-18",
      "capabilities":{},
      "clientInfo":{"name":"curl","version":"1.0"}
    }
  }'
```

**预期结果**：

- 状态码 `200`；
- `Content-Type: application/json`（单条响应）或 `text/event-stream`（流式）；
- 响应体里是 `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"...","serverInfo":{...},"capabilities":{"tools":{}}}}`；
- 若服务器选择了有状态模式，响应头里会出现 `Mcp-Session-Id: <随机串>`。

拿到会话 ID 后，发 `tools/call` 时要带上它：

```bash
curl -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Mcp-Session-Id: <上一步拿到的ID>" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"add_snippet",
                 "arguments":{"title":"hi","language":"python","code":"print(1)"}}}'
```

**常见问题**：若返回 `406 Not Acceptable`，说明 `Accept` 头没声明 `text/event-stream`，而服务器想用流式回包——补上即可；若返回 `400 session not found`，说明你漏带或带错了 `Mcp-Session-Id`。

## 第六步：有状态 vs 无状态——做对选择

这是远程部署最容易被忽略、却决定架构的一步。

- **有状态（stateful）**：服务器为每个 `Mcp-Session-Id` 维护上下文（订阅列表、游标、缓存）。优点是能支持 Resources 订阅、分页游标等需要连续性的能力；代价是**实例与会话绑定**，负载均衡要做粘性路由（sticky session），水平扩展受限。
- **无状态（stateless）**：每个请求独立处理，不持有会话。优点是可任意横向扩展、天然适配 Serverless（Cloudflare Workers、AWS Lambda）；代价是不能用任何依赖「会话连续性」的特性。

选型口诀：**工具类、查询类、无副作用的远程服务器 → 优先无状态**；需要长会话、订阅推送、多步游标 → 选有状态并配粘性负载均衡。本教程的「代码片段管家」属于前者，所以全程用无状态。

## 第七步：部署到云端（两条路径）

### 路径 A：Cloudflare Workers（最快上云，原生无状态）

Cloudflare 官方提供了 `agents` 模板，内置 Streamable HTTP 传输：

```bash
pnpm create cloudflare@latest snippet-vault \
  --template=agents
```

模板会生成一个继承 `McpAgent` 的入口：

```typescript
// src/index.ts
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export class Vault extends McpAgent<Env> {
  server = new McpServer({ name: "snippet-vault", version: "1.0.0" });
  async init() {
    this.server.tool("add_snippet", { title: z.string(), language: z.string(), code: z.string() },
      async ({ title, language, code }) => ({ content: [{ type: "text", text: `saved ${language}-${title}` }] }));
  }
}

export default {
  fetch: Vault.mount("/mcp", { corsOptions: { origin: "*" } }).fetch,
};
```

部署：

```bash
npx wrangler deploy
# 产物：https://snippet-vault.<your-subdomain>.workers.dev/mcp
```

Workers 天然无状态、全球边缘分发，是「纯工具类」远程 MCP 的首选。

### 路径 B：Docker + 反向代理（可控、可私有部署）

适合需要自带数据库、要部署在自有机房的场景。`Dockerfile`：

```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen
COPY server.py .
EXPOSE 8000
CMD ["uv","run","python","server.py"]
```

`docker-compose.yml` 用 Caddy 做 TLS 反代，把 `https://vault.internal/mcp` 指向容器：

```yaml
services:
  vault:
    build: .
  caddy:
    image: caddy:2
    ports: ["443:443"]
    command: caddy reverse-proxy --from vault.internal --to vault:8000
```

```bash
docker compose up -d
```

**常见问题**：`0.0.0.0` 监听一定要设——只听 `127.0.0.1` 的话，容器外完全不可达；反向代理必须保留 `Mcp-Session-Id` 透传，Caddy/Nginx 默认会透传未知头，但若你自定义了头过滤就要显式放行。

## 第八步：在 Claude Code 里挂载远程服务器

部署成功后，回到 Claude Code。Claude Code 2.1.1+ 用 `add-json` 注册 HTTP 传输（旧版用 `--transport http`）。官方 GitHub MCP Server 安装指南给出的正是这种格式：

```bash
claude mcp add-json vault \
  '{"type":"http","url":"https://snippet-vault.xxx.workers.dev/mcp"}'
```

若服务器要鉴权，把凭据放进 `headers`：

```bash
claude mcp add-json vault \
  '{"type":"http","url":"https://vault.example.com/mcp","headers":{"Authorization":"Bearer '"$VAULT_TOKEN"'"}}'
```

`--scope` 控制配置可见范围：`local`（仅你本项目，默认）、`project`（写入 `.mcp.json` 供全队共享）、`user`（你的所有项目）。验证：

```bash
claude mcp list        # 看到 vault ✔ Connected
claude mcp get vault   # 查看完整配置
```

**预期结果**：列表里 `vault` 显示已连接；在任意会话里说「存一段快速排序」，模型即可远程调用。**常见问题**：连接失败时优先查三处——URL 是否带了 `/mcp` 后缀、TLS 证书是否有效、`Accept` 头协商是否被中间代理改写。

## 第九步：加上认证，别让服务裸奔

公网上的 `/mcp` 必须有访问控制，最简方案是**静态 Bearer Token**（如上一步的 `Authorization` 头）。但更规范、更适合多用户的是 **OAuth 2.1**（Authorization Code + PKCE）。MCP 规范定义了标准化的授权元数据发现流程：客户端访问 `/.well-known/oauth-protected-resource`，拿到授权服务器地址后走 PKCE 流程拿 token，再以 Bearer 形式带在每个 `/mcp` 请求上。

在 SaaS 场景里，这层还能天然挂上**多租户隔离与计费计量**——每个 token 对应一个租户，按 `tools/call` 次数计费。生产化落地可参考社区已有的 streamable HTTP + OAuth 参考实现，无需从零造轮子。

---

## 实战案例：把「代码片段管家」升级为团队共享服务

把上面九步串成一个完整闭环。团队 5 人共用一套代码片段库，要求：Web、移动端、Claude Code 都能存取；存取带身份；按团队隔离。

落地架构：

```mermaid
flowchart LR
    subgraph 客户端
        A[Claude Code CLI]
        B[团队 Web 应用]
        C[移动端]
    end
    A & B & C -->|HTTPS POST /mcp<br/>Bearer Token| GW[(Caddy / Workers<br/>TLS + 鉴权)]
    GW --> S1[无状态 MCP 实例 1]
    GW --> S2[无状态 MCP 实例 2]
    S1 & S2 -->|读写| DB[(共享片段库<br/>D1/KV)]
    S1 -.进度/通知.-> A
```

关键决策点全部对应前面的步骤：

1. 选 **无状态**（第六步）——因为存取查询无需会话连续性，可直接横向扩展。
2. 部署 **Workers 多副本**（第七步路径 A），用 Workers KV 当共享存储，省去运维数据库。
3. 每个团队成员发一个 **独立 Bearer Token**（第九步），token 内嵌团队 ID 实现租户隔离。
4. Claude Code 侧每人 `claude mcp add-json vault ... --scope user` 一次性配置（第八步）。

最终验证流程：成员在 Claude Code 里说「把这段快速排序存进团队库」，模型远程调用 `add_snippet`；另一位成员在 Web 端用 `search_snippet("排序")` 即刻检索到——一处部署、多端共用，这正是 stdio 做不到的事。

```mermaid
sequenceDiagram
    participant CC as Claude Code
    participant SRV as 远程 MCP /mcp
    participant DB as 片段库
    CC->>SRV: POST initialize (Accept: json, sse)
    SRV-->>CC: 200 result{serverInfo, capabilities}
    Note over SRV: 无状态：不发 Mcp-Session-Id
    CC->>SRV: POST tools/call add_snippet (+ Bearer)
    SRV->>DB: 写入片段
    SRV-->>CC: 200 单条 JSON 结果
    CC->>SRV: POST tools/call search_snippet (+ Bearer)
    SRV-->>CC: 200 SSE 流式分页结果
```

## 常见问题排查速查

| 现象 | 根因 | 处理 |
|---|---|---|
| `claude mcp list` 显示 Failed to connect | URL 漏 `/mcp`、证书无效、Accept 头被代理改写 | 逐步用 `curl` 验证端点契约 |
| curl 返回 406 | 没声明 `text/event-stream` 而服务器要流式回包 | 补 `Accept` 头 |
| curl 返回 400 session not found | 有状态模式下漏带/带错 `Mcp-Session-Id` | 回放初始化拿到的会话头 |
| 容器外不可达 | 只监听 `127.0.0.1` | 改成 `host="0.0.0.0"` |
| 横向扩展后会话丢失 | 有状态实例没做粘性路由 | 改无状态，或 LB 配 sticky |
| 公网被刷接口 | 没有认证 | 至少上 Bearer Token，正式环境上 OAuth 2.1 |

## 延伸阅读

- MCP 规范「Transports」章节（`modelcontextprotocol.io/specification`）——协议层的权威契约定义
- Cloudflare Agents 文档「Build a Remote MCP server」——Workers 部署与 OAuth 接入最详尽范例
- Claude Code 文档「Connect to external tools with MCP」——`add-json`、scope、tool search、认证与错误处理的官方说明
- 社区参考实现 `NapthaAI/http-oauth-mcp-server`——Streamable HTTP + OAuth 可直接读源码

## 参考来源

1. [Connect to external tools with MCP — Claude Code Docs](https://code.claude.com/docs/en/agent-sdk/mcp) — Claude Code 官方 MCP 配置、传输类型、认证与错误处理
2. [Build a Remote MCP server — Cloudflare Agents docs](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) — Workers 上用 Streamable HTTP 部署远程 MCP 并接认证
3. [Install GitHub MCP Server in Claude Applications](https://github.com/github/github-mcp-server/blob/main/docs/installation-guides/install-claude.md) — `claude mcp add-json` 远程 HTTP 注册命令与 scope 的权威范例
4. [How to Deploy a Remote MCP Server — Toolradar Blog](https://toolradar.com/blog/deploy-remote-mcp-server) — Cloudflare/Vercel/AWS 部署对比，点明 stdio 在移动端/Web 端的局限
5. [MCP Remote HTTP Server Deployment Patterns — jangwook.net](https://jangwook.net/en/blog/en/mcp-remote-http-deployment-pattern/) — 有状态 vs 无状态部署模式的实战取舍

## 训练营适用性评估

- **适用类型**：MCP 进阶训练营、AI 工程化训练营、平台/工具链方向训练营。要求学员已具备本地 MCP 服务器开发基础，不适合作为第一堂 MCP 课。
- **前置铺垫**：建议先完成「用 FastMCP/TS SDK 写第一个 stdio 工具」再切入本教程。
- **预计教学时长**：讲授 + 跟做约 **3.5～4.5 小时**。其中协议契约与 curl 验证约 1 小时，部署实操（Workers 或 Docker 二选一）约 1.5 小时，认证与会话模式研讨约 1 小时，综合实战案例约 1 小时。
- **考核建议**：以「把指定本地工具部署成一个公网可访问、带 Bearer 认证的无状态远程 MCP，并用 Claude Code 连通调用成功」作为通过标准。
- **延伸方向**：通过后可衔接 OAuth 2.1 多租户化、可观测性（调用日志/计费）、以及多远程 MCP 的编排治理。

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
