1. MCP Server jako Resource Server: co dokładnie konfigurujemy
W poprzednim wykładzie konfigurowaliśmy Auth Server – komponent, który wydaje tokeny. Teraz zajmiemy się drugą stroną tej układanki: serwerem MCP jako Resource Server, który te tokeny przyjmuje i weryfikuje.
Z punktu widzenia OAuth 2.1 wasz serwer MCP to Resource Server. Przechowuje „zasoby” (narzędzia MCP, dane użytkownika) i przyjmuje żądania z access tokenem w nagłówku Authorization: Bearer .... Zanim wykona narzędzie, musi sprawdzić, że token jest prawdziwy, nie wygasł, został wydany przez zaufany serwer autoryzacji (Auth Server), jest przeznaczony właśnie dla tego serwera MCP, a także posiada wymagane uprawnienia (scope).
Warto rozdzielić dwa poziomy:
- Warstwa transportowa – tutaj obsługiwane są nagłówki HTTP i tokeny. W szczególności:
- odbieracie i parsujecie Authorization: Bearer,
- przy braku/błędzie tokenu zwracacie 401 Unauthorized z WWW-Authenticate: Bearer ...,
- przy poprawnym tokenie formujecie kontekst użytkownika.
- Warstwa MCP SDK, która w ogóle nie musi wiedzieć o JWT. Po prostu dostaje „już uwierzytelnione” wywołanie i w handlerze może korzystać z ctx.userId, ctx.scopes itp.
Analogicznie: MCP SDK to kucharz w kuchni, a OAuth‑middleware to ochroniarz przy wejściu. Kucharz nie sprawdza paszportów – on po prostu przygotowuje zamówienia.
Jako nasz przykład szkoleniowy kontynuujemy GiftGenius: serwer MCP na http://localhost:3000 z narzędziem list_my_gifts oraz Auth Server (np. Keycloak lub autorski mini‑AS) na http://localhost:4000.
2. .well-known/oauth-protected-resource: wizytówka waszego zasobu MCP
Po co .well-known dla zasobu
Gdy ChatGPT (lub MCP Jam) po raz pierwszy puka do waszego serwera MCP i dostaje 401, musi zrozumieć dwie rzeczy:
- dokąd pójść po token;
- jakie uprawnienia wspiera ten zasób.
Aby nie „hardcodować” tego w klientach, wykorzystuje się endpoint discovery:
GET /.well-known/oauth-protected-resource
Ten endpoint zwraca JSON z metadanymi chronionego zasobu (Protected Resource Metadata) zgodnie z RFC 9728.
Przykład z GiftGenius:
{
"resource": "http://localhost:3000",
"authorization_servers": ["http://localhost:4000"],
"scopes_supported": ["gifts:read", "gifts:write"],
"bearer_methods_supported": ["header"]
}
OpenAI w swoich poradnikach pokazuje niemal taki sam przykład, tylko z HTTPS i realnymi domenami.
Klient (ChatGPT/Jam) czyta ten dokument i:
- rozumie, że token musi mieć audience http://localhost:3000,
- wie, z którymi authorization_servers pracować (issuer URL),
- widzi listę wspieranych scope’ów (łatwiej wtedy zbudować ekran zgody i podpowiedzi).
Omówienie pól metadanych
Skrót po najważniejszych polach:
| Pole | Przeznaczenie |
|---|---|
|
Kanoniczny identyfikator HTTPS/HTTP serwera MCP. Później musi odpowiadać aud tokenu. |
|
Lista adresów URL waszych serwerów autoryzacji (Auth Server/issuer). Klient pójdzie tam po metadane OAuth/OIDC. |
|
Tablica wspieranych scope’ów; potrzebna klientowi dla lepszego UX i poprawnego żądania tokenu. |
|
Sposoby przekazania tokenu: zazwyczaj ["header"], czyli Authorization: Bearer .... |
Dodatkowo czasem publikuje się resource_documentation, jwks_uri, introspection_endpoint itp., ale do podstawowego scenariusza wystarczą pierwsze cztery.
Kluczowa kwestia: resource musi być zgodne z tym, co Auth Server umieszcza w aud tokenu. Jeśli się nie zgadza – klient MCP (i wy sami) będzie protestować i odrzuci token.
Implementacja .well-known w Next.js 16
Załóżmy, że nasz serwer MCP działa w aplikacji Next.js (Apps SDK backend, port 3000). Najprostszy sposób – przygotować route handler w 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);
}
W produkcji resource powinien być adresem HTTPS środowiska produkcyjnego waszego serwera MCP (np. https://mcp.giftgenius.com) i musi zgadzać się z aud w tokenach od IdP.
3. WWW-Authenticate i 401: jak MCP informuje „potrzebny token”
Mamy już „wizytówkę” zasobu w .well-known/oauth-protected-resource. Teraz zobaczmy, jak serwer MCP podpowiada klientowi, że należy z niej skorzystać – przez 401 i nagłówek WWW-Authenticate.
Scenariusz podstawowy: żądanie bez tokenu
Załóżmy, że ChatGPT po raz pierwszy wywołuje narzędzie list_my_gifts. Żądanie sieciowe wygląda mniej więcej tak:
GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000
Tokenu brak. Serwer MCP nie powinien po cichu zwracać 403 ani żadnej strony HTML. Poprawne zachowanie chronionego zasobu w świecie OAuth to zwrócić 401 Unauthorized i w nagłówku WWW-Authenticate wyjaśnić klientowi, jak się uwierzytelnić.
Przykład poprawnej odpowiedzi:
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"}
Ważne szczegóły:
- schemat Bearer mówi, że oczekujemy OAuth Bearer tokenu;
- parametr resource_metadata wskazuje URL do .well-known/oauth-protected-resource;
- parametr scope podpowiada minimalny wymagany zakres uprawnień (np. gifts:read).
MCP Jam i ChatGPT potrafią czytać ten nagłówek. Widząc go:
- odwołają się do .well-known/oauth-protected-resource,
- na podstawie authorization_servers znajdą Auth Server i jego metadane OpenID/OAuth,
- uruchomią przepływ Authorization Code + PKCE, otworzą użytkownikowi stronę logowania i uzyskają token.
Innymi słowy, WWW-Authenticate to wyzwalacz – bez niego klient nawet się nie domyśli, że tutaj działa OAuth.
Middleware dla odpowiedzi 401 (Next.js)
Napiszmy mały pomocnik, który będzie używany na wszystkich chronionych endpointach. Najpierw – funkcja formująca odpowiedź:
// 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",
},
}
);
}
Teraz dowolny route (np. nasz endpoint MCP) może po prostu zwrócić return unauthorized("gifts:read"), a klient dostanie poprawny challenge. Funkcja unauthorized() zwraca obiekt NextResponse (kompatybilny ze standardowym Response). W kolejnych przykładach będziemy czasem „rzucać” ten obiekt jako wyjątek i w route handlerach przechwytywać właśnie Response, by nie duplikować kodu formującego odpowiedź 401 w każdym routingu.
4. Odbiór i weryfikacja tokenu Bearer
Teraz najciekawsze: jak odebrać i zweryfikować token Bearer.
Gdzie wykonywać weryfikację
Transport MCP macie prawdopodobnie zaimplementowany albo:
- w Next.js jako route handler (app/mcp/route.ts), który przyjmuje POST i deleguje dalej do MCP SDK;
- w serwerze Express/Fastify, który nasłuchuje /mcp i przekazuje JSON do handlera MCP.
We wszystkich tych wariantach to warstwa HTTP powinna:
- pobrać Authorization z nagłówka;
- gdy go brakuje lub jest błędny – zwrócić 401 przez naszą unauthorized;
- w przypadku sukcesu – zbudować obiekt kontekstu (userId, scopes, roles) i przekazać go do MCP SDK (przez argumenty handlera/kontekst).
Samo MCP SDK (np. @modelcontextprotocol/sdk) nie musi w ogóle wiedzieć, czym jest JWT. To wasza odpowiedzialność.
Warianty weryfikacji: JWT vs introspection
Są dwa główne style:
- Weryfikować podpis i pola (claims) tokenu JWT lokalnie, używając kluczy JWK serwera autoryzacji.
- Odpyt(y)wać /introspect serwera autoryzacyjnego i pytać: „Czy ten token jest nadal ważny? Jakie ma scope’y?”.
W kursie przyjmujemy, że Auth Server wydaje JWT i publikuje jwks_uri, a serwer MCP lokalnie sprawdza podpis i pola (to szybsze i bardziej autonomiczne).
Pomocnicza funkcja verifyAccessToken w TypeScript
Skorzystamy z popularnej biblioteki jose (ESM‑friendly). Potrzebny jest mniej więcej taki 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,
};
}
W tym helperze:
- pobieramy klucze JWK z Auth Server przez jwks_uri;
- weryfikujemy podpis i standardowe pola (iss, aud);
- wyciągamy sub (identyfikator użytkownika) i scope (ciąg rozdzielany spacją, więc robimy split(" ")).
audience musi zgadzać się z resource z naszego .well-known/oauth-protected-resource, co gwarantuje, że token został wydany właśnie dla naszego serwera MCP.
Prosta weryfikacja nagłówka Authorization
Teraz stwórzmy małego helpera, który wyciągnie token z nagłówka i przepuści go przez 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");
}
}
Zwróć uwagę: tutaj „rzucamy” unauthorized(...) (czyli obiekt Response) jako wyjątek, aby w route handlerze można go było zwięźle przechwycić i zwrócić jako odpowiedź.
5. audience i scope: powiązanie tokenu z zasobem i działaniami
Audience (aud): „dla kogo” wystawiono token
Pole aud odpowiada na pytanie: czy ten token jest przeznaczony dla tego zasobu. W naszym przypadku:
- aud w tokenie Auth Server ustawia na http://localhost:3000;
- nasz .well-known/oauth-protected-resource publikuje resource: "http://localhost:3000";
- verifyAccessToken sprawdza, że tak właśnie jest.
Jeśli token jest przeznaczony dla innego zasobu (np. https://api.other-app.com), wasz serwer MCP ma obowiązek go odrzucić jako „nie do mnie adresowany”.
Typowy błąd – zapomnieć zsynchronizować resource i aud, przez co niby wszystko jest ustawione, a ChatGPT w kółko dostaje 401. Wrócimy do tego w bloku „Typowe błędy”.
Scope’y: „co konkretnie” można robić
Pole scope w tokenie to wykaz uprawnień, które użytkownik przyznał klientowi. W naszym przykładzie:
- gifts:read – prawo odczytu swoich prezentów;
- gifts:write – prawo tworzenia/aktualizacji prezentów.
W .well-known/oauth-protected-resource te wartości pojawiają się jako scopes_supported, żeby klient z góry wiedział, o co może poprosić.
Serwer autoryzacji w swoim dokumencie discovery (.well-known/openid-configuration) także publikuje scopes_supported, ale to lista globalnych scope’ów IdP (nie mylić z .well-known/oauth-protected-resource serwera zasobu).
Ważne, by nie mieszać tych dwóch list: scopes_supported zasobu opisuje, jakie uprawnienia są potrzebne waszemu serwerowi MCP, a scopes_supported IdP – cały „katalog” globalnych scope’ów u dostawcy. Klient zwykle bierze część wspólną.
Po stronie serwera MCP należy:
- dla każdego narzędzia określić, jakie scope’y są wymagane;
- przy każdym wywołaniu narzędzia sprawdzać, że token zawiera te scope’y.
Napiszmy 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(" "));
}
Teraz można wołać requireScope(user, ["gifts:read"]) przed wykonaniem narzędzia.
6. Integracja z narzędziami MCP: od tokenu do list_my_gifts
Endpoint MCP w Next.js
Załóżmy, że mamy serwer MCP oparty o jakieś SDK, który potrafi obsługiwać żądania HTTP. Z punktu widzenia Next.js może to wyglądać tak:
// 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();
}
}
Ważne jest, że:
- wyciągamy użytkownika i scope’y z tokenu (getUserFromRequest);
- przekazujemy je do serwera MCP przez kontekst { user };
- przy braku/błędzie tokenu zwracamy nasze 401 z WWW-Authenticate.
Konkretne API MCP SDK może się różnić, ale idea wszędzie jest ta sama: owinąć wywołanie MCP middleware’em, który już „wie”, kto puka.
Narzędzie list_my_gifts z weryfikacją scope
Zajrzyjmy teraz do implementacji samego narzędzia. Załóżmy, że używamy TypeScript SDK dla MCP i mamy coś w rodzaju:
// 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 },
};
}
);
Wykonujemy trzy kluczowe kroki:
- wymagamy gifts:read przed uruchomieniem głównego kodu;
- używamy ctx.user.sub jako identyfikatora użytkownika (z tokenu);
- zwracamy dane tylko tego użytkownika.
Dzięki temu wasze narzędzie przestaje być „ogólnym API”, a staje się spersonalizowane – powiązane z tożsamością z Auth Server.
7. Podsumowanie przepływu: od 401 do udanego wywołania
Aby wszystko utrwalić, zbierzmy mini‑schemat przepływu, który teraz realizuje wasz chroniony serwer 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
Zwróć uwagę na parametr resource w żądaniach do Auth Server: jest kopiowany do aud tokenu i musi zgadzać się z resource w .well-known/oauth-protected-resource.
8. Mała praktyczna weryfikacja z użyciem curl
Dla pewności można wykonać dwa żądania ręcznie.
Pierwsze – próba wywołania MCP bez tokenu:
curl -i http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
Oczekujemy statusu 401 i naszego WWW-Authenticate z resource_metadata oraz scope="gifts:read".
Drugie – z prawidłowym tokenem (uzyskanym z 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":{}}}'
Teraz, jeśli abc123 to prawidłowy JWT z poprawnymi iss, aud="http://localhost:3000" i scope zawierającym gifts:read, otrzymacie odpowiedź JSON narzędzia, a w structuredContent.gifts znajdą się prezenty bieżącego użytkownika.
9. Typowe błędy przy konfiguracji serwera MCP jako chronionego zasobu
Poniżej zestaw pułapek, w które najczęściej się wpada właśnie przy implementacji tego kodu: .well-known, WWW-Authenticate, weryfikacja tokenu i sprawdzanie scope’ów.
Błąd nr 1: niesynchronizowane resource i audience.
Często w .well-known/oauth-protected-resource wpisuje się jedną wartość resource, a w Auth Server w tokenach wydaje się inne aud. W efekcie jwtVerify odrzuca token, nawet jeśli podpis i czas życia są w porządku. Szczególnie łatwo to zepsuć, gdy zmieniacie domenę/port serwera MCP i zapominacie zaktualizować albo .well-known, albo konfigurację Auth Server. W naszym przykładzie to ten sam ciąg http://localhost:3000 w polu resource w .well-known i w EXPECTED_AUD wewnątrz verifyAccessToken. Warto zdefiniować jedną stałą RESOURCE_ID i używać jej w obu miejscach, aby uniknąć rozjazdów.
Błąd nr 2: brak WWW-Authenticate przy 401.
Deweloperzy czasem zwracają samo 401 albo 403 bez nagłówka WWW-Authenticate. Z perspektywy przeglądarki może to przejść, ale ChatGPT i MCP Jam nie zrozumieją, dokąd pójść po token i jakie scope’y są wymagane. W rezultacie uznają wasz serwer MCP za „zepsuty” i nie pokażą użytkownikowi UI łączenia. Absolutne minimum: WWW-Authenticate: Bearer z resource_metadata=".../.well-known/oauth-protected-resource". Lepiej od razu dodać także scope="...", by przepływ był bardziej przejrzysty. Nasz helper unauthorized() gwarantuje, że przy 401 ten nagłówek zawsze jest obecny.
Błąd nr 3: zaufanie tokenowi bez weryfikacji podpisu i iss.
Czasem, zwłaszcza na początku, pokusa jest duża: „Przecież to token z mojego Auth Server, zróbmy tylko JSON.parse(atob(..)) i już”. Tak nie wolno: wtedy przyjmujecie dowolny token o odpowiednim formacie, nawet podrobiony. Poprawne podejście – pobrać klucze przez jwks_uri i weryfikować podpis oraz iss/aud biblioteką (jose, jsonwebtoken itp.). Dopiero potem można ufać zawartości pól.
Błąd nr 4: mieszanie weryfikacji tokenu z logiką biznesową.
Czasem weryfikacja tokenu rozlewa się po kodzie narzędzi: jedno sprawdza scope, inne – nie, gdzieś zapomniano sprawdzić aud, a gdzie indziej przyjmuje się identyfikator użytkownika z argumentu narzędzia. To prowadzi do dziwnych błędów i potencjalnych luk. Lepiej trzymać wyraźny podział: middleware na poziomie HTTP zajmuje się tokenem (podpis, iss, aud, termin), a w narzędziu opieracie się już na ctx.user jako „prawdzie” i tylko uzupełniacie o kontrole biznesowe (np. rola/tenant).
Błąd nr 5: niespójność między scopes_supported a faktycznie używanymi scope’ami.
Kolejny klasyk: w .well-known/oauth-protected-resource publikujecie jeden zestaw scope’ów, w Auth Server – inny, a w narzędziach sprawdzacie trzeci. ChatGPT/MCP Jam formują żądanie autoryzacji na podstawie opublikowanych scopes_supported, a wasz serwer później narzeka, że brakuje wymaganego scope’u. Starajcie się minimalizować liczbę scope’ów i traktować je jako „jedno źródło prawdy” – np. przez enum w TypeScript, który służy i do generowania .well-known, i do konfiguracji klientów w Auth Server.
Błąd nr 6: poleganie wyłącznie na securitySchemes z Apps SDK i zapominanie o weryfikacji po stronie serwera.
Apps SDK pozwala opisać securitySchemes dla narzędzi (noauth, oauth2, scope’y), a ChatGPT uczciwie pokaże użytkownikowi właściwy UX. Ale te adnotacje nie czynią serwera automatycznie bezpiecznym. Nawet jeśli narzędzie jest zadeklarowane jako wymagające tokenu OAuth, wasz serwer MCP i tak musi przy każdym żądaniu sprawdzać token, issuer, audience i scope’y. W przeciwnym razie da się obejść kontrole, wysyłając żądanie bezpośrednio na URL MCP.
Błąd nr 7: poleganie wyłącznie na długim czasie życia tokenów i brak obsługi wygaśnięcia.
Jeśli access tokeny żyją zbyt długo, obniżacie bezpieczeństwo; jeśli zbyt krótko, ale serwer nie potrafi poprawnie obsłużyć wygaśnięcia, użytkownik będzie stale trafiał na błędy. Właściwy model to krótko żyjący access token plus gotowość serwera MCP do zwracania 401 z WWW-Authenticate, gdy exp jest już w przeszłości. Klient (ChatGPT) ponowi wtedy przepływ OAuth i odświeży token.
GO TO FULL VERSION