<!-- title: REST API封装成远程MCP服务器 -->

# REST API 封装成远程 MCP 服务器：Streamable HTTP 传输 + 认证 + 部署

## 概述

本教程聚焦一个被大多数入门文章略过的关键环节——**把一个真实的 REST API 封装成可被 Claude Code 远程调用的 MCP 服务器**。你将掌握：stdio 何时该被放弃、Streamable HTTP 传输的工作原理、REST 端点到 MCP 工具的正确映射方法、Bearer/OAuth 2.1 认证、Docker 化部署，以及让 Claude Code 连上远程服务器的完整配置。适合已写过本地 stdio MCP 工具、想把它推向生产或团队共享的开发者。

## 前置条件

- Node.js 20+ 与 npm/pnpm（本文用 `@modelcontextprotocol/sdk` 2.x）
- 已经亲手写过至少一个 stdio MCP 工具（熟悉 `McpServer`、`server.tool(...)` 的基本结构）
- 一个待封装的 REST API——本文用 **GitHub REST API** 做示例，因为它免费、有清晰分页、需要鉴权
- Docker，以及 Fly.io / Cloudflare Workers / Vercel 任选其一用于部署
- Claude Code 最新版（用于端到端验证）

---

## 一、为什么需要远程 MCP：stdio 的天花板

绝大多数教程停在 stdio 传输：客户端把服务器当成子进程拉起，通过标准输入输出收发 JSON-RPC。这对本地单机开发足够，但一旦遇到下面任何一种诉求，stdio 就会顶到天花板：

| 诉求 | stdio 表现 | Streamable HTTP |
|------|-----------|-----------------|
| 团队多人共享同一后端 | 每人各自启动子进程，状态分散 | 一份服务，全员连接 |
| Serverless / 云函数部署 | 无法"拉起子进程"，不兼容 | 原生 HTTP，天然兼容 |
| 需要服务端会话与状态 | 进程即状态，关闭即丢失 | `Mcp-Session-Id` 维持会话 |
| 多客户端并发 | 一个进程一个客户端 | 单进程服务多连接 |
| 集中式密钥与审计 | 密钥散落各端 env | 密钥留在服务端，统一审计 |

**判断标准很简单**：如果这个 MCP 服务器只服务你一个人的本机，用 stdio；如果要让团队、外部协作者、或云端函数调用它，就该上 Streamable HTTP。

## 二、Streamable HTTP 传输原理（基于 MCP 2025-11-25 规范）

MCP 规范（当前 schema 版本 `2025-11-25`）只定义了两种标准传输：**stdio** 与 **Streamable HTTP**。后者正是为远程部署而生，并在 2025-03-26 规范版里**取代了旧的 HTTP+SSE 传输**（旧版已被标记废弃）。

核心机制：

1. **单一端点**：服务端只暴露一个 MCP 端点（如 `https://example.com/mcp`），同时支持 `POST` 和 `GET`。
2. **POST 发消息**：每条 JSON-RPC 消息都是一次新的 `POST`，请求头必须声明 `Accept: application/json, text/event-stream`。
3. **两种响应**：服务端对请求既可以返回单个 JSON 对象（`Content-Type: application/json`），也可以发起 SSE 流（`text/event-stream`），客户端必须都能处理。
4. **会话管理**：服务端可在初始化响应里通过 `MCP-Session-Id` 头分配会话 ID，后续请求带上它即可关联状态。
5. **可恢复**：SSE 事件可带 `id`，客户端断线后用 `Last-Event-ID` 头重连并补投，避免消息丢失。

**安全要点**（规范明确要求，常被忽略导致 DNS rebinding 攻击）：

- 服务端**必须校验 `Origin` 头**，非法来源返回 `403`；
- 本地运行时**应只绑定 127.0.0.1**，而非 `0.0.0.0`；
- **应为所有连接实现认证**。

## 三、设计模式：REST 端点 → MCP 工具

这是新手最容易踩坑的环节。把 REST API 封装成 MCP **不是 1:1 机械映射**，因为 REST 按"HTTP 动词 + 路径"组织，而 MCP 工具应按"用户想完成的能力"组织。模型不会说"向 `/repos/{owner}/{repo}/issues` 发 GET"，它会说"帮我看看这个仓库最近的 issue"。

| 反模式（机械映射） | 推荐（能力导向） |
|-------------------|----------------|
| `get_repos_issues_get` | `list_recent_issues` |
| 把 `limit/offset/page/per_page` 全暴露 | 暴露 `limit`，内部处理分页 |
| 上游 500 直接抛给模型 | 转成结构化文本 + 错误说明 |
| 一个工具做十件事 | 单一职责，靠 description 引导 |

四条设计准则：

1. **动词命名、名词参数**：工具名是动作（`search_repos`），参数是名词（`query`、`owner`、`repo`）。
2. **用 `zod` 描述输入**：`z.string().describe(...)`、`.min(1).max(30).default(10)` 把约束直接喂给模型，减少无效调用。
3. **返回精炼文本**：模型读的是 `content[].text`，不要返回几十 KB 的原始 JSON；做字段裁剪与格式化。
4. **错误要"可解释"**：把上游错误转成一句话描述，让模型知道是 token 过期还是参数错误。

## 四、实战：把 GitHub REST API 封装成 Streamable HTTP 服务器

### 步骤 1：初始化项目

```bash
mkdir github-remote-mcp && cd github-remote-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod express
npm install -D typescript @types/express @types/node tsx
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext
```

### 步骤 2：编写服务器 `server.ts`

下面是一份完整、可直接运行的服务端，含三个工具（搜索仓库 / 取仓库详情 / 列最近 issue）、Origin 校验、共享密钥认证、按会话隔离的 transport 实例。

```ts
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
const SERVER_TOKEN = process.env.SERVER_TOKEN; // 共享密钥认证；生产建议替换为 OAuth 2.1
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN ?? ""; // 允许的前端来源

// 上游 GitHub 调用：集中鉴权 + 统一错误处理
async function gh<T>(path: string): Promise<T> {
  const res = await fetch(`https://api.github.com${path}`, {
    headers: {
      Authorization: `Bearer ${GITHUB_TOKEN}`,
      Accept: "application/vnd.github+json",
      "X-GitHub-Api-Version": "2022-11-28",
    },
  });
  if (!res.ok) {
    throw new Error(`GitHub ${res.status}: ${(await res.text()).slice(0, 200)}`);
  }
  return res.json() as Promise<T>;
}

// 构建一个独立的 server 实例（每个会话各一份）
function buildServer(): McpServer {
  const server = new McpServer({ name: "github-remote", version: "1.0.0" });

  server.tool(
    "search_repos",
    "按关键词搜索 GitHub 仓库，返回仓库全名、星标数与简介",
    { query: z.string().describe("仓库名或关键词"),
      limit: z.number().int().min(1).max(30).default(10) },
    async ({ query, limit }) => {
      const data = await gh<{ items: any[] }>(
        `/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}`
      );
      const lines = data.items.map(
        (r) => `${r.full_name}  ★${r.stargazers_count}  — ${r.description ?? ""}`
      );
      return { content: [{ type: "text", text: lines.join("\n") || "无结果" }] };
    }
  );

  server.tool(
    "get_repo",
    "获取仓库详情：简介、星标、主语言、默认分支、最近推送时间",
    { owner: z.string(), repo: z.string() },
    async ({ owner, repo }) => {
      const r = await gh<any>(`/repos/${owner}/${repo}`);
      const text = [
        `${r.full_name}  ★${r.stargazers_count}`,
        r.description ?? "（无简介）",
        `主语言: ${r.language ?? "未知"} | 默认分支: ${r.default_branch}`,
        `最近推送: ${r.pushed_at}`,
      ].join("\n");
      return { content: [{ type: "text", text }] };
    }
  );

  server.tool(
    "list_recent_issues",
    "列出某仓库最近开放的 issue（自动排除 PR，内部处理分页）",
    { owner: z.string(), repo: z.string(),
      limit: z.number().int().min(1).max(30).default(10) },
    async ({ owner, repo, limit }) => {
      const data = await gh<any[]>(
        `/repos/${owner}/${repo}/issues?state=open&per_page=${limit}`
      );
      const lines = data.filter((i) => !i.pull_request).map((i) => `#${i.number}  ${i.title}`);
      return { content: [{ type: "text", text: lines.join("\n") || "暂无 open issue" }] };
    }
  );

  return server;
}

const app = express();
app.use(express.json());

// 中间件 1：校验 Origin，防 DNS rebinding
app.use((req, res, next) => {
  if (ALLOWED_ORIGIN && req.headers.origin && req.headers.origin !== ALLOWED_ORIGIN) {
    return res.status(403).json({ error: "Forbidden origin" });
  }
  next();
});

// 中间件 2：Bearer 共享密钥认证（仅 /mcp）
app.use("/mcp", (req, res, next) => {
  if (SERVER_TOKEN && req.headers.authorization !== `Bearer ${SERVER_TOKEN}`) {
    return res.status(401).json({ error: "Unauthorized" });
  }
  next();
});

// 每个会话独立持有 transport + server
const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: McpServer }>();

app.post("/mcp", async (req, res) => {
  const id = req.headers["mcp-session-id"] as string | undefined;
  let entry = id ? sessions.get(id) : undefined;

  // 首次请求：创建会话
  if (!entry && !id) {
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
    const server = buildServer();
    transport.onclose = () => sessions.delete(transport.sessionId!);
    await server.connect(transport);
    entry = { transport, server };
    sessions.set(transport.sessionId!, entry);
  } else if (!entry) {
    return res.status(404).json({ error: "session not found" });
  }
  await entry.transport.handleRequest(req, res, req.body);
});

// GET：可选的 SSE 服务端推送通道（本例不主动推送，回 405）
app.get("/mcp", (_req, res) => res.status(405).json({ error: "GET not supported" }));

// DELETE：结束会话
app.delete("/mcp", async (req, res) => {
  const id = req.headers["mcp-session-id"] as string | undefined;
  if (id && sessions.has(id)) {
    await sessions.get(id)!.transport.close();
    sessions.delete(id);
  }
  res.status(204).end();
});

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => console.log(`remote MCP on :${PORT}/mcp`));
```

### 步骤 3：本地启动与自测

```bash
export GITHUB_TOKEN=ghp_xxxxxxxxxxxx   # 你的 GitHub PAT
export SERVER_TOKEN=s3cret-shared-key  # 共享密钥
export ALLOWED_ORIGIN=""               # 本地留空
npx tsx server.ts
```

**预期结果**：终端打印 `remote MCP on :3000/mcp`。用 MCP Inspector 自测：

```bash
npx @modelcontextprotocol/inspector node node_modules/.bin/tsx server.ts
```

在 Inspector 里选择 "Streamable HTTP" 传输，填 `http://localhost:3000/mcp`，并在请求头加上 `Authorization: Bearer s3cret-shared-key`，连接后应能看到 `search_repos` / `get_repo` / `list_recent_issues` 三个工具并能调用。

### 常见问题

| 现象 | 原因 | 解决 |
|------|------|------|
| Inspector 报 401 | 共享密钥未带或拼写错误 | 请求头加 `Authorization: Bearer <SERVER_TOKEN>` |
| 上游返回 403 | `GITHUB_TOKEN` 过期或无权限 | 在 GitHub 刷新 PAT，赋予 `public_repo` 作用域 |
| 连接后立即 404 session | 客户端发了旧 `Mcp-Session-Id` | 清空 Inspector 会话重连 |
| 403 Forbidden origin | `ALLOWED_ORIGIN` 与实际来源不符 | 本地调试置空，生产填真实域名 |

## 五、Docker 化与云端部署

### Dockerfile

```dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm i tsx
COPY . .
ENV PORT=3000
EXPOSE 3000
CMD ["npx", "tsx", "server.ts"]
```

### 一键发布到 Fly.io（最匹配 Express 长连接模型）

```bash
fly launch --no-deploy          # 生成 fly.toml
fly secrets set GITHUB_TOKEN=ghp_xxx SERVER_TOKEN=$(openssl rand -hex 24)
fly deploy
# 得到 https://github-remote-mcp.fly.dev/mcp
```

> 若选 **Cloudflare Workers**，需把传输换成 Workers 适配版（用 `McpServer` + Workers 的 `fetch` handler），它对 SSE 流式支持更好、冷启动更快，但需要改写上面的 Express 入口。

## 六、生产认证：从共享密钥到 OAuth 2.1

共享密钥适合内部团队，真正面向多用户时应上 **OAuth 2.1**。根据规范，MCP 服务端扮演 **OAuth 资源服务器（Resource Server）** 角色，并**必须实现 RFC9728 Protected Resource Metadata**，让客户端自动发现授权服务器。

落地路径（推荐对接 Auth0 / Clerk / Cloudflare Access，不必自建）：

1. 在授权服务器（如 Auth0）创建应用，拿到签发 URL；
2. MCP 服务端暴露 `/.well-known/oauth-protected-resource`，返回含 `authorization_servers` 的元数据；
3. 中间件把 `Authorization: Bearer <token>` 交给授权服务器的 JWKS 校验签名与作用域；
4. Claude Code 会自动走发现 + PKCE 授权码流程完成登录。

简化的 JWKS 校验中间件骨架（替换上文的共享密钥中间件）：

```ts
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL(`${process.env.ISSUER}/.well-known/jwks.json`));

app.use("/mcp", async (req, res, next) => {
  const token = req.headers.authorization?.replace(/^Bearer /, "");
  if (!token) return res.status(401).json({ error: "no token" });
  try {
    await jwtVerify(token, JWKS, { issuer: process.env.ISSUER, audience: "github-remote-mcp" });
    next();
  } catch {
    res.status(401).json({ error: "invalid token" });
  }
});
```

## 七、让 Claude Code 接入远程服务器

部署完成后，在任意项目目录注册远程 MCP 服务器：

```bash
# 共享密钥方式
claude mcp add --transport http github-api \
  https://github-remote-mcp.fly.dev/mcp \
  --header "Authorization: Bearer $SERVER_TOKEN"

# OAuth 方式（Claude Code 自动引导授权）
claude mcp add --transport http github-api \
  https://github-remote-mcp.fly.dev/mcp
```

验证连接：

```bash
claude mcp list          # 应看到 github-api (http) ✅ Connected
claude mcp get github-api
```

随后在对话里直接说："搜索 `modelcontextprotocol` 相关仓库，看看 typescript-sdk 的星标和最近 issue"——Claude 会自动调用对应工具，整个过程模型只看到三个语义清晰的工具，而非 GitHub 的几十个 REST 端点。

## 工作流全景

```mermaid
flowchart LR
    subgraph Client["客户端"]
        CC["Claude Code"]
    end
    subgraph Server["远程 MCP 服务 (Fly.io / Workers)"]
        MW["认证 + Origin 中间件<br/>Bearer / OAuth 2.1"]
        EP["/mcp 端点<br/>Streamable HTTP"]
        SESS["会话管理<br/>MCP-Session-Id"]
        TOOLS["MCP 工具<br/>search_repos / get_repo / list_recent_issues"]
    end
    subgraph Upstream["上游 API"]
        GH["GitHub REST API"]
    end
    CC -->|"POST JSON-RPC<br/>Authorization: Bearer"| MW
    MW --> EP --> SESS --> TOOLS
    TOOLS -->|"fetch + Token"| GH
    GH --> TOOLS
    TOOLS -->|"裁剪为文本"| EP -->|"JSON / SSE"| CC
```

```mermaid
sequenceDiagram
    participant CC as Claude Code
    participant S as MCP 服务 /mcp
    participant GH as GitHub API
    CC->>S: POST initialize (无 session-id)
    S-->>CC: 200 + MCP-Session-Id: <uuid>
    CC->>S: POST tools/list (带 session-id)
    S-->>CC: [search_repos, get_repo, list_recent_issues]
    CC->>S: POST tools/call {search_repos, query}
    S->>GH: GET /search/repositories (Bearer GITHUB_TOKEN)
    GH-->>S: JSON
    S-->>CC: 200 text/plain 裁剪结果
```

## 延伸阅读

- **MCP 官方规范**（2025-11-25）— Transports、Authorization 章节，权威定义 Streamable HTTP 与 OAuth 2.1 要求
- **TypeScript SDK**（`@modelcontextprotocol/sdk` 2.x）— `StreamableHTTPServerTransport` 源码与示例
- **MCP Inspector** — 可视化调试任意 stdio / HTTP MCP 服务
- **Claude Code 文档 · Connect to external tools with MCP** — `claude mcp add --transport http` 完整参数
- **RFC 9728** OAuth 2.0 Protected Resource Metadata — 生产认证发现的底层标准

## 训练营适用性评估

- **适合类型**：进阶后端 / 平台工程方向训练营，或"AI 工程化"专题的远程能力模块；不建议零基础营（需先掌握 stdio 工具开发）
- **前置课时**：本系列 Round 1–2（安装配置、首个 Skill/工具）+ 一个 stdio MCP 工具实战
- **预计教学时长**：4–5 课时（传输原理 0.5 + 映射设计 1 + 编码实战 2 + 部署认证 1）
- **考核点**：能独立把任意公开 REST API（如 GitLab、Jira、自研服务）封装成带认证的远程 MCP，并由 Claude Code 成功调用
- **延伸练习**：把共享密钥升级为 Auth0 OAuth、增加流式进度通知、对接数据库做工具级权限分级

## 参考来源

1. [MCP Specification 2025-11-25 — Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) — stdio 与 Streamable HTTP 传输的权威定义、SSE 会话与安全要求
2. [MCP Specification 2025-11-25 — Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) — OAuth 2.1、RFC9728 Protected Resource Metadata 等认证规范
3. [Connect to external tools with MCP — Claude Code Docs](https://code.claude.com/docs/en/agent-sdk/mcp) — `claude mcp add --transport http` 与远程服务器接入官方文档
4. [Building Streamable HTTP MCP Servers from Scratch using FastMCP in 2026 — dev.to](https://dev.to/composiodev/building-streamable-http-mcp-servers-from-scratch-using-fastmcp-in-2026-5fh9) — Streamable HTTP 从零搭建实战教程
5. [How to map REST APIs to MCP tools — Scalekit](https://www.scalekit.com/blog/map-api-into-mcp-tool-definitions) — REST 端点到 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)
