CodeGym /課程 /ChatGPT Apps /將 MCP Server 設定為受保護的資源: .w...

將 MCP Server 設定為受保護的資源: .well-knownBearer、audience/scope

ChatGPT Apps
等級 10 , 課堂 3
開放

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)。

重要的是分清兩個層級:

  1. 傳輸層 — 這裡處理 HTTP 標頭與權杖。在此你會:
    • 接收/解析 Authorization: Bearer
    • 當權杖缺失/錯誤時,回傳 401 Unauthorized,並附上 WWW-Authenticate: Bearer ...
    • 在權杖有效時組成使用者的 context。
  2. MCP SDK 層,完全不需要知道 JWT。它只會收到「已驗證」的呼叫,並可在 handler 內使用 ctx.userIdctx.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 清單(更易形成同意畫面與提示)。

中繼資料欄位解析

重點欄位摘要:

欄位 用途
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 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-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 回應的 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 層應該:

  1. 從標頭取出 Authorization
  2. 若缺失/錯誤則回傳 401(用我們的 unauthorized);
  3. 成功時 — 建立 context 物件(userIdscopesroles),並傳給 MCP SDK(透過 handler 參數/上下文)。

MCP SDK(例如 @modelcontextprotocol/sdk)本身可以完全不知道 JWT。這部分是你的責任範圍。

驗證方式:JWT vs introspection

主要有兩種風格:

  1. 在本機使用授權伺服器的 JWK 金鑰驗證 JWT 權杖的簽章與宣告(claims)。
  2. 呼叫授權伺服器的 /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;
  • 檢查簽章與標準宣告(issaud);
  • 取出 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 伺服器必須拒絕它,因為「不是給我用的」。

常見錯誤是忘了同步 resourceaud,於是看似都設定好了,但 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;
  • 當權杖缺失/錯誤時回傳我們的 401WWW-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_metadatascope="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,且 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,但 Auth Server 在權杖裡發的是另一個 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="...",讓流程更清楚。我們的 helper unauthorized() 就能保證在 401 時一定帶上這個標頭。

錯誤 #3:信任權杖但不驗簽章與 iss
有時,特別在早期階段,很容易心想:「這是我的 Auth Server 發的權杖,直接 JSON.parse(atob(..)) 就好」。千萬別這麼做:你等於接受任何格式正確的權杖,連偽造的也行。正確做法是透過 jwks_uri 載入金鑰,並用函式庫(josejsonwebtoken 等)驗證簽章與 iss/aud。只有這樣才能信任宣告內容。

錯誤 #4:把權杖檢查與商業邏輯混在一起。
有時權杖檢查分散在工具程式碼中:某個工具檢查 scope,另一個沒有;某處忘了檢查 aud;甚至從 tool 的參數接收使用者 id。這會導致怪異的 bug 與潛在弱點。最好清楚分工:HTTP 層的 middleware 處理權杖(簽章、issaud、有效期),而在工具程式中你把 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 可為工具描述 securitySchemesnoauthoauth2、scopes),ChatGPT 也會呈現正確的 UX。但這些註記不會自動把你的伺服器變安全。即使工具宣告需要 OAuth 權杖,你的 MCP 伺服器仍必須在每個請求上驗證權杖、issuer、audience 與 scopes。否則別人可以直接打 MCP 的 URL 來繞過檢查。

錯誤 #7:只依賴長壽命權杖,或沒處理權杖到期。
若 access token 太長壽,安全性會降低;若太短但伺服器不會妥善處理到期,使用者會頻頻遇錯。正確模型是:短生命週期的 access token,加上當 exp 已過期時,MCP 伺服器能回 401 並附上 WWW-Authenticate。客戶端(ChatGPT)會據此重走 OAuth 流程並更新權杖。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION