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 .... Перед тем как выполнять tool, он обязан проверить, что токен настоящий, не истёк по времени, выдан доверенным сервером авторизации (Auth Server) и предназначен именно этому MCP‑серверу, да ещё и с нужными правами (scope).

Важно отделить два уровня:

  1. Транспортный уровень — здесь обрабатываются HTTP‑заголовки и токены. Там вы:
    • принимаете/парсите Authorization: Bearer,
    • при отсутствии/ошибке токена возвращаете 401 Unauthorized с WWW-Authenticate: Bearer ...,
    • при валидном токене формируете контекст пользователя.
  2. Уровень 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

Этот эндпоинт возвращает 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 работать (issuer URL),
  • видит список поддерживаемых scopes (так проще формировать экран согласия и подсказки).

Разбор полей метаданных

Сводка по основным полям:

Поле Назначение
resource
Канонический HTTPS/HTTP‑идентификатор MCP‑сервера. Потом совпадает с aud токена.
authorization_servers
Список URL ваших серверов авторизации (Auth Server/issuer). Клиент пойдёт туда за OAuth/OIDC‑метаданными.
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). Самый простой способ — сделать 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 должен быть 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. Сетевой запрос выглядит как-то так:

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 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‑хендлер.

Во всех этих вариантах именно HTTP‑слой должен:

  1. забрать Authorization из заголовка;
  2. при его отсутствии/ошибке вернуть 401 через нашу unauthorized;
  3. при успехе — сформировать объект контекста (userId, scopes, roles) и передать его в MCP SDK (через аргументы хендлера/контекст).

Сам MCP SDK (например, @modelcontextprotocol/sdk) может вообще не знать, что такое JWT. Это ваша зона ответственности.

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

Есть два основных стиля:

  1. Проверять подпись и клеймы JWT‑токена локально, используя JWK‑ключи Auth Server.
  2. Ходить на /introspect авторизационного сервера и спрашивать: «Этот токен ещё жив? Какие у него scopes?».

В курсе мы будем считать, что Auth Server выдаёт JWT и публикует jwks_uri, а MCP‑сервер проверяет подпись и клеймы локально (это быстрее и автономнее).

Утилита verifyAccessToken на TypeScript

Используем популярную библиотеку jose (ESM‑friendly). Нам нужен примерно такой 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’е мы:

  • скачиваем JWK‑ключи Auth Server по jwks_uri;
  • проверяем подпись и стандартные клеймы (iss, aud);
  • достаём sub (user id) и 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‑объект) как исключение, чтобы в route handler’е можно было лаконично перехватить его и вернуть как ответ.

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

Audience (aud): «для кого» выписан токен

Клейм aud отвечает на вопрос: этому ли ресурсу предназначен токен. В нашем случае:

  • aud в токене Auth Server выставляет в 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, но это уже список глобальных scopes IdP. (просьба не путать с .well-known/oauth-protected-resource resource сервера)

Важно не путать эти два списка: 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» и становится персонализированным — привязанным к Identity из 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‑сервер «сломленным» и не покажут пользователю UI линкинга. Минимум, который нужен: 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 и т.п.). Только после этого можно доверять содержимому клеймов.

Ошибка №4: смешивание проверки токена и бизнес‑логики.
Иногда проверка токена размазывается по коду инструментов: один инструмент проверяет scope, другой — нет, где‑то забыли проверить aud, а где‑то вообще принимают пользовательский id из аргумента tool. Это ведёт к очень странным багам и потенциальным уязвимостям. Лучше держать чёткое разделение: middleware на HTTP‑уровне занимается токеном (подпись, iss, aud, срок), а в инструменте вы уже опираетесь на ctx.user как на «правду» и только дополняете бизнес‑проверками (например, роль/tenant).

Ошибка №5: несоответствие scopes_supported и реально используемых scopes.
Ещё один популярный случай: в .well-known/oauth-protected-resource вы публикуете один набор scopes, в Auth Server — другой, а в инструментах проверяете третий. ChatGPT/МCP Jam формируют запрос авторизации исходя из опубликованных scopes_supported, а ваш сервер потом ругается, что нужного scope нет. Старайтесь минимизировать количество scopes и вести их как «единую правду» — например, через enum в TypeScript, который используется и при генерации .well-known, и при настройке клиентов в Auth Server.

Ошибка №6: полагаться только на Apps SDK securitySchemes и забывать про проверку на сервере.
Apps SDK позволяет описать securitySchemes для инструментов (noauth, oauth2, scopes), и ChatGPT будет честно показывать пользователю правильный UX. Но эти аннотации не делают сервер автоматически безопасным. Даже если tool объявлен как требующий OAuth‑токен, ваш MCP‑сервер всё равно обязан проверять токен, issuer, audience и scopes при каждом запросе. Иначе можно будет обойти проверки, просто послав запрос напрямую на URL MCP.

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

1
Задача
ChatGPT Apps, 10 уровень, 3 лекция
Недоступна
Discovery-эндпоинт /.well-known/oauth-protected-resource
Discovery-эндпоинт /.well-known/oauth-protected-resource
1
Задача
ChatGPT Apps, 10 уровень, 3 лекция
Недоступна
401 Unauthorized + WWW-Authenticate для защищённого REST-эндпоинта
401 Unauthorized + WWW-Authenticate для защищённого REST-эндпоинта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ