首页 › tutorials › 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 工具、想把它推向生产或团队共享的开发者。

前置条件


一、为什么需要远程 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 攻击):

三、设计模式: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:初始化项目

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 实例。

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:本地启动与自测

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 自测:

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

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 长连接模型)

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 校验中间件骨架(替换上文的共享密钥中间件):

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 服务器:

# 共享密钥方式
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

验证连接:

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

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

工作流全景

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
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 裁剪结果

延伸阅读

训练营适用性评估

参考来源

  1. MCP Specification 2025-11-25 — Transports — stdio 与 Streamable HTTP 传输的权威定义、SSE 会话与安全要求
  2. MCP Specification 2025-11-25 — Authorization — OAuth 2.1、RFC9728 Protected Resource Metadata 等认证规范
  3. Connect to external tools with MCP — Claude Code Docs — claude mcp add --transport http 与远程服务器接入官方文档
  4. Building Streamable HTTP MCP Servers from Scratch using FastMCP in 2026 — dev.to — Streamable HTTP 从零搭建实战教程
  5. How to map REST APIs to MCP tools — Scalekit — 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学通信知识星球海报-扫码加入