首页 › tutorials › 远程MCP上OAuth2.1:从发现到令牌校验

概述

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

前置条件

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

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

因此"可上线远程 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:

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 上:

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:

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 的守卫:

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 字段):

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 验证发现链路的第一跳:

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:

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 页与刷新令牌):

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 客户端):

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

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

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

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

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、返回审查意见
  });

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

整体工作流

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

延伸阅读

训练营适用性评估

参考来源

  1. MCP Authorization Specification (2025-06-18) — 官方授权规范,定义 OAuth 2.1、RFC 9728/8414/7591/8707 集成与发现流程
  2. MCP Transports Specification (Streamable HTTP) — Streamable HTTP 传输细节、会话管理与 DNS 重绑定安全警告
  3. MCP Security Best Practices — Confused Deputy 攻击分析与 per-client consent 缓解方案
  4. Protecting MCP Server with OAuth 2.1: A Practical Guide Using Go and Keycloak — Go+Keycloak 实现远程 MCP 鉴权的工程参考
  5. OAuth 2.1 + PKCE for MCP Gateways: The Complete Flow — 网关视角下的 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学通信知识星球海报-扫码加入