本教程聚焦一个被大多数入门文章略过的关键环节——把一个真实的 REST API 封装成可被 Claude Code 远程调用的 MCP 服务器。你将掌握:stdio 何时该被放弃、Streamable HTTP 传输的工作原理、REST 端点到 MCP 工具的正确映射方法、Bearer/OAuth 2.1 认证、Docker 化部署,以及让 Claude Code 连上远程服务器的完整配置。适合已写过本地 stdio MCP 工具、想把它推向生产或团队共享的开发者。
@modelcontextprotocol/sdk 2.x)McpServer、server.tool(...) 的基本结构)绝大多数教程停在 stdio 传输:客户端把服务器当成子进程拉起,通过标准输入输出收发 JSON-RPC。这对本地单机开发足够,但一旦遇到下面任何一种诉求,stdio 就会顶到天花板:
| 诉求 | stdio 表现 | Streamable HTTP |
|---|---|---|
| 团队多人共享同一后端 | 每人各自启动子进程,状态分散 | 一份服务,全员连接 |
| Serverless / 云函数部署 | 无法"拉起子进程",不兼容 | 原生 HTTP,天然兼容 |
| 需要服务端会话与状态 | 进程即状态,关闭即丢失 | Mcp-Session-Id 维持会话 |
| 多客户端并发 | 一个进程一个客户端 | 单进程服务多连接 |
| 集中式密钥与审计 | 密钥散落各端 env | 密钥留在服务端,统一审计 |
判断标准很简单:如果这个 MCP 服务器只服务你一个人的本机,用 stdio;如果要让团队、外部协作者、或云端函数调用它,就该上 Streamable HTTP。
MCP 规范(当前 schema 版本 2025-11-25)只定义了两种标准传输:stdio 与 Streamable HTTP。后者正是为远程部署而生,并在 2025-03-26 规范版里取代了旧的 HTTP+SSE 传输(旧版已被标记废弃)。
核心机制:
https://example.com/mcp),同时支持 POST 和 GET。POST,请求头必须声明 Accept: application/json, text/event-stream。Content-Type: application/json),也可以发起 SSE 流(text/event-stream),客户端必须都能处理。MCP-Session-Id 头分配会话 ID,后续请求带上它即可关联状态。id,客户端断线后用 Last-Event-ID 头重连并补投,避免消息丢失。安全要点(规范明确要求,常被忽略导致 DNS rebinding 攻击):
Origin 头,非法来源返回 403;0.0.0.0;这是新手最容易踩坑的环节。把 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 引导 |
四条设计准则:
search_repos),参数是名词(query、owner、repo)。zod 描述输入:z.string().describe(...)、.min(1).max(30).default(10) 把约束直接喂给模型,减少无效调用。content[].text,不要返回几十 KB 的原始 JSON;做字段裁剪与格式化。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
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`));
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 与实际来源不符 |
本地调试置空,生产填真实域名 |
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 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 的fetchhandler),它对 SSE 流式支持更好、冷启动更快,但需要改写上面的 Express 入口。
共享密钥适合内部团队,真正面向多用户时应上 OAuth 2.1。根据规范,MCP 服务端扮演 OAuth 资源服务器(Resource Server) 角色,并必须实现 RFC9728 Protected Resource Metadata,让客户端自动发现授权服务器。
落地路径(推荐对接 Auth0 / Clerk / Cloudflare Access,不必自建):
/.well-known/oauth-protected-resource,返回含 authorization_servers 的元数据;Authorization: Bearer <token> 交给授权服务器的 JWKS 校验签名与作用域;简化的 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" });
}
});
部署完成后,在任意项目目录注册远程 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 裁剪结果
@modelcontextprotocol/sdk 2.x)— StreamableHTTPServerTransport 源码与示例claude mcp add --transport http 完整参数claude mcp add --transport http 与远程服务器接入官方文档[!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 等场景全覆盖。
