<!-- title: 远程MCP上OAuth2.1：从发现到令牌校验 -->

## 概述

本教程解决一个被大多数 MCP 入门资料跳过的问题：**把一个本地 stdio 的 MCP 工具，升级为可在公网开放、且只有授权用户能调用的远程服务**。你会学到完整的 OAuth 2.1 鉴权链路——受保护资源元数据（RFC 9728）发布、授权服务器发现（RFC 8414）、动态客户端注册（RFC 7591）、带 PKCE 与资源指示符（RFC 8707）的授权码流程，以及服务端 JWT 校验中间件的工程实现。适用人群：已能写出本地 MCP 工具、希望给团队或客户安全开放远程调用的后端/AI 工程师。

## 前置条件

- 已掌握 MCP 三原语（tools/resources/prompts）与 Streamable HTTP 传输概念（本文是它们的"上公网"续集）
- Node.js 20+（或 Bun 1.1+），能跑 `npm`/`pnpm`
- 了解 JWT、OAuth 基本术语（client_id、授权码、访问令牌）
- 一台可暴露 HTTPS 的机器或隧道（本文用 `localhost` 演示，原理一致）

## 第一步：理解 stdio 为什么不能直接上公网

stdio 传输的安全模型是"子进程边界"——客户端拉起服务器进程，消息只在父子进程的管道里流动，攻击面几乎为零。一旦换成远程，这个边界就消失了：

- 任何能访问到端点的人都能 `initialize` 并调用你的工具
- 工具背后若挂着数据库、Kubernetes、支付 API，等价于裸奔
- 多租户场景下，A 用户的请求可能被 B 用户的会话串号

因此"可上线远程 MCP"必须叠加两层：**传输层**（Streamable HTTP）解决消息怎么走，**鉴权层**（OAuth 2.1）解决谁能调。MCP 规范在 2025-06-18 版正式把 HTTP+SSE 替换为 Streamable HTTP，并规定远程服务器应以 OAuth 2.1 Resource Server 身份校验 Bearer 令牌。2025 年 11 月的修订进一步把 PKCE 设为强制，并收紧资源指示符要求。

## 第二步：理清三条并行链路

很多人把"传输、会话、鉴权"混为一谈，实现时就会错位。请先把它们分开：

| 链路 | 载体 | 作用 | 失败表现 |
|---|---|---|---|
| 传输 | HTTP POST/GET + 可选 SSE | 承载 JSON-RPC 消息 | 400/连接断开 |
| 会话 | `Mcp-Session-Id` 头 | 关联同一客户端的状态 | 404 Not Found |
| 鉴权 | `Authorization: Bearer` 头 | 证明调用者身份与权限 | 401 + `WWW-Authenticate` |

注意：会话 ID 和访问令牌是两回事。会话 ID 是服务器在 `InitializeResult` 响应里下发的、用于关联逻辑交互；访问令牌是授权服务器签发的、用于证明身份。客户端每次请求都要同时带上两者（外加 `MCP-Protocol-Version` 头）。

## 第三步：搭一个最小的 Streamable HTTP MCP 服务器

先建工程并安装依赖。这里用 Hono（轻量、跨 Node/Bun/Edge，社区远程 MCP 模板的主流选择）和官方 TypeScript SDK：

```bash
mkdir remote-mcp-auth && cd remote-mcp-auth
npm init -y
npm i hono @modelcontextprotocol/sdk jose zod
npm i -D @types/node tsx typescript
```

`src/server.ts`——先不接鉴权，只把一个 `echo` 工具跑在 Streamable HTTP 上：

```ts
import { Hono } from "hono";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const app = new Hono();
const MCP_ENDPOINT = "/mcp";

// 复用一个有状态传输实例（演示用；多实例需共享会话存储）
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

const server = new McpServer({ name: "remote-echo", version: "0.1.0" });
server.tool("echo", { text: z.string() }, async ({ text }) => ({
  content: [{ type: "text", text: `echo: ${text}` }],
}));

// 接管 POST/GET/DELETE 三种方法
app.all(MCP_ENDPOINT, (c) => transport.handleRequest(c.env, c.req.raw));

// 启动时把 server 连到 transport（仅一次）
(async () => {
  await server.connect(transport);
  Bun.serve({ port: 3000, fetch: app.fetch });
  console.log("listening :3000/mcp");
})();
```

预期结果：`bun run src/server.ts` 后，用 `curl` 发一个裸的 `initialize`：

```bash
curl -i -X POST http://localhost:3000/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"}}}'
```

此时应能返回 `InitializeResult`。**注意：这步还"裸奔"，下一步立刻上锁。**

常见问题：若返回 405，说明路由没覆盖 GET；若 SSE 一直不结束，检查 `Accept` 头是否同时声明了 `application/json` 和 `text/event-stream`（规范要求客户端必须声明两者）。

## 第四步：发布受保护资源元数据（RFC 9728）

OAuth 2.1 的精髓是"发现优先于配置"。MCP 客户端连上来不知道你的授权服务器在哪，必须靠发现文档指路。服务器要做的第一件事，是告诉客户端"我是受保护资源，去这里找 AS"。

先在 `/mcp` 前加一个返回 401 + `WWW-Authenticate` 的守卫：

```ts
const RESOURCE_URL = "http://localhost:3000/mcp";
const PROTECTED_RESOURCE_META = "http://localhost:3000/.well-known/oauth-protected-resource";

function unauthorized(c: any) {
  return c.body(null, 401, {
    "WWW-Authenticate": `Bearer resource_metadata="${PROTECTED_RESOURCE_META}"`,
  });
}
```

再发布发现文档（RFC 9728 字段）：

```ts
app.get("/.well-known/oauth-protected-resource", (c) =>
  c.json({
    resource: RESOURCE_URL,
    authorization_servers: ["http://localhost:3001"], // 我们的迷你 AS
    scopes_supported: ["repo:read", "repo:write"],
    bearer_methods_supported: ["header"],
    resource_documentation: "http://localhost:3000/docs",
  })
);
```

用 curl 验证发现链路的第一跳：

```bash
curl -i http://localhost:3000/mcp          # → 401，带 WWW-Authenticate
curl http://localhost:3000/.well-known/oauth-protected-resource
# → {"resource":".../mcp","authorization_servers":["http://localhost:3001"], ...}
```

常见问题：`resource` 字段必须是 MCP 服务器的**规范 URI**，且与客户端后续在 token 请求里带的 `resource` 参数、以及 JWT 的 `aud` 声明**三者完全一致**。这是 RFC 8707 资源指示符的核心约束，错一个字符都会导致令牌被拒。

## 第五步：写 JWT 校验中间件

资源服务器不签发令牌，只校验。用 `jose` 通过 JWKS 远程公钥校验 RS256 JWT：

```ts
import { jwtVerify, createRemoteJWKSet } from "jose";

const AS_ISSUER = "http://localhost:3001";
const JWKS = createRemoteJWKSet(new URL(`${AS_ISSUER}/jwks`));

async function requireAuth(c: any, next: any) {
  const auth = c.req.header("authorization");
  if (!auth?.startsWith("Bearer ")) return unauthorized(c);
  const token = auth.slice(7);
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: AS_ISSUER,
      audience: RESOURCE_URL, // 关键：aud 必须等于本资源 URL（RFC 8707）
      requiredClaims: ["sub", "iss", "aud", "exp", "iat"],
    });
    c.set("principal", payload); // 后续工具可按 scope/payload 鉴权
    await next();
  } catch {
    return unauthorized(c);
  }
}

// 把守卫挂到 MCP 端点前
app.all(MCP_ENDPOINT, requireAuth, (c) => transport.handleRequest(c.env, c.req.raw));
```

预期结果：不带 token → 401；带一个 `aud` 不匹配或过期的 token → 401；带合法 token → 正常返回工具结果。

常见问题：`audience` 校验失败是远程 MCP 最常见的"诡异 401"。务必确认 AS 签发时把客户端请求里的 `resource` 值写进了 `aud`。另一个坑：本地 `http` 下 `jose` 会照常拉取 JWKS，但生产环境 AS 的 `jwks_uri` 必须是 HTTPS。

## 第六步：配一个最小授权服务器

为了让整条链路能在本地跑通（生产请换 Keycloak/Auth0/Okta），我们在 `:3001` 实现一个最小 AS。它要提供：AS 元数据（RFC 8414）、动态注册（RFC 7591）、`/authorize`、`/token`、`/jwks`。

`src/as.ts`（精简版，省略 consent 页与刷新令牌）：

```ts
import { Hono } from "hono";
import { generateKeyPair, SignJWT, exportJWK } from "jose";
import { createHash, randomBytes, base64url } from "node:crypto";

const app = new Hono();
const ISSUER = "http://localhost:3001";
const RESOURCE_URL = "http://localhost:3000/mcp";
const { publicKey, privateKey } = await generateKeyPair("RS256");

// 内存态：注册的客户端 + 授权码 + PKCE
const clients = new Map<string, any>();
const codes = new Map<string, any>();

// 1) AS 元数据（RFC 8414）
app.get("/.well-known/oauth-authorization-server", (c) => c.json({
  issuer: ISSUER,
  authorization_endpoint: `${ISSUER}/authorize`,
  token_endpoint: `${ISSUER}/token`,
  registration_endpoint: `${ISSUER}/register`,
  jwks_uri: `${ISSUER}/jwks`,
  response_types_supported: ["code"],
  grant_types_supported: ["authorization_code", "refresh_token"],
  code_challenge_methods_supported: ["S256"], // 强制 PKCE
}));

// 2) JWKS
app.get("/jwks", async (c) => c.json({ keys: [await exportJWK(publicKey)] }));

// 3) 动态客户端注册（RFC 7591）
app.post("/register", async (c) => {
  const body = await c.req.json();
  const clientId = randomBytes(8).toString("hex");
  clients.set(clientId, { redirect_uris: body.redirect_uris ?? [] });
  return c.json({ client_id: clientId, client_id_issued_at: 0 });
});

// 4) 授权端点：核验 PKCE、签发 code
app.get("/authorize", (c) => {
  const { client_id, redirect_uri, code_challenge, response_type, resource } = c.req.query();
  if (response_type !== "code") return c.text("unsupported", 400);
  if (resource !== RESOURCE_URL) return c.text("resource mismatch", 400); // RFC 8707
  const code = randomBytes(16).toString("hex");
  codes.set(code, { client_id, redirect_uri, code_challenge, user: "demo-user" });
  const u = new URL(redirect_uri); u.searchParams.set("code", code);
  return c.redirect(u.toString()); // 生产应先弹 consent 页
});

// 5) 令牌端点：校验 code_verifier、签发带 aud 的 JWT
app.post("/token", async (c) => {
  const body = await c.req.parseBody();
  const rec = codes.get(String(body.code));
  codes.delete(String(body.code));
  if (!rec) return c.json({ error: "invalid_grant" }, 400);
  const calc = base64url(createHash("sha256").update(body.code_verifier).digest());
  if (calc !== rec.code_challenge) return c.json({ error: "invalid_grant" }, 400);
  const access_token = await new SignJWT({ scope: "repo:read" })
    .setProtectedHeader({ alg: "RS256" })
    .setIssuer(ISSUER)
    .setSubject(rec.user)
    .setAudience(RESOURCE_URL) // 写进 aud，供资源服务器校验
    .setIssuedAt().setExpirationTime("1h").sign(privateKey);
  return c.json({ access_token, token_type: "Bearer", expires_in: 3600 });
});

Bun.serve({ port: 3001, fetch: app.fetch });
console.log("AS on :3001");
```

预期结果：客户端完成发现后，会按 S256 生成 `code_verifier`/`code_challenge`，经浏览器走授权码，换到带正确 `aud` 的 JWT。

## 第七步：用 Claude Code 作为客户端跑通整条链路

把 MCP 服务器和 AS 都启动后，用 Claude Code 连接远程服务器（它内置了完整的 OAuth 发现 + PKCE 客户端）：

```bash
claude mcp add --transport http my-remote http://localhost:3000/mcp
```

Claude Code 第一次连接会：收到 401 → 解析 `WWW-Authenticate` → 拉 `oauth-protected-resource` → 拉 `oauth-authorization-server` → 动态注册客户端 → 打开浏览器完成 PKCE 授权 → 拿到令牌并缓存 → 用令牌重发 `initialize`。在服务器侧日志里你能依次看到这一连串请求，这是验证"链路通"的最直观证据。

常见问题：若客户端反复弹浏览器却连不上，八成是 `resource` 三处不一致（发现文档、token 请求参数、JWT aud）。若报 `unsupported_grant_type`，检查 AS 元数据里 `grant_types_supported` 是否声明了 `authorization_code`。

## 第八步：安全加固（上线前清单）

规范在传输章节专门给出三条硬性警告，不要漏：

1. **校验 `Origin` 头**，防 DNS 重绑定攻击——否则恶意网页能让浏览器代你调用本地 MCP：

```ts
const ALLOWED = new Set(["http://localhost:5173"]);
app.use("/mcp", async (c, next) => {
  const origin = c.req.header("origin");
  if (origin && !ALLOWED.has(origin)) return c.text("forbidden origin", 403);
  await next();
});
```

2. **本地运行只绑 127.0.0.1**，不要 `0.0.0.0`。
3. **防 Confused Deputy**：若你的 MCP 是代理型（背后转发到第三方 API、且用静态 client_id），**必须**在转发到第三方 AS 之前，实现"按 client_id 的独立 consent 页"，否则攻击者可借助受害者浏览器的 consent cookie，把授权码重定向到自己服务器，冒充用户调你的工具。这是官方安全最佳实践文档明确标注的高危项。

## 实战案例：团队共享的 PR 审计 MCP

把上面的零件组装成一个真实场景：服务器暴露 `review_pull_request(repo, pr)` 工具，调用 GitHub API 拉取 diff 并返回审查意见。鉴权上要求：

- 访问令牌 `aud = https://audit.mcp.internal/mcp`
- 必须带 `scope = repo:read`
- 工具内部按 `payload.sub` 限流（每人每小时 20 次）

工具实现里读取鉴权中间件注入的 principal：

```ts
server.tool("review_pull_request",
  { repo: z.string(), pr: z.number() },
  async (args, { _meta }) => {
    const principal = (transport as any).__principal; // 实际从请求上下文取
    if (!principal.scope?.includes("repo:read")) {
      return { content: [{ type: "text", text: "forbidden: need repo:read" }], isError: true };
    }
    // ... 调用 GitHub、返回审查意见
  });
```

这个例子把"传输—发现—令牌校验—细粒度授权—业务工具"五层串起来，可作为团队内部上线的最小可信骨架。

## 整体工作流

```mermaid
sequenceDiagram
    participant CC as Claude Code (Client)
    participant MCP as MCP Server (Resource)
    participant AS as Authorization Server

    CC->>MCP: POST /mcp (无 token)
    MCP-->>CC: 401 + WWW-Authenticate: resource_metadata=...
    CC->>MCP: GET /.well-known/oauth-protected-resource
    MCP-->>CC: { authorization_servers:[AS] }
    CC->>AS: GET /.well-known/oauth-authorization-server
    AS-->>CC: endpoints (auth/token/register, S256)
    CC->>AS: POST /register (redirect_uri)
    AS-->>CC: client_id
    Note over CC: 生成 PKCE code_verifier/S256 challenge
    CC->>AS: GET /authorize?resource=MCP_URL&code_challenge=...
    AS-->>CC: 302 redirect + code
    CC->>AS: POST /token (code + code_verifier + resource)
    AS-->>CC: access_token (aud=MCP_URL)
    CC->>MCP: POST /mcp Authorization: Bearer + Mcp-Session-Id
    MCP->>MCP: jwtVerify(aud=MCP_URL, iss, exp)
    MCP-->>CC: tool result
```

## 延伸阅读

- MCP 规范授权章节：`modelcontextprotocol.io/specification/2025-06-18/basic/authorization`
- MCP 规范传输章节（Streamable HTTP 安全警告）：`modelcontextprotocol.io/specification/2025-06-18/basic/transports`
- RFC 9728（受保护资源元数据）、RFC 8414（AS 元数据）、RFC 8707（资源指示符）、RFC 7591（动态注册）
- `jose` 库文档：JWT 签发与 JWKS 校验
- Hono 官方远程 MCP 模板：`github.com/honojs` 社区示例

## 训练营适用性评估

- **适合**：MCP 工程化训练营（中后段）、AI 应用安全训练营、企业 AI 集成训练营。要求学员已能独立开发本地 MCP 工具。
- **不适合**：零基础入门营——前置概念（OAuth、JWT、HTTP 中间件）偏多。
- **建议时长**：1 个教学日，讲授约 3 小时（发现链路 + 安全模型）+ 实操约 4 小时（搭服务器、写 AS、跑通 Claude Code 连接、完成加固清单）。
- **可考核产出**：一个带 OAuth 2.1 鉴权、能被 Claude Code 通过 PKCE 连接、且通过 Origin 校验与 audience 校验的远程 MCP 服务，附 `curl` 复现脚本。

## 参考来源

1. [MCP Authorization Specification (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) — 官方授权规范，定义 OAuth 2.1、RFC 9728/8414/7591/8707 集成与发现流程
2. [MCP Transports Specification (Streamable HTTP)](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) — Streamable HTTP 传输细节、会话管理与 DNS 重绑定安全警告
3. [MCP Security Best Practices](https://modelcontextprotocol.io/specification/latest/basic/security_best_practices) — Confused Deputy 攻击分析与 per-client consent 缓解方案
4. [Protecting MCP Server with OAuth 2.1: A Practical Guide Using Go and Keycloak](https://medium.com/@wadahiro/protecting-mcp-server-with-oauth-2-1-a-practical-guide-using-go-and-keycloak-7544eb5379d3) — Go+Keycloak 实现远程 MCP 鉴权的工程参考
5. [OAuth 2.1 + PKCE for MCP Gateways: The Complete Flow](https://docs.gostoa.dev/blog/oauth-pkce-mcp-gateway) — 网关视角下的 RFC 9728 发现与 scope 处理

> [!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)
