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)。
需要区分两个层次:
- 传输层——处理 HTTP 头与令牌。在这里你需要:
- 接收/解析 Authorization: Bearer;
- 在缺少/错误令牌时返回 401 Unauthorized,并携带 WWW-Authenticate: Bearer ...;
- 令牌校验通过后,构建用户上下文。
- MCP SDK 层,它完全不必了解 JWT。它只接收“已认证”的调用,并可在 handler 中使用 ctx.userId、ctx.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 列表(便于生成同意界面与提示)。
元数据字段解析
主要字段概览:
| 字段 | 用途 |
|---|---|
|
MCP 服务器的规范 HTTPS/HTTP 标识符;随后应与令牌中的 aud 匹配。 |
|
你的授权服务器(Auth Server/issuer)URL 列表。客户端会去那里获取 OAuth/OIDC 元数据。 |
|
受支持的 scopes 数组;供客户端实现更好的 UX 并发起正确的令牌请求。 |
|
令牌传递方式:通常为 ["header"],即 Authorization: Bearer ...。 |
此外有时还会发布 resource_documentation、jwks_uri、introspection_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-Authenticate 与 401:MCP 如何告知“需要令牌”
我们已经通过 .well-known/oauth-protected-resource 准备好了资源“名片”。接下来看看 MCP 服务器如何通过 401 与 WWW-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 能够读取该响应头。看到它后,它们会:
- 请求 .well-known/oauth-protected-resource;
- 根据 authorization_servers 找到 Auth Server 及其 OpenID/OAuth 元数据;
- 发起 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 层应该:
- 从请求头中获取 Authorization;
- 缺失/错误时,通过我们的 unauthorized 返回 401;
- 成功时——构造上下文对象(如 userId、scopes、roles),并传入 MCP SDK(通过处理器参数/上下文)。
MCP SDK(例如 @modelcontextprotocol/sdk)可以完全不知道什么是 JWT。这是你的职责范围。
验证方式:JWT vs introspection
主要有两种风格:
- 使用授权服务器的 JWK 密钥,在本地验证 JWT 的签名与声明;
- 请求授权服务器的 /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 密钥;
- 验证签名与标准声明(iss、aud);
- 提取 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 用于回答:该令牌是否发给了这个资源。就我们的例子而言:
- 授权服务器在令牌中设置 aud 为 http://localhost:3000;
- 我们的 .well-known/oauth-protected-resource 发布 resource: "http://localhost:3000";
- verifyAccessToken 会验证这一点。
若令牌面向其他资源(例如 https://api.other-app.com),你的 MCP 服务器必须将其拒绝,因为“并非发给我”。
常见错误是忘记同步 resource 与 aud,导致看起来都配置好了,但 ChatGPT 不断收到 401。我们会在“常见错误”一节再次提到。
Scopes:可以执行“哪些操作”
令牌中的 scope 声明是用户授予客户端的一组权限。在我们的示例中:
- gifts:read——读取自己的礼物;
- gifts:write——创建/更新礼物。
这些值会出现在 .well-known/oauth-protected-resource 的 scopes_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-Authenticate 的 401。
不同的 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_metadata 与 scope="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,且包含正确的 iss、aud="http://localhost:3000",并且 scope 含有 gifts:read,你将得到工具的 JSON 响应;structuredContent.gifts 中会包含当前用户的礼物。
9. 将 MCP Server 配置为受保护资源时的常见错误
下面是最容易在我们刚刚实现的这些部分中踩到的坑:.well-known、WWW-Authenticate、令牌验证与 scopes 校验。
错误 1:resource 与 audience 未同步。
常见场景是:在 .well-known/oauth-protected-resource 中写了一个 resource 值,而授权服务器签发的令牌里用的是另一个 aud。结果即使签名和过期时间都没问题,jwtVerify 也会丢弃令牌。尤其当你更换 MCP 服务器的域名/端口时,容易忘记同时更新 .well-known 或 Auth Server 配置。在我们的示例中,这两处都是同一字符串 http://localhost:3000:一个在 .well-known 的 resource 字段,另一个在 verifyAccessToken 内部的 EXPECTED_AUD。建议定义一个常量 RESOURCE_ID,并在两处复用,以免不一致。
错误 2:返回 401 时缺少 WWW-Authenticate。
有些开发者只返回 401 或 403,却没有 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 加载密钥,并使用库(jose、jsonwebtoken 等)验证签名以及 iss/aud。只有通过这些验证后,才能信任声明内容。
错误 4:将令牌验证与业务逻辑混在一起。
有时令牌校验被分散在各个工具代码中:这个工具检查 scope,那个不检查;某处忘了查 aud;甚至在某些地方直接从 tool 参数接收用户 id。这会导致奇怪的 bug,甚至存在安全隐患。更好的做法是明确分层:HTTP 中间件负责令牌(签名、iss、aud、过期时间),工具内部基于 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 可以为工具声明 securitySchemes(noauth、oauth2、scopes),ChatGPT 会据此向用户展示正确的 UX。但这些注解并不会自动让你的服务器变安全。即便 tool 标注为需要 OAuth 令牌,你的 MCP 服务器仍必须在每个请求中验证令牌、issuer、audience 与 scopes。否则,直接向 MCP 的 URL 发送请求就可能绕过检查。
错误 7:只关注令牌时长,却没有处理过期。
如果 access token 的寿命太长,会降低安全性;如果太短而服务器又不善于处理过期,用户会频繁碰到错误。更合理的模式是:短时有效的 access token,加上当 exp 已过期时,MCP 服务器能返回带 WWW-Authenticate 的 401。客户端(ChatGPT)随后会重新执行 OAuth 流程并刷新令牌。
GO TO FULL VERSION