CodeGym /Các khóa học /ChatGPT Apps /Cấu hình MCP Server như một tài nguyên được bảo vệ:

Cấu hình MCP Server như một tài nguyên được bảo vệ: .well-known, Bearer, audience/scope

ChatGPT Apps
Mức độ , Bài học
Có sẵn

1. MCP Server như Resource Server: chúng ta cấu hình điều gì

Ở bài trước, ta cấu hình Auth Server — thành phần phát hành token. Giờ chuyển sang phía còn lại của cặp đôi này: MCP‑server như một Resource Server, nơi tiếp nhận và kiểm tra các token.

Về mặt OAuth 2.1, MCP‑server của bạn chính là Resource Server. Nó lưu trữ “tài nguyên” (các công cụ MCP, dữ liệu người dùng) và nhận yêu cầu kèm access token trong header Authorization: Bearer .... Trước khi chạy công cụ, nó phải kiểm tra token là thật, chưa hết hạn, do máy chủ ủy quyền đáng tin (Auth Server) cấp và được phát hành cho đúng MCP‑server này, đồng thời có đủ quyền (scope).

Quan trọng là tách bạch hai tầng:

  1. Tầng vận chuyển — nơi xử lý HTTP headers và token. Ở đây bạn:
    • nhận/phân tích Authorization: Bearer,
    • khi thiếu/sai token thì trả về 401 Unauthorized kèm WWW-Authenticate: Bearer ...,
    • khi token hợp lệ thì tạo ngữ cảnh người dùng.
  2. Tầng MCP SDK, vốn không cần biết JWT là gì. Nó chỉ nhận một lời gọi “đã xác thực” và trong handler có thể dùng ctx.userId, ctx.scopes, v.v.

Ví von: MCP SDK là đầu bếp trong bếp, còn OAuth middleware là bảo vệ ở cửa. Đầu bếp không kiểm tra hộ chiếu, chỉ nấu món được gọi.

Làm ví dụ học tập, ta tiếp tục với GiftGenius: MCP‑server tại http://localhost:3000 có công cụ list_my_gifts, và Auth Server (ví dụ Keycloak hoặc một mini AS tự dựng) tại http://localhost:4000.

2. .well-known/oauth-protected-resource: “danh thiếp” của tài nguyên MCP

Tại sao tài nguyên cần .well-known

Khi ChatGPT (hoặc MCP Jam) lần đầu gọi đến MCP‑server của bạn và nhận 401, nó cần hiểu hai điều:

  • đi đâu để lấy token;
  • tài nguyên này hỗ trợ những quyền nào.

Để không phải “hardcode” các thông tin đó trong client, dùng endpoint discovery:

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

Endpoint này trả về JSON chứa metadata của tài nguyên được bảo vệ (Protected Resource Metadata) theo RFC 9728.

Ví dụ từ GiftGenius:

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

OpenAI trong các hướng dẫn của mình cũng đưa ví dụ gần như vậy, chỉ khác ở HTTPS và domain thực tế.

Client (ChatGPT/Jam) đọc tài liệu này và:

  • hiểu rằng token phải có audience http://localhost:3000,
  • biết sẽ làm việc với những authorization_servers nào (issuer URL),
  • thấy danh sách scope hỗ trợ (giúp tạo màn hình đồng ý và gợi ý dễ hơn).

Phân tích các trường metadata

Tóm tắt các trường chính:

Trường Mục đích
resource
Định danh HTTPS/HTTP chuẩn của MCP‑server. Sau đó phải khớp với aud trong token.
authorization_servers
Danh sách URL máy chủ ủy quyền của bạn (Auth Server/issuer). Client sẽ đến đó để lấy metadata OAuth/OIDC.
scopes_supported
Mảng các scope được hỗ trợ; cần cho client để có UX đẹp và yêu cầu token chính xác.
bearer_methods_supported
Cách truyền token: thường là ["header"], tức Authorization: Bearer ....

Ngoài ra đôi khi người ta công bố thêm resource_documentation, jwks_uri, introspection_endpoint, v.v., nhưng với kịch bản cơ bản, bốn trường đầu đã đủ.

Điểm then chốt: resource phải trùng với giá trị mà Auth Server đặt trong aud của token. Nếu không trùng — MCP client (và chính bạn) sẽ từ chối token.

Triển khai .well-known trong Next.js 16

Giả sử MCP‑server của bạn chạy trong ứng dụng Next.js (Apps SDK backend, cổng 3000). Cách đơn giản nhất — tạo 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);
}

Trong production, resource phải là HTTPS URL của môi trường prod cho MCP‑server của bạn (ví dụ https://mcp.giftgenius.com), và nó phải trùng với aud trong các token do IdP phát hành.

3. WWW-Authenticate401: cách MCP thông báo “cần token”

Ta đã làm “danh thiếp” tài nguyên ở .well-known/oauth-protected-resource. Giờ xem MCP‑server gợi ý client đi lấy danh thiếp đó như thế nào — thông qua 401 và header WWW-Authenticate.

Kịch bản cơ bản: gọi đến mà không có token

Giả sử ChatGPT lần đầu gọi công cụ list_my_gifts. Yêu cầu mạng trông như sau:

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

Không có token. MCP‑server không nên im lặng trả 403 hoặc trả về một trang HTML nào đó. Hành vi đúng của một tài nguyên bảo vệ trong thế giới OAuth — trả về 401 Unauthorized và qua header WWW-Authenticate giải thích cho client cách để ủy quyền.

Ví dụ phản hồi đúng:

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"}

Các chi tiết quan trọng:

  • scheme Bearer cho biết ta muốn OAuth Bearer token;
  • tham số resource_metadata chỉ URL đến .well-known/oauth-protected-resource;
  • tham số scope gợi ý quyền tối thiểu cần có (ví dụ gifts:read).

MCP Jam và ChatGPT có thể đọc header này. Thấy nó, chúng sẽ:

  1. Gọi .well-known/oauth-protected-resource.
  2. Từ authorization_servers tìm Auth Server và metadata OpenID/OAuth của nó.
  3. Khởi chạy luồng Authorization Code + PKCE, mở trang đăng nhập cho người dùng và lấy token.

Tức WWW-Authenticate là tín hiệu kích hoạt: nếu không có nó, client thậm chí không biết ở đây có OAuth.

Middleware cho phản hồi 401 (Next.js)

Viết một tiện ích nhỏ dùng cho mọi endpoint được bảo vệ. Trước tiên — hàm tạo phản hồi:

// 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",
      },
    }
  );
}

Giờ bất kỳ route nào (ví dụ endpoint MCP của ta) có thể đơn giản gọi return unauthorized("gifts:read"), và client sẽ nhận đúng challenge. Hàm unauthorized() trả về đối tượng NextResponse (tương thích với Response tiêu chuẩn). Ở các ví dụ sau, đôi khi ta sẽ ném đối tượng này như một ngoại lệ và trong route handler bắt chính Response, để khỏi lặp lại mã tạo phản hồi 401 ở mỗi route.

4. Tiếp nhận và kiểm tra Bearer token

Giờ đến phần thú vị nhất: cách nhận và kiểm tra Bearer token.

Kiểm tra ở đâu

Tầng vận chuyển của MCP có thể được bạn triển khai theo một trong hai cách:

  • trong Next.js route handler (app/mcp/route.ts) nhận POST rồi ủy thác cho MCP SDK;
  • trong máy chủ Express/Fastify, lắng nghe /mcp và chuyển JSON vào MCP handler.

Trong mọi phương án, chính tầng HTTP phải:

  1. lấy Authorization từ header;
  2. khi thiếu/sai thì trả 401 qua unauthorized của ta;
  3. khi thành công — tạo đối tượng ngữ cảnh (userId, scopes, roles) và truyền vào MCP SDK (qua tham số handler/ngữ cảnh).

Bản thân MCP SDK (ví dụ @modelcontextprotocol/sdk) có thể không hề biết JWT là gì. Đây là trách nhiệm của bạn.

Các cách kiểm tra: JWT vs introspection

Có hai phong cách chính:

  1. Kiểm tra chữ ký và các claim của JWT cục bộ, dùng JWK của Auth Server.
  2. Gọi đến /introspect của máy chủ ủy quyền để hỏi: “Token này còn hiệu lực không? Nó có các scope nào?”.

Trong khóa học này ta giả định Auth Server phát hành JWT và công bố jwks_uri, còn MCP‑server kiểm tra chữ ký và claim cục bộ (nhanh và tự chủ hơn).

Tiện ích verifyAccessToken bằng TypeScript

Dùng thư viện phổ biến jose (thân thiện ESM). Ta cần một helper cỡ như sau:

// 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,
  };
}

Trong helper này, ta:

  • tải JWK của Auth Server qua jwks_uri;
  • kiểm tra chữ ký và các claim chuẩn (iss, aud);
  • lấy sub (user id) và scope (chuỗi cách nhau bằng khoảng trắng, nên ta split(" ")).

audience phải trùng với resource từ .well-known/oauth-protected-resource, đảm bảo token được phát cho đúng MCP‑server của ta.

Kiểm tra đơn giản header Authorization

Tiếp theo tạo một helper nhỏ, lấy token từ header và chạy qua 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");
  }
}

Lưu ý: ở đây ta ném unauthorized(...) (tức đối tượng Response) như một ngoại lệ, để trong route handler có thể gọn gàng bắt và trả về như phản hồi.

5. audience và scope: gắn token với tài nguyên và hành động

Audience (aud): “token được phát cho ai”

Claim aud trả lời câu hỏi: token này dành cho tài nguyên nào. Với trường hợp của ta:

  • aud trong token được Auth Server đặt là http://localhost:3000;
  • .well-known/oauth-protected-resource của ta công bố resource: "http://localhost:3000";
  • verifyAccessToken kiểm tra điều đó.

Nếu token được phát cho tài nguyên khác (ví dụ https://api.other-app.com), MCP‑server của bạn phải từ chối vì “không phải phát cho tôi”.

Lỗi hay gặp — quên đồng bộ resourceaud, dẫn đến mọi thứ có vẻ đúng cấu hình, nhưng ChatGPT cứ nhận 401. Ta sẽ quay lại điều này trong phần “Lỗi thường gặp”.

Scopes: “được phép làm gì”

Claim scope trong token — là danh sách quyền mà người dùng cấp cho client. Trong ví dụ:

  • gifts:read — quyền đọc quà tặng của mình;
  • gifts:write — quyền tạo/cập nhật quà tặng.

Trong .well-known/oauth-protected-resource các giá trị này xuất hiện ở scopes_supported, để client biết trước mình có thể yêu cầu gì.

Máy chủ ủy quyền trong tài liệu discovery của nó (.well-known/openid-configuration) cũng công bố scopes_supported, nhưng đó là danh sách scope toàn cục của IdP. (đừng nhầm với .well-known/oauth-protected-resource của resource server)

Quan trọng là không nhầm hai danh sách: scopes_supported của tài nguyên mô tả các quyền cần cho MCP‑server của bạn, còn scopes_supported của IdP — “catalog” scope toàn cục của nhà cung cấp. Client thường sẽ lấy giao của hai tập này.

Ở phía MCP‑server bạn cần:

  • quyết định cho mỗi công cụ cần scope nào;
  • mỗi lần gọi công cụ phải kiểm tra token có chứa các scope đó.

Viết 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(" "));
}

Giờ có thể gọi requireScope(user, ["gifts:read"]) trước khi thực thi công cụ.

6. Ghép nối với công cụ MCP: từ token đến list_my_gifts

Route MCP trong Next.js

Giả sử ta có một MCP‑server dựa trên một SDK nào đó, có thể xử lý HTTP request. Với Next.js, trông có thể như sau:

// 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();
  }
}

Điểm quan trọng:

  • ta trích người dùng và scope từ token (getUserFromRequest);
  • truyền chúng vào MCP‑server qua ngữ cảnh { user };
  • khi thiếu/sai token thì trả về 401 với WWW-Authenticate của ta.

API cụ thể của MCP SDK có thể khác nhau, nhưng ý tưởng chung là như nhau: bọc lời gọi MCP bằng middleware hiểu “ai” đang gọi.

Công cụ list_my_gifts với kiểm tra scope

Giờ xem triển khai chính công cụ. Giả sử ta dùng TypeScript SDK cho MCP, và có gì đó như:

// 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 },
    };
  }
);

Ta làm ba bước chính:

  • yêu cầu gifts:read trước khi chạy mã chính;
  • dùng ctx.user.sub làm định danh người dùng (từ token);
  • chỉ trả về dữ liệu của đúng người dùng đó.

Nhờ vậy, công cụ của bạn không còn là “API chung” mà trở nên cá nhân hóa — gắn với Identity từ Auth Server.

7. Tóm tắt luồng: từ 401 đến gọi thành công

Để cố định lại mọi thứ, hãy ráp một sơ đồ mini cho luồng mà MCP‑server bảo vệ của bạn hiện thực.

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

Lưu ý tham số resource trong các yêu cầu đến Auth Server: nó được sao chép vào aud của token và phải trùng với resource trong .well-known/oauth-protected-resource.

8. Kiểm thử nhỏ với curl

Để yên tâm, bạn có thể tự thực hiện hai yêu cầu.

Thứ nhất — thử gọi MCP không có token:

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

Kỳ vọng thấy status 401WWW-Authenticate của ta với resource_metadatascope="gifts:read".

Thứ hai — với token hợp lệ (lấy từ 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":{}}}'

Bây giờ, nếu abc123 là JWT hợp lệ với iss đúng, aud="http://localhost:3000" đúng và scope bao gồm gifts:read, bạn sẽ nhận JSON phản hồi của công cụ, và trong structuredContent.gifts sẽ là các món quà của người dùng hiện tại.

9. Các lỗi thường gặp khi cấu hình MCP Server như tài nguyên được bảo vệ

Dưới đây là bộ “chiếc bẫy” hay gặp nhất đúng ở phần mã ta vừa viết: .well-known, WWW-Authenticate, xác thực token và kiểm tra scope.

Lỗi số 1: resourceaudience không đồng bộ.
Thường trong .well-known/oauth-protected-resource bạn ghi một giá trị resource, còn Auth Server lại phát token với aud khác. Kết quả jwtVerify loại token, dù chữ ký và hạn vẫn ổn. Điều này càng dễ xảy ra khi bạn đổi domain/cổng MCP‑server mà quên cập nhật hoặc .well-known, hoặc cấu hình Auth Server. Trong ví dụ của ta, đó là cùng một chuỗi http://localhost:3000 ở trường resource trong .well-known và trong EXPECTED_AUD bên trong verifyAccessToken. Nên tạo một hằng số RESOURCE_ID và dùng ở cả hai chỗ để tránh lệch.

Lỗi số 2: thiếu WWW-Authenticate khi trả 401.
Đôi khi lập trình viên chỉ trả 401 hoặc 403 mà không có header WWW-Authenticate. Với trình duyệt có thể vẫn ổn, nhưng ChatGPT và MCP Jam sẽ không biết đi đâu để lấy token và cần scope nào. Cuối cùng, chúng sẽ coi MCP‑server của bạn “bị lỗi” và không hiển thị UI liên kết cho người dùng. Tối thiểu cần có: WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource". Tốt hơn nữa là thêm cả scope="..." để luồng minh bạch hơn. Helper unauthorized() của ta đảm bảo rằng khi 401 thì header này luôn có mặt.

Lỗi số 3: tin tưởng token mà không kiểm tra chữ ký và iss.
Đôi khi, nhất là ở giai đoạn đầu, rất dễ “cám dỗ”: “Token này từ Auth Server của mình mà, cứ JSON.parse(atob(..)) là xong”. Không được làm vậy: bạn sẽ chấp nhận bất kỳ token có đúng định dạng, kể cả giả mạo. Cách đúng — tải khóa qua jwks_uri và kiểm tra chữ ký cùng iss/aud bằng thư viện (jose, jsonwebtoken, v.v.). Chỉ sau đó mới tin nội dung các claim.

Lỗi số 4: trộn kiểm tra token với logic nghiệp vụ.
Đôi khi kiểm tra token bị “bôi” khắp nơi: công cụ này kiểm tra scope, công cụ khác thì không, chỗ này quên check aud, chỗ kia lại nhận user id từ tham số của tool. Dẫn đến bug kỳ quặc và lỗ hổng tiềm ẩn. Tốt nhất giữ ranh giới rõ: middleware ở tầng HTTP lo token (chữ ký, iss, aud, hạn), còn trong công cụ bạn dựa vào ctx.user như “sự thật” và chỉ bổ sung kiểm tra nghiệp vụ (ví dụ vai trò/tenant).

Lỗi số 5: scopes_supported không khớp với các scope thực sự dùng.
Một trường hợp phổ biến khác: trong .well-known/oauth-protected-resource bạn công bố một tập scope, trong Auth Server là tập khác, còn trong công cụ lại kiểm tra tập thứ ba. ChatGPT/MCP Jam tạo yêu cầu ủy quyền dựa trên scopes_supported đã công bố, còn server của bạn lại báo thiếu scope cần thiết. Cố gắng tối giản số lượng scope và quản lý như “single source of truth” — ví dụ, qua enum trong TypeScript dùng cả khi sinh .well-known lẫn khi cấu hình client trong Auth Server.

Lỗi số 6: chỉ dựa vào securitySchemes của Apps SDK và quên kiểm tra phía server.
Apps SDK cho phép mô tả securitySchemes cho các công cụ (noauth, oauth2, scopes), và ChatGPT sẽ hiển thị đúng UX cho người dùng. Nhưng các annotation đó không tự khiến server an toàn. Dù tool được khai báo là yêu cầu OAuth token, MCP‑server của bạn vẫn phải kiểm tra token, issuer, audience và scope ở mỗi yêu cầu. Nếu không, người ta có thể lách kiểm tra bằng cách gửi thẳng yêu cầu đến URL MCP.

Lỗi số 7: quên thời hạn ngắn của token và xử lý hết hạn.
Nếu access token sống quá lâu, bạn giảm an toàn; nếu quá ngắn nhưng server không xử lý hết hạn tốt, người dùng sẽ liên tục gặp lỗi. Mô hình hợp lý — access token sống ngắn cộng với khả năng MCP‑server luôn trả 401 kèm WWW-Authenticate khi exp đã quá hạn. Client (ChatGPT) khi đó sẽ chạy lại luồng OAuth để làm mới token.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION