JavaRush /Курси /ChatGPT Apps /Налаштування MCP Server як захищеного ресурсу:

Налаштування MCP Server як захищеного ресурсу: .well-known, Bearer, audience/scope

ChatGPT Apps
Рівень 10 , Лекція 3
Відкрита

1. MCP Server як Resource Server: що саме ми налаштовуємо

У попередній лекції ми налаштовували Auth Server — компонент, який видає токени. Тепер займіймося іншою стороною цього звʼязку: MCP‑сервером як Resource Server. Саме він приймає токени й перевіряє їх.

З погляду OAuth 2.1 ваш MCP‑сервер — це Resource Server. Він зберігає «ресурси» (інструменти MCP, дані користувача) і приймає запити з access‑token у заголовку Authorization: Bearer .... Перш ніж виконувати інструмент, він зобовʼязаний перевірити токен. Зокрема: чи справжній токен, чи не минув термін його дії, чи видано його довіреним сервером авторизації (Auth Server) і чи призначено його саме для цього MCP‑сервера. Також токен має містити потрібні права доступу (scope).

Важливо відокремити два рівні:

  1. Транспортний рівень — тут обробляються HTTP‑заголовки й токени. На цьому рівні ви:
    • приймаєте й розбираєте Authorization: Bearer,
    • за відсутності токена або помилки в ньому повертаєте 401 Unauthorized із WWW-Authenticate: Bearer ...,
    • для коректного токена формуєте контекст користувача.
  2. Рівень MCP SDK, який узагалі не мусить знати про JWT. Він просто отримує «вже автентифікований» виклик, а всередині обробника може використовувати 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

Ця кінцева точка повертає JSON із метаданими захищеного ресурсу (Protected Resource Metadata) за 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 працювати (URL емітента, issuer),
  • бачить список підтримуваних scopes (так простіше сформувати екран згоди й підказки).

Розбір полів метаданих

Підсумуймо призначення основних полів:

Поле Призначення
resource
Канонічний HTTPS/HTTP‑ідентифікатор MCP‑сервера. Далі він має збігатися з aud токена.
authorization_servers
Список URL ваших серверів авторизації (Auth Server/issuer). Клієнт звернеться туди по OpenID/OAuth‑метадані.
scopes_supported
Масив підтримуваних областей доступу (scopes). Потрібен клієнту для кращого UX і коректного запиту токена.
bearer_methods_supported
Способи передавання токена. Зазвичай ["header"], тобто Authorization: Bearer ....

Додатково іноді публікують resource_documentation, jwks_uri, introspection_endpoint тощо. Але для базового сценарію нам досить перших чотирьох.

Критичний момент: resource має збігатися з тим, що Auth Server кладе в aud токена. Якщо значення не збігатимуться, MCP‑клієнт (і ви самі) скаржитиметься та відхилятиме токен.

Реалізація .well-known у Next.js 16

Нехай наш MCP‑сервер працює в застосунку Next.js (Apps SDK backend, порт 3000). Найпростіший спосіб — зробити обробник маршруту в 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 має бути HTTPS‑URL вашого MCP‑сервера (наприклад, https://mcp.giftgenius.com). І, звісно, він має збігатися з aud у токенах від IdP.

3. WWW-Authenticate і 401: як MCP повідомляє «потрібен токен»

Ми вже зробили «візитівку» ресурсу в .well-known/oauth-protected-resource. Тепер подивімося, як MCP‑сервер підказує клієнту, що за цією «візитівкою» взагалі потрібно перейти. Для цього використовується 401 і заголовок WWW-Authenticate.

Базовий сценарій: прийшли без токена

Уявімо, що ChatGPT уперше викликає інструмент list_my_gifts. HTTP‑запит виглядає приблизно так:

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 вказує URL до .well-known/oauth-protected-resource;
  • параметр 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.

Middleware для 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). У наступних прикладах ми інколи викидатимемо цей обʼєкт як виняток, а в обробниках маршрутів перехоплюватимемо саме 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. за його відсутності або помилки повернути 401 через нашу unauthorized;
  3. у разі успіху сформувати обʼєкт контексту (userId, scopes, roles) і передати його в MCP SDK (через аргументи обробника/контекст).

Сам MCP SDK (наприклад, @modelcontextprotocol/sdk) може взагалі не знати, що таке JWT. Це ваша зона відповідальності.

Варіанти перевірки: JWT vs introspection

Є два основні підходи:

  1. Перевіряти підпис і claims JWT‑токена локально, використовуючи JWK‑ключі Auth Server.
  2. Звертатися до /introspect авторизаційного сервера і запитувати: «Цей токен ще чинний? Які в нього scopes?».

У цьому курсі вважатимемо, що Auth Server видає JWT і публікує jwks_uri, а MCP‑сервер перевіряє підпис і claims локально (це швидше й автономніше).

Утиліта verifyAccessToken на TypeScript

Використаємо популярну бібліотеку jose (ESM‑friendly). Нам потрібна приблизно така допоміжна функція:

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

У цій допоміжній функції ми:

  • завантажуємо JWK‑ключі Auth Server за jwks_uri;
  • перевіряємо підпис і стандартні claims (iss, aud);
  • дістаємо sub (ідентифікатор користувача) і scope (це рядок, розділений пробілами, тому робимо split(" ")).

audience має збігатися з resource із нашого .well-known/oauth-protected-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‑обʼєкт) як виняток. Так в обробнику маршруту можна лаконічно перехопити його та повернути як відповідь.

5. audience і scope: привʼязка токена до ресурсу та дій

Audience (aud): «для кого» призначено токен

Claim aud відповідає на запитання: чи призначено токен саме цьому ресурсу. У нашому випадку:

  • aud у токені Auth Server задано як http://localhost:3000;
  • наш .well-known/oauth-protected-resource публікує resource: "http://localhost:3000";
  • verifyAccessToken перевіряє, що значення збігаються.

Якщо токен призначений для іншого ресурсу (наприклад, https://api.other-app.com), ваш MCP‑сервер зобовʼязаний відхилити його як «не для мене».

Типова помилка — забути синхронізувати resource і audience. У результаті, здавалося б, усе налаштовано, а ChatGPT постійно отримує 401. Ми ще повернемося до цього в блоці «Типові помилки».

Scopes: «що саме» можна робити

Claim scope у токені — це перелік прав, які користувач надав клієнту. У нашому прикладі:

  • gifts:read — право читати свої подарунки;
  • gifts:write — право створювати й оновлювати подарунки.

У .well-known/oauth-protected-resource ці значення зʼявляються як scopes_supported, щоб клієнт заздалегідь знав, що саме він може запросити.

Авторизаційний сервер у своєму discovery‑документі (.well-known/openid-configuration) теж публікує scopes_supported, але це вже список глобальних scopes IdP (не плутайте його зі списком у .well-known/oauth-protected-resource для resource server).

Важливо не плутати ці два списки: scopes_supported ресурсу описує, які права потрібні саме вашому MCP‑серверу, а scopes_supported IdP — увесь «каталог» глобальних scopes у провайдера. Клієнт зазвичай бере перетин цих двох списків.

На рівні 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

Маршрут MCP у Next.js

Уявіть, що у вас є MCP‑сервер на базі якогось SDK, який уміє обробляти 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);
  • передаємо їх у MCP‑сервер через контекст { user };
  • за відсутності токена або помилки в ньому повертаємо наш 401 із WWW-Authenticate.

Конкретний API у MCP SDK може відрізнятися, але ідея одна й та сама: обгорнути виклик MCP проміжним шаром (middleware), який уже знає, «хто» звертається.

Інструмент list_my_gifts із перевіркою scope

Тепер зазирнімо в реалізацію самого інструмента. Припустімо, ми використовуємо TypeScript SDK для MCP, і в нас є щось на кшталт:

// 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

Зверніть увагу на параметр resource у запитах до Auth Server: він копіюється в aud токена і має збігатися з resource у .well-known/oauth-protected-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 у полі resource в .well-known і в EXPECTED_AUD усередині verifyAccessToken. Щоб не множити розбіжностей, варто завести одну константу RESOURCE_ID і використовувати її в обох місцях.

Помилка №2: відсутність WWW-Authenticate під час 401.
Розробники іноді просто повертають 401 або 403 без заголовка WWW-Authenticate. Для браузера це може бути й нормально, але ChatGPT і MCP Jam не зрозуміють, куди звертатися по токен і які scopes потрібні. У підсумку вони вважатимуть ваш MCP‑сервер «зламаним» і не покажуть користувачеві інтерфейс звʼязування. Мінімум, який потрібен: WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource". Краще одразу додати й scope="...", щоб потік був прозорішим. Наш helper unauthorized() якраз гарантує, що під час 401 цей заголовок завжди присутній.

Помилка №3: довіра до токена без перевірки підпису та iss.
Іноді, особливо на ранніх етапах, спокуса велика: «Ну це ж токен, він із мого Auth Server — давайте просто JSON.parse(atob(..)), і все». Так робити не можна: тоді ви прийматимете будь‑який токен потрібного формату, навіть підроблений. Правильний підхід — завантажити ключі за jwks_uri і перевіряти підпис та iss/aud через бібліотеку (jose, jsonwebtoken тощо). Лише після цього можна довіряти вмісту claims (клеймів).

Помилка №4: змішування перевірки токена та бізнес‑логіки.
Іноді перевірка токена «розповзається» по коду інструментів: один інструмент перевіряє scope, інший — ні; десь забули перевірити aud, а десь узагалі беруть ідентифікатор користувача з аргументів інструмента. Це веде до дивних помилок і потенційних уразливостей. Краще тримати чіткий поділ: middleware на HTTP‑рівні займається токеном (підпис, iss, aud, термін дії), а в інструменті ви вже спираєтесь на ctx.user як на «джерело правди» й лише доповнюєте бізнес‑перевірками (наприклад, роль/tenant).

Помилка №5: невідповідність scopes_supported і реально використовуваних scopes.
Ще один поширений випадок: у .well-known/oauth-protected-resource ви публікуєте один набір scopes, у Auth Server — інший, а в інструментах перевіряєте третій. ChatGPT/MCP Jam формують запит авторизації, виходячи з опублікованих scopes_supported, а ваш сервер потім повідомляє, що потрібного scope немає. Намагайтеся мінімізувати кількість scopes і вести їх як «єдине джерело істини» — наприклад, через enum у TypeScript, який використовується і під час генерації .well-known, і під час налаштування клієнтів в Auth Server.

Помилка №6: покладатися лише на Apps SDK securitySchemes і забувати про перевірку на сервері.
Apps SDK дозволяє описати securitySchemes для інструментів (noauth, oauth2, scopes), і ChatGPT сумлінно показуватиме користувачеві правильний UX. Але ці анотації не роблять сервер автоматично безпечним. Навіть якщо інструмент оголошено таким, що вимагає OAuth‑токен, ваш MCP‑сервер усе одно зобовʼязаний перевіряти токен, issuer, audience і scopes під час кожного запиту. Інакше перевірки можна буде обійти, просто надіславши запит напряму на URL MCP.

Помилка №7: забути про короткий термін життя токенів і обробку завершення строку дії.
Якщо access‑токени живуть занадто довго, ви знижуєте безпеку. Якщо ж надто коротко, але при цьому сервер не вміє коректно обробляти завершення строку дії, користувач постійно натикатиметься на помилки. Правильна модель — короткоживучий access‑token плюс готовність MCP‑сервера повертати 401 із WWW-Authenticate, коли exp уже в минулому. Клієнт (ChatGPT) у такому разі запустить OAuth‑потік ще раз і оновить токен.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ