CodeGym /课程 /ChatGPT Apps /将 MCP Server 配置为受保护资源: .we...

将 MCP Server 配置为受保护资源: .well-known, Bearer, audience/scope

ChatGPT Apps
第 10 级 , 课程 3
可用

1. MCP Server 作为 Resource Server:我们究竟在配置什么

在上一讲中,我们配置了 Auth Server——发放令牌的组件。现在来处理这对组合的另一侧:作为 Resource Server 的 MCP 服务器,它负责接收并验证这些令牌。

从 OAuth 2.1 的视角看,你的 MCP 服务器就是一个 Resource Server。它保存“资源”(MCP 工具、用户数据),并接收在 Authorization: Bearer ... 头中带有 access token 的请求。 在执行某个 tool 之前,它必须确认令牌真实有效、未过期、由可信的授权服务器(Auth Server)签发,而且令牌确实是发给这个 MCP 服务器的,并且包含所需的权限(scope)。

需要区分两个层次:

  1. 传输层——处理 HTTP 头与令牌。在这里你需要:
    • 接收/解析 Authorization: Bearer
    • 在缺少/错误令牌时返回 401 Unauthorized,并携带 WWW-Authenticate: Bearer ...
    • 令牌校验通过后,构建用户上下文。
  2. MCP SDK 层,它完全不必了解 JWT。它只接收“已认证”的调用,并可在 handler 中使用 ctx.userIdctx.scopes 等。

类比:MCP SDK 是厨房里的厨师,而 OAuth 中间件是门口的保安。厨师不查证件,他只负责做菜。

作为本课程的例子,我们继续使用 GiftGenius:MCP 服务器运行在 http://localhost:3000,提供 list_my_gifts 工具;Auth Server(如 Keycloak 或自建 Mini AS)在 http://localhost:4000

2. .well-known/oauth-protected-resource:你 MCP 资源的“名片”

为什么资源需要 .well-known

当 ChatGPT(或 MCP Jam)第一次访问你的 MCP 服务器并收到 401 时,它需要明白两件事:

  • 去哪里获取令牌;
  • 该资源支持哪些权限(scopes)。

为了避免在客户端“硬编码”,会使用一个 discovery 端点:

GET /.well-known/oauth-protected-resource

该端点返回受保护资源的元数据(Protected Resource Metadata)JSON,遵循 RFC 9728。

GiftGenius 的示例:

{
  "resource": "http://localhost:3000",
  "authorization_servers": ["http://localhost:4000"],
  "scopes_supported": ["gifts:read", "gifts:write"],
  "bearer_methods_supported": ["header"]
}

OpenAI 的指南中展示了几乎相同的例子,只是换成了 HTTPS 和真实域名。

客户端(ChatGPT/Jam)读取该文档后会:

  • 知道令牌应当包含 audience http://localhost:3000
  • 知道应该与哪些 authorization_servers(issuer URL)交互;
  • 看到支持的 scopes 列表(便于生成同意界面与提示)。

元数据字段解析

主要字段概览:

字段 用途
resource
MCP 服务器的规范 HTTPS/HTTP 标识符;随后应与令牌中的 aud 匹配。
authorization_servers
你的授权服务器(Auth Server/issuer)URL 列表。客户端会去那里获取 OAuth/OIDC 元数据。
scopes_supported
受支持的 scopes 数组;供客户端实现更好的 UX 并发起正确的令牌请求。
bearer_methods_supported
令牌传递方式:通常为 ["header"],即 Authorization: Bearer ...

此外有时还会发布 resource_documentationjwks_uriintrospection_endpoint 等,但基础场景只需要前四个字段即可。

关键点: resource 必须与 Auth Server 放入令牌 aud 的值一致。若不一致——MCP 客户端(包括你自己)都会拒绝该令牌。

在 Next.js 16 中实现 .well-known

假设我们的 MCP 服务器部署在 Next.js 应用中(Apps SDK 后端,端口 3000)。最简单的方法是在 app/.well-known/oauth-protected-resource/route.ts 中创建一个 route handler:


// app/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const body = {
    resource: "http://localhost:3000",
    authorization_servers: ["http://localhost:4000"],
    scopes_supported: ["gifts:read", "gifts:write"],
    bearer_methods_supported: ["header"],
  };

  return NextResponse.json(body);
}

在生产环境中,resource 应该是你 MCP 服务器生产环境的 HTTPS URL(例如 https://mcp.giftgenius.com),并且必须与 IdP 签发的令牌中的 aud 相匹配。

3. WWW-Authenticate401:MCP 如何告知“需要令牌”

我们已经通过 .well-known/oauth-protected-resource 准备好了资源“名片”。接下来看看 MCP 服务器如何通过 401WWW-Authenticate 头提示客户端去拿这张“名片”。

基础场景:请求未携带令牌

设想 ChatGPT 第一次调用工具 list_my_gifts。网络请求大概如下:

GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000

没有令牌。MCP 服务器不应静默地返回 403 或某个 HTML 页面。按照 OAuth 的规范,受保护资源应返回 401 Unauthorized,并通过 WWW-Authenticate 头告诉客户端如何授权。

正确响应示例:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource", scope="gifts:read"
Content-Type: application/json

{"error":"unauthorized","error_description":"Missing or invalid access token"}

关键细节:

  • Bearer 表示我们需要 OAuth Bearer 令牌;
  • resource_metadata 指向 .well-known/oauth-protected-resource 的 URL;
  • scope 提示所需的最小权限(例如 gifts:read)。

MCP Jam 与 ChatGPT 能够读取该响应头。看到它后,它们会:

  1. 请求 .well-known/oauth-protected-resource
  2. 根据 authorization_servers 找到 Auth Server 及其 OpenID/OAuth 元数据;
  3. 发起 Authorization Code + PKCE 流程,打开登录页面,获取令牌。

因此,WWW-Authenticate 是一个“触发器”:没有它,客户端甚至不会知道这里启用了 OAuth。

用于 401 响应的中间件(Next.js)

我们写一个小工具函数,在所有受保护端点中使用。首先是构造响应的函数:

// lib/authResponses.ts
import { NextResponse } from "next/server";

export function unauthorized(scope?: string) {
  const wwwAuth = [
    `Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource"`,
    scope ? `scope="${scope}"` : null,
  ]
    .filter(Boolean)
    .join(", ");

  return new NextResponse(
    JSON.stringify({
      error: "unauthorized",
      error_description: "Missing or invalid access token",
    }),
    {
      status: 401,
      headers: {
        "WWW-Authenticate": wwwAuth,
        "Content-Type": "application/json",
      },
    }
  );
}

现在,任何路由(例如我们的 MCP 端点)都可以直接调用 return unauthorized("gifts:read"),客户端就会收到正确的 challenge。 函数 unauthorized() 返回 NextResponse(兼容标准 Response)。 在后续示例中,我们会有时将该对象作为异常抛出,并在 route handler 中专门捕获 Response,以免在每个路由中重复构造 401 响应的代码。

4. 接收并验证 Bearer 令牌

接下来是重点:如何接收并验证 Bearer 令牌。

在何处执行验证

你的 MCP 传输层多半实现为:

  • Next.js 的 route handler(app/mcp/route.ts),接收 POST 并委托给 MCP SDK;
  • Express/Fastify 服务器,监听 /mcp 并将 JSON 转交 MCP 处理器。

在这些实现中,正是 HTTP 层应该:

  1. 从请求头中获取 Authorization
  2. 缺失/错误时,通过我们的 unauthorized 返回 401
  3. 成功时——构造上下文对象(如 userIdscopesroles),并传入 MCP SDK(通过处理器参数/上下文)。

MCP SDK(例如 @modelcontextprotocol/sdk)可以完全不知道什么是 JWT。这是你的职责范围。

验证方式:JWT vs introspection

主要有两种风格:

  1. 使用授权服务器的 JWK 密钥,在本地验证 JWT 的签名与声明;
  2. 请求授权服务器的 /introspect,询问“该令牌是否仍然有效?有哪些 scopes?”。

在本课程中,我们假设授权服务器签发 JWT 并发布 jwks_uri;MCP 服务器在本地验证签名与声明(更快、更自治)。

TypeScript 辅助函数 verifyAccessToken

使用常见库 jose(ESM 友好)。我们需要一个大致如下的 helper:

// lib/verifyAccessToken.ts
import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("http://localhost:4000/.well-known/jwks.json")
);
const EXPECTED_ISS = "http://localhost:4000";
const EXPECTED_AUD = "http://localhost:3000";

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: EXPECTED_ISS,
    audience: EXPECTED_AUD,
  });

  return {
    sub: String(payload.sub),
    scopes: String(payload.scope || "").split(" ").filter(Boolean),
    raw: payload,
  };
}

在这个 helper 中我们:

  • 通过 jwks_uri 下载授权服务器的 JWK 密钥;
  • 验证签名与标准声明(issaud);
  • 提取 sub(用户 id)与 scope(空格分隔的字符串,因此做 split(" "))。

audience 必须与我们 .well-known/oauth-protected-resource 中的 resource 一致,以确保令牌确实是为我们的 MCP 服务器签发的。

Authorization 请求头提取并校验

现在再写一个小 helper,从请求头取出令牌并交给 verifyAccessToken

// lib/getUserFromRequest.ts
import type { NextRequest } from "next/server";
import { unauthorized } from "./authResponses";
import { verifyAccessToken } from "./verifyAccessToken";

export async function getUserFromRequest(req: NextRequest) {
  const auth = req.headers.get("authorization") || "";
  const [, token] = auth.split(" ");

  if (!token) throw unauthorized("gifts:read");

  try {
    return await verifyAccessToken(token);
  } catch {
    throw unauthorized("gifts:read");
  }
}

请注意:这里我们将 unauthorized(...)(即 Response 对象)作为异常抛出,这样在 route handler 中就能简洁地捕获并直接返回它。

5. audience 与 scope:将令牌绑定到资源与动作

Audience(aud):令牌“发给谁”

声明 aud 用于回答:该令牌是否发给了这个资源。就我们的例子而言:

  • 授权服务器在令牌中设置 audhttp://localhost:3000
  • 我们的 .well-known/oauth-protected-resource 发布 resource: "http://localhost:3000"
  • verifyAccessToken 会验证这一点。

若令牌面向其他资源(例如 https://api.other-app.com),你的 MCP 服务器必须将其拒绝,因为“并非发给我”。

常见错误是忘记同步 resourceaud,导致看起来都配置好了,但 ChatGPT 不断收到 401。我们会在“常见错误”一节再次提到。

Scopes:可以执行“哪些操作”

令牌中的 scope 声明是用户授予客户端的一组权限。在我们的示例中:

  • gifts:read——读取自己的礼物;
  • gifts:write——创建/更新礼物。

这些值会出现在 .well-known/oauth-protected-resourcescopes_supported 中,以便客户端提前知道可以请求哪些权限。

授权服务器的 discovery 文档(.well-known/openid-configuration)也会发布 scopes_supported,但那是 IdP 的全局 scopes 列表。(请不要与资源服务器的 .well-known/oauth-protected-resource 混淆)

不要混淆这两个列表:资源的 scopes_supported 描述的是你的 MCP 服务器所需的权限,而 IdP 的 scopes_supported 是提供方的“全量目录”。客户端通常会取二者的交集。

在 MCP 服务器层面,你需要:

  • 为每个工具决定所需的 scopes;
  • 在每次调用工具时检查令牌是否包含这些 scopes。

编写一个 helper:

// lib/requireScope.ts
import { unauthorized } from "./authResponses";

export function requireScope(
  user: { scopes: string[] },
  needed: string[]
) {
  const hasAll = needed.every((s) => user.scopes.includes(s));
  if (!hasAll) throw unauthorized(needed.join(" "));
}

现在可以在执行工具前调用 requireScope(user, ["gifts:read"])

6. 与 MCP 工具的对接:从令牌到 list_my_gifts

Next.js 中的 MCP 路由

假设我们使用某个 SDK 搭建了 MCP 服务器,它能处理 HTTP 请求。从 Next.js 的视角可能如下:

// app/api/mcp/route.ts
import { NextRequest } from "next/server";
import { unauthorized } from "@/lib/authResponses";
import { getUserFromRequest } from "@/lib/getUserFromRequest";
import { mcpServer } from "@/lib/mcpServer";

export async function POST(req: NextRequest) {
  try {
    const user = await getUserFromRequest(req);

    const body = await req.json();
    const result = await mcpServer.handle(body, { user });

    return Response.json(result);
  } catch (err) {
    if (err instanceof Response) return err; // unauthorized(...)
    console.error(err);
    return unauthorized();
  }
}

这里的要点:

  • 我们从令牌中提取用户与 scopes(getUserFromRequest);
  • 通过上下文 { user } 将它们传给 MCP 服务器;
  • 缺少/错误令牌时,返回带有 WWW-Authenticate401

不同的 MCP SDK 可能有不同的 API,但核心思想一致:用一个了解“来者何人”的中间件包住 MCP 的调用。

带 scope 校验的 list_my_gifts 工具

再看看工具本身的实现。假设我们使用 TypeScript SDK for MCP,大致如下:

// lib/mcpServer.ts (fragment)
import { createMcpServer } from "@modelcontextprotocol/sdk";
import { requireScope } from "./requireScope";

export const mcpServer = createMcpServer<{ user: any }>();

mcpServer.registerTool(
  "list_my_gifts",
  {
    title: "List my gifts",
    description: "Shows your saved gift ideas.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false },
  },
  async (_input, ctx) => {
    requireScope(ctx.user, ["gifts:read"]);

    const gifts = await loadGiftsForUser(ctx.user.sub);
    return {
      content: [{ type: "text", text: `Found ${gifts.length} gifts` }],
      structuredContent: { gifts },
    };
  }
);

我们做了三件关键的事:

  • 在执行主逻辑前要求 gifts:read
  • 使用 ctx.user.sub 作为用户标识(来自令牌);
  • 仅返回该用户的数据。

这样,你的工具就不再是“公共 API”,而是个性化的——绑定到来自 Auth Server 的身份。

7. 流程回顾:从 401 到成功调用

为了固定整个流程,我们把受保护的 MCP 服务器所实现的流转简要梳理一下。

sequenceDiagram
    participant ChatGPT
    participant MCP as MCP Server (3000)
    participant AS as Auth Server (4000)

    ChatGPT->>MCP: POST /api/mcp (no Authorization)
    MCP-->>ChatGPT: 401 + WWW-Authenticate: Bearer resource_metadata=...

    ChatGPT->>MCP: GET /.well-known/oauth-protected-resource
    MCP-->>ChatGPT: { resource, authorization_servers, scopes_supported }

    ChatGPT->>AS: GET /authorize?scope=gifts:read&resource=...
    AS-->>ChatGPT: redirect with ?code=XYZ

    ChatGPT->>AS: POST /token (code + code_verifier)
    AS-->>ChatGPT: { access_token, scope, ... }

    ChatGPT->>MCP: POST /api/mcp Authorization: Bearer token
    MCP->>MCP: verify JWT (iss, aud, exp, scope)
    MCP-->>ChatGPT: tool result for this user

注意授权请求中的 resource 参数:它会被复制到令牌的 aud,并且必须与 .well-known/oauth-protected-resource 中的 resource 一致。

8. 用 curl 做一个小型实测

为了自检,可以手动发两个请求。

第一个——不带令牌调用 MCP:

curl -i http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'

预期能看到状态码 401,以及带有 resource_metadatascope="gifts:read"WWW-Authenticate

第二个——使用从 Auth Server 获得的有效令牌:

curl -i http://localhost:3000/api/mcp \
  -H "Authorization: Bearer abc123" \
  -H "Content-Type: application/json" \
  -d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'

此时,如果 abc123 是一个有效的 JWT,且包含正确的 issaud="http://localhost:3000",并且 scope 含有 gifts:read,你将得到工具的 JSON 响应;structuredContent.gifts 中会包含当前用户的礼物。

9. 将 MCP Server 配置为受保护资源时的常见错误

下面是最容易在我们刚刚实现的这些部分中踩到的坑:.well-knownWWW-Authenticate、令牌验证与 scopes 校验。

错误 1:resourceaudience 未同步。
常见场景是:在 .well-known/oauth-protected-resource 中写了一个 resource 值,而授权服务器签发的令牌里用的是另一个 aud。结果即使签名和过期时间都没问题,jwtVerify 也会丢弃令牌。尤其当你更换 MCP 服务器的域名/端口时,容易忘记同时更新 .well-known 或 Auth Server 配置。在我们的示例中,这两处都是同一字符串 http://localhost:3000:一个在 .well-knownresource 字段,另一个在 verifyAccessToken 内部的 EXPECTED_AUD。建议定义一个常量 RESOURCE_ID,并在两处复用,以免不一致。

错误 2:返回 401 时缺少 WWW-Authenticate
有些开发者只返回 401403,却没有 WWW-Authenticate 头。对浏览器也许没问题,但 ChatGPT 与 MCP Jam 不会知道去哪儿拿令牌、需要哪些 scopes。它们会把你的 MCP 服务器视为“不可用”,也不会给用户展示链接 UI。至少需要 WWW-Authenticate: Bearer 并带上 resource_metadata=".../.well-known/oauth-protected-resource"。更好的是再加上 scope="...",使流程更透明。我们的 unauthorized() helper 就保证了在 401 时一定包含该响应头。

错误 3:在未验证签名与 iss 的情况下信任令牌。
尤其在早期阶段,可能会有这样的诱惑:“这是我的 Auth Server 发的 token,直接 JSON.parse(atob(..)) 就行。”这种做法是不可取的:你等于接受了任何格式正确(甚至伪造)的令牌。正确做法是通过 jwks_uri 加载密钥,并使用库(josejsonwebtoken 等)验证签名以及 iss/aud。只有通过这些验证后,才能信任声明内容。

错误 4:将令牌验证与业务逻辑混在一起。
有时令牌校验被分散在各个工具代码中:这个工具检查 scope,那个不检查;某处忘了查 aud;甚至在某些地方直接从 tool 参数接收用户 id。这会导致奇怪的 bug,甚至存在安全隐患。更好的做法是明确分层:HTTP 中间件负责令牌(签名、issaud、过期时间),工具内部基于 ctx.user 作为“事实”,只补充业务检查(如角色/租户)。

错误 5:scopes_supported 与实际使用的 scopes 不一致。
另一个常见问题:你在 .well-known/oauth-protected-resource 发布了一套 scopes,在 Auth Server 配置了另一套,而工具中又检查了第三套。ChatGPT/MCP Jam 会根据发布的 scopes_supported 生成授权请求,而你的服务器随后又抱怨缺少必须的 scope。尽量精简 scopes,并将其作为“单一事实来源”管理——例如在 TypeScript 中用一个 enum,同时用于生成 .well-known 与配置 Auth Server 的客户端。

错误 6:只依赖 Apps SDK 的 securitySchemes,却忘了在服务器端做验证。
Apps SDK 可以为工具声明 securitySchemesnoauthoauth2、scopes),ChatGPT 会据此向用户展示正确的 UX。但这些注解并不会自动让你的服务器变安全。即便 tool 标注为需要 OAuth 令牌,你的 MCP 服务器仍必须在每个请求中验证令牌、issuer、audience 与 scopes。否则,直接向 MCP 的 URL 发送请求就可能绕过检查。

错误 7:只关注令牌时长,却没有处理过期。
如果 access token 的寿命太长,会降低安全性;如果太短而服务器又不善于处理过期,用户会频繁碰到错误。更合理的模式是:短时有效的 access token,加上当 exp 已过期时,MCP 服务器能返回带 WWW-Authenticate401。客户端(ChatGPT)随后会重新执行 OAuth 流程并刷新令牌。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION