1. MCP Server 作為 Resource Server:我們究竟要設定什麼
在上一講我們設定了 Auth Server — 發出權杖的元件。現在處理這個組合的另一端:作為 Resource Server 的 MCP 伺服器,它要接收並驗證這些權杖。
就 OAuth 2.1 的觀點,你的 MCP 伺服器是 Resource Server。它保存「資源」(MCP 工具、使用者資料),並接收在標頭中帶有 access token 的請求 Authorization: Bearer ...。 在執行 tool 之前,它必須檢查權杖是否真實、未過期、由受信任的授權伺服器(Auth Server)簽發,且確實是發給這個 MCP 伺服器,並具備所需的權限(scope)。
重要的是分清兩個層級:
- 傳輸層 — 這裡處理 HTTP 標頭與權杖。在此你會:
- 接收/解析 Authorization: Bearer,
- 當權杖缺失/錯誤時,回傳 401 Unauthorized,並附上 WWW-Authenticate: Bearer ...,
- 在權杖有效時組成使用者的 context。
- MCP SDK 層,完全不需要知道 JWT。它只會收到「已驗證」的呼叫,並可在 handler 內使用 ctx.userId、ctx.scopes 等等。
類比:MCP SDK 是廚房的廚師,而 OAuth 的 middleware 是門口的保全。廚師不查驗護照,他只負責做菜。
作為我們的教學示例,延續 GiftGenius:MCP 伺服器位於 http://localhost:3000,有工具 list_my_gifts;Auth Server(例如 Keycloak 或自製的迷你 AS)位於 http://localhost:4000。
2. .well-known/oauth-protected-resource:你的 MCP 資源的名片
為什麼資源需要 .well-known
當 ChatGPT(或 MCP Jam)第一次打到你的 MCP 伺服器並收到 401 時,它需要搞清兩件事:
- 去哪裡取得權杖;
- 這個資源支援哪些權限。
為了不在客戶端「硬編寫」,會使用 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 backend,埠號 3000)。最簡單的方式是建立一個 route handler 於 app/.well-known/oauth-protected-resource/route.ts:
// 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 回應的 middleware(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 endpoint)都能直接寫 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 handler。
在這些做法中,就是 HTTP 層應該:
- 從標頭取出 Authorization;
- 若缺失/錯誤則回傳 401(用我們的 unauthorized);
- 成功時 — 建立 context 物件(userId、scopes、roles),並傳給 MCP SDK(透過 handler 參數/上下文)。
MCP SDK(例如 @modelcontextprotocol/sdk)本身可以完全不知道 JWT。這部分是你的責任範圍。
驗證方式:JWT vs introspection
主要有兩種風格:
- 在本機使用授權伺服器的 JWK 金鑰驗證 JWT 權杖的簽章與宣告(claims)。
- 呼叫授權伺服器的 /introspect,詢問:「這個權杖還有效嗎?它有哪些 scopes?」。
本課程中我們假設 Auth Server 簽發 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 下載 Auth Server 的 JWK;
- 檢查簽章與標準宣告(iss、aud);
- 取出 sub(user 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 回答的是:權杖是否發給此資源。在我們的例子中:
- Auth Server 會把權杖中的 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 伺服器的 context;
- 當權杖缺失/錯誤時回傳我們的 401 與 WWW-Authenticate。
MCP SDK 的具體 API 可能不同,但核心思路一致:用 middleware 包住 MCP 的呼叫,讓它知道「是誰」在呼叫。
帶 scope 檢查的 list_my_gifts 工具
現在看看工具本身的實作。假設我們使用 MCP 的 TypeScript SDK,會像這樣:
// lib/mcpServer.ts (片段)
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
請注意對 Auth Server 請求中的 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,以及我們的 WWW-Authenticate,其中包含 resource_metadata 與 scope="gifts:read"。
第二個 — 夾帶從 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,但 Auth Server 在權杖裡發的是另一個 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="...",讓流程更清楚。我們的 helper unauthorized() 就能保證在 401 時一定帶上這個標頭。
錯誤 #3:信任權杖但不驗簽章與 iss。
有時,特別在早期階段,很容易心想:「這是我的 Auth Server 發的權杖,直接 JSON.parse(atob(..)) 就好」。千萬別這麼做:你等於接受任何格式正確的權杖,連偽造的也行。正確做法是透過 jwks_uri 載入金鑰,並用函式庫(jose、jsonwebtoken 等)驗證簽章與 iss/aud。只有這樣才能信任宣告內容。
錯誤 #4:把權杖檢查與商業邏輯混在一起。
有時權杖檢查分散在工具程式碼中:某個工具檢查 scope,另一個沒有;某處忘了檢查 aud;甚至從 tool 的參數接收使用者 id。這會導致怪異的 bug 與潛在弱點。最好清楚分工:HTTP 層的 middleware 處理權杖(簽章、iss、aud、有效期),而在工具程式中你把 ctx.user 視為「可信事實」,只補上商業判斷(例如角色/tenant)。
錯誤 #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。但這些註記不會自動把你的伺服器變安全。即使工具宣告需要 OAuth 權杖,你的 MCP 伺服器仍必須在每個請求上驗證權杖、issuer、audience 與 scopes。否則別人可以直接打 MCP 的 URL 來繞過檢查。
錯誤 #7:只依賴長壽命權杖,或沒處理權杖到期。
若 access token 太長壽,安全性會降低;若太短但伺服器不會妥善處理到期,使用者會頻頻遇錯。正確模型是:短生命週期的 access token,加上當 exp 已過期時,MCP 伺服器能回 401 並附上 WWW-Authenticate。客戶端(ChatGPT)會據此重走 OAuth 流程並更新權杖。
GO TO FULL VERSION