Wzorce uwierzytelniania MCP: OAuth, tokeny i kiedy co
Specyfikacja Model Context Protocol (modelcontextprotocol.io) definiuje transporty i prymitywy. Nie definiuje uwierzytelniania. Słusznie, bo uwierzytelnianie to sprawa transportu i wdrożenia, a nie protokołu. Jest też źródłem najczęstszego błędu produkcyjnego, jaki widuję: serwer MCP wystawiony w Internet bez auth, eksponujący narzędzia mutujące stan. W polskim kontekście dochodzi do tego art. 32 RODO, który nakłada obowiązek odpowiednich środków technicznych, gdy w grze są dane osobowe.
Artykuł osadzony jest w filarze budowa serwera MCP.
TL;DR
- Tylko-do-odczytu na publicznych danych z rate limitem to jedyny legalny przypadek anonim.
- Scoped tokeny API z hashowaniem i krótkim TTL pokrywają B2B i headless.
- OAuth 2.1 z PKCE pokrywa konsumenckie asystenty działające w imieniu zalogowanego użytkownika.
- Rate limiting przykleja się do principala; narzędzia mutujące dostają węższy bucket.
- Każde zdarzenie auth jest logowane: wydane, użyte, cofnięte, nieudane.
Drzewo decyzyjne
Przed wyborem wzorca auth przepuszczam pięć tych samych pytań:
- Czy któreś narzędzie zmienia stan? Jeśli tak, anonim odpada.
- Czy agent działa w imieniu konkretnego ludzkiego użytkownika? Jeśli tak, OAuth.
- Czy agent działa jako integracja B2B bez ludzkiego użytkownika? Jeśli tak, scoped token API.
- Czy powierzchnia danych jest osiągalna w publicznym Internecie? Jeśli tak, rate limiting jest obowiązkowy.
- Czy w jednym serwerze mieszają się narzędzia mutujące i tylko-do-odczytu? Jeśli tak, scope auth per narzędzie, a nie per serwer.
Drzewo mapuje się na trzy wzorce:
| Wzorzec | Kiedy używać | Implementacja |
|---|---|---|
| Anonim + rate limit IP | Publiczny katalog, tylko-do-odczytu, ograniczone obciążenie | Worker sprawdza tylko bucket IP |
| Scoped token API | Integracja B2B, headless’owy runtime agenta, brak ludzkiego użytkownika | JWT z claimami, hashowane przechowywanie, rotacja |
| OAuth 2.1 + PKCE | Konsumencki agent dla zalogowanego użytkownika | Standardowy authorization code flow |
Wzorce się nie wykluczają. Realny serwer produkcyjny zwykle uruchamia wszystkie trzy, a każde narzędzie jest otagowane wymaganym scope’em.
Wzorzec pierwszy: anonimowo tylko-do-odczytu z rate limitem
Narzędzie do przeglądania katalogu opakowujące /wp-json/wc/v3/products?stock_status=instock eksponuje te same dane, które publiczna strona już serwuje. Postawienie auth przed nim nie poprawia postawy bezpieczeństwa; obniża tylko dostępność. Realnym zagrożeniem jest obciążenie: pętla agenta lub scrape konkurencji potrafi zatłuc origin WooCommerce.
Implementacja:
async function checkAnonymousRateLimit(request: Request, env: Env): Promise<boolean> {
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const key = `rl:anon:${ip}`;
const count = Number((await env.RATE_LIMIT.get(key)) ?? "0");
if (count >= 60) return false;
await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 60 });
return true;
}
60 żądań na minutę na IP to mój default. Licznik oparty o KV jest przybliżony (eventually consistent przy zapisach KV); dla ścisłych limitów Durable Objects lub natywne Rate Limiting binding Cloudflare są właściwym narzędziem.
Czego ten wzorzec nie pokrywa: żadnego narzędzia zwracającego dane specyficzne dla użytkownika, żadnego mutującego stan, żadnego ujawniającego stan magazynu produktów niepublicznych. Te wymagają realnego principala.
Wzorzec drugi: scoped tokeny API dla B2B
Przypadek B2B: integracja partnera dostarcza agenta wołającego twój serwer MCP w imieniu organizacji partnera, nie konkretnego ludzkiego użytkownika. Przykłady to agent synchronizacji magazynu u hurtownika, integracja marketplace’u u agregatora, agent analityczny raportujący trendy zamówień do BI.
Przepływ:
- Administrator w backendzie WordPressa tworzy token z nazwą, zestawem scope’ów (
catalogue:read,inventory:read,orders:write) i terminem ważności (default 90 dni). - Token jest pokazywany administratorowi raz, potem trzymany jako hash SHA-256 plus zestaw scope’ów plus wygaśnięcie.
- Partner konfiguruje swojego klienta MCP z tokenem w nagłówku
Authorization: Bearer <token>. - Worker hashuje przychodzący token i sprawdza hash w KV. Jeśli hash się zgadza, wygaśnięcie jest w przyszłości, a wymagany scope narzędzia jest w zestawie tokena, wywołanie idzie dalej.
async function verifyApiToken(authHeader: string | null, env: Env): Promise<TokenContext | null> {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
const hash = await sha256(token);
const record = await env.TOKENS.get(`tok:${hash}`, "json") as TokenRecord | null;
if (!record) return null;
if (record.expiresAt < Date.now()) return null;
return { tokenId: record.id, scopes: record.scopes, principal: record.principal };
}
function requireScope(ctx: TokenContext, scope: string): void {
if (!ctx.scopes.includes(scope)) {
throw new McpError("forbidden", `Tool requires scope ${scope}`);
}
}
Dwa nawyki operacyjne trzymają wzorzec uczciwie:
Rotacja, nie wieczność. Token, który nigdy nie wygasa, to token, który za sześć miesięcy ląduje w publicznym repo na GitHubie. 90 dni domyślnie z flow odnawiania, który nakłada stary i nowy token na 7 dni, to wzorzec przetrwający realnych partnerów.
Hashowane przechowywanie, nie plaintext. Jeśli twoje KV wycieknie, hashe bez oryginalnego tokena są bezużyteczne. Jeśli trzymałeś tokeny w plaintext, każda integracja partnera potrzebuje natychmiastowej rotacji. Różnica kosztu w momencie wydania to jeden sha256.
Wzorzec trzeci: OAuth 2.1 z PKCE dla delegowanych agentów
Przypadek konsumencki: użytkownik otwiera Claude Desktop, łączy konto w twoim sklepie po OAuth i prosi agenta “pokaż moje ostatnie zamówienie”. Agent musi teraz zawołać twój serwer MCP z poświadczeniami mówiącymi “działam dla użytkownika 4231”.
OAuth 2.1 (draft-ietf-oauth-v2-1) to skonsolidowany profil. PKCE (RFC 7636) jest obowiązkowy dla klientów publicznych (tu mieszczą się asystenty desktopowe bez poufnej części serwerowej).
Flow w pięciu krokach:
- Host MCP otwiera przeglądarkę na twoim endpoincie autoryzacji z
response_type=code,code_challenge=<S256 hash verifiera>i żądanymi scope’ami. - Użytkownik loguje się na twojej stronie WordPress (lub u twojego dostawcy auth) i akceptuje scope’y.
- Twój endpoint autoryzacji przekierowuje hosta z jednorazowym kodem autoryzacji.
- Host wymienia kod plus oryginalny
code_verifierna twoim endpoincie tokenów na access token (krótki TTL, 1 godzina) i refresh token (dłuższy TTL, 30 dni). - Host woła serwer MCP z
Authorization: Bearer <access_token>. Worker weryfikuje podpis tokena, wygaśnięcie i scope wobec wołanego narzędzia.
Access token to podpisany JWT. Worker weryfikuje go Web Crypto API (dokumentacja Cloudflare Workers) bez biblioteki zewnętrznej:
async function verifyJwt(token: string, env: Env): Promise<JwtClaims | null> {
const [headerB64, payloadB64, sigB64] = token.split(".");
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const sig = base64UrlDecode(sigB64);
const valid = await crypto.subtle.verify("RS256", env.PUBLIC_KEY, sig, data);
if (!valid) return null;
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (payload.exp * 1000 < Date.now()) return null;
return payload as JwtClaims;
}
Scope’y w JWT odzwierciedlają scope’y ze wzorca scoped token: catalogue:read, orders:read, orders:write. ID użytkownika WordPressa siedzi w claimie sub, więc wywołanie orders:read zwraca tylko zamówienia tego użytkownika.
Mieszanie wzorców w jednym serwerze
Realny serwer MCP dla WooCommerce zwykle wystawia:
catalogue.listiproduct.detail: anonim + rate limit IP.inventory.check(dla partnerów): scoped token zinventory:read.order.status(dla zalogowanego użytkownika): OAuth zorders:read.order.intent(dla zalogowanego użytkownika): OAuth zorders:write, plus węższy rate limit.
Logika pre-dispatch w Workerze przechodzi przez wzorce:
async function authenticate(request: Request, toolName: string, env: Env): Promise<Principal> {
const requirement = TOOL_AUTH_REQUIREMENTS[toolName];
if (requirement === "anonymous") {
if (!await checkAnonymousRateLimit(request, env)) throw new McpError("rate_limit");
return { kind: "anonymous" };
}
const auth = request.headers.get("Authorization");
if (requirement === "api_token") {
const ctx = await verifyApiToken(auth, env);
if (!ctx) throw new McpError("unauthorized");
return { kind: "api_token", ctx };
}
if (requirement === "oauth") {
const claims = auth?.startsWith("Bearer ") ? await verifyJwt(auth.slice(7), env) : null;
if (!claims) throw new McpError("unauthorized");
return { kind: "oauth", claims };
}
throw new Error(`Unknown auth requirement for ${toolName}`);
}
Mapa TOOL_AUTH_REQUIREMENTS to jedyne źródło prawdy o tym, które narzędzie potrzebuje którego trybu auth. Żadne narzędzie nie zostaje dodane bez jawnego wpisu.
Rate limiting per principal
Ruch anonimowy dostaje bucket na IP. Ruch z tokenem dostaje bucket na token. Ruch OAuth dostaje bucket na użytkownika. Narzędzia mutujące dostają węższą półkę niezależnie od principala.
Dla kształtu WooCommerce moje domyślne buckety:
| Kategoria narzędzia | Anonim | Token | OAuth |
|---|---|---|---|
catalogue.* (odczyt) | 60 / minutę / IP | 600 / minutę / token | 120 / minutę / użytkownika |
inventory.* (odczyt) | nie wolno | 300 / minutę / token | nie wolno |
order.status (odczyt) | nie wolno | 60 / minutę / token | 60 / minutę / użytkownika |
order.intent (zapis) | nie wolno | 30 / minutę / token | 10 / minutę / użytkownika |
Liczby są punktem startu; właściwe wartości wynikają z obserwacji realnego ruchu przez dwa tygodnie i tuningu. Liczy się struktura.
Logowanie zdarzeń auth
Każde zdarzenie auth-relewantne ląduje w logu:
- Token wydany. Administrator, principal docelowy, scope’y, wygaśnięcie.
- Token użyty. ID tokena, nazwa narzędzia, principal, opóźnienie, sukces/porażka.
- Token cofnięty. ID tokena, kto cofnął, dlaczego.
- Token nieudany. Powód (wygasł, brak scope’a, niezgodność hash), IP, user agent.
- Wymiana kodu OAuth. ID użytkownika, przyznane scope’y, wydany refresh token.
- Refresh OAuth. ID użytkownika, nowy access token, stary token zastąpiony.
Logi idą przez Cloudflare Logpush do długoterminowego store’u. Zapytanie dashboardu śledzące “tokeny użyte w ostatnich 24 godzinach, których nie używano przez poprzednie 90 dni” łapie prawdopodobną kradzież tokena. Zapytanie o “nieudane weryfikacje tokena na IP” łapie próby credential stuffingu.
Gdzie to siedzi w klastrze
Ten artykuł pokrywa powierzchnię auth. Po przebieg implementacji sięgnij do budowy serwera MCP dla WooCommerce. Po typowane definicje narzędzi do pisania typowanych narzędzi katalogu z Zod dla MCP. Po decyzję na poziomie protokołu do MCP vs REST. Po ścieżkę migracji z istniejącego API do migracji API WordPress do MCP. Filar to budowa serwera MCP.
Wycena indywidualna, bo zakres auth zależy od wzorców wymaganych w twoim środowisku; serwer tylko-do-odczytu w trybie anonim to inny projekt niż pełna powierzchnia wydająca OAuth.
