1. MCP Server come Resource Server: cosa stiamo configurando esattamente
Nella lezione precedente abbiamo configurato l’Auth Server — il componente che emette i token. Ora ci occupiamo dell’altra metà del sistema: il server MCP come Resource Server, che riceve e verifica quei token.
Dal punto di vista di OAuth 2.1 il vostro MCP‑server è un Resource Server. Conserva “risorse” (strumenti MCP, dati dell’utente) e riceve richieste con l’access token nell’intestazione Authorization: Bearer .... Prima di eseguire un tool, ha l’obbligo di verificare che il token sia valido, non sia scaduto, sia stato emesso da un server di autorizzazione fidato (Auth Server) e sia destinato proprio a questo MCP‑server, oltre ad avere i permessi necessari (scope).
È importante separare due livelli:
- Livello di trasporto — qui si gestiscono intestazioni HTTP e token. Qui:
- accettate/analizzate Authorization: Bearer,
- in assenza/errore del token restituite 401 Unauthorized con WWW-Authenticate: Bearer ...,
- con un token valido costruite il contesto utente.
- Livello MCP SDK, che non deve necessariamente conoscere JWT. Riceve semplicemente una chiamata “già autenticata” e all’interno dell’handler può usare ctx.userId, ctx.scopes ecc.
Un’analogia: l’MCP SDK è lo chef in cucina, mentre l’OAuth middleware è il buttafuori all’ingresso. Lo chef non controlla i documenti; cucina gli ordini.
Come esempio didattico continuiamo con GiftGenius: MCP‑server su http://localhost:3000 con lo strumento list_my_gifts, e Auth Server (ad esempio Keycloak o un mini‑AS custom) su http://localhost:4000.
2. .well-known/oauth-protected-resource: il biglietto da visita della vostra risorsa MCP
Perché serve .well-known per la risorsa
Quando ChatGPT (o MCP Jam) bussa per la prima volta al vostro MCP‑server e riceve 401, deve capire due cose:
- dove andare a prendere il token;
- quali permessi supporta questa risorsa.
Per non “hardcodare” tutto questo nei client, si usa un endpoint di discovery:
GET /.well-known/oauth-protected-resource
Questo endpoint restituisce un JSON con i metadati della risorsa protetta (Protected Resource Metadata) secondo RFC 9728.
Esempio da GiftGenius:
{
"resource": "http://localhost:3000",
"authorization_servers": ["http://localhost:4000"],
"scopes_supported": ["gifts:read", "gifts:write"],
"bearer_methods_supported": ["header"]
}
OpenAI nelle sue guide mostra quasi lo stesso esempio, solo con HTTPS e domini reali.
Il client (ChatGPT/Jam) legge questo documento e:
- capisce che il token deve avere audience http://localhost:3000;
- capisce con quali authorization_servers lavorare (issuer URL);
- vede l’elenco degli scope supportati (così è più facile costruire lo schermo del consenso e i suggerimenti).
Analisi dei campi dei metadati
Riepilogo dei campi principali:
| Campo | Scopo |
|---|---|
|
Identificatore canonico HTTPS/HTTP del server MCP. In seguito coincide con aud del token. |
|
Elenco degli URL dei vostri server di autorizzazione (Auth Server/issuer). Il client andrà lì a leggere i metadati OAuth/OIDC. |
|
Matrice di scope supportati; serve al client per un buon UX e per richiedere correttamente il token. |
|
Modalità di invio del token: di solito ["header"], cioè Authorization: Bearer .... |
In aggiunta a volte si pubblicano resource_documentation, jwks_uri, introspection_endpoint ecc., ma per lo scenario di base bastano i primi quattro.
Punto critico: resource deve coincidere con ciò che l’Auth Server inserisce in aud del token. Se non coincide — il client MCP (e anche voi) rifiuterà il token.
Implementazione di .well-known in Next.js 16
Supponiamo che il nostro MCP‑server viva in un’app Next.js (Apps SDK backend, porta 3000). Il modo più semplice è creare un route handler in 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);
}
In produzione resource deve essere l’URL HTTPS dell’ambiente prod del vostro MCP‑server (ad esempio https://mcp.giftgenius.com) e deve coincidere con aud nei token dell’IdP.
3. WWW-Authenticate e 401: come l’MCP comunica “serve un token”
Abbiamo già creato il “biglietto da visita” della risorsa in .well-known/oauth-protected-resource. Ora vediamo come l’MCP‑server suggerisce al client di consultare proprio quel biglietto — tramite 401 e l’intestazione WWW-Authenticate.
Scenario base: richiesta senza token
Immaginiamo che ChatGPT invochi per la prima volta lo strumento list_my_gifts. La richiesta di rete assomiglia a questa:
GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000
Il token non c’è. L’MCP‑server non deve restituire in silenzio 403 o una qualsiasi pagina HTML. Il comportamento corretto di una risorsa protetta nel mondo OAuth è restituire 401 Unauthorized e, tramite l’intestazione WWW-Authenticate, spiegare al client come autenticarsi.
Esempio di risposta corretta:
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"}
Dettagli importanti:
- lo schema Bearer indica che vogliamo un Bearer token OAuth;
- il parametro resource_metadata indica l’URL di .well-known/oauth-protected-resource;
- il parametro scope suggerisce l’ambito minimo richiesto (ad esempio gifts:read).
MCP Jam e ChatGPT sanno leggere questa intestazione. Vedendola, essi:
- effettueranno una richiesta a .well-known/oauth-protected-resource;
- tramite authorization_servers troveranno l’Auth Server e i suoi metadati OpenID/OAuth;
- avvieranno il flusso Authorization Code + PKCE, apriranno all’utente la pagina di login e otterranno il token.
Quindi WWW-Authenticate è il grilletto: senza di essa il client non capisce nemmeno che qui si usa OAuth.
Middleware per le risposte 401 (Next.js)
Scriviamo una piccola utility da usare su tutti gli endpoint protetti. Per prima cosa — una funzione che costruisce la risposta:
// 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",
},
}
);
}
Ora qualsiasi route (ad esempio il nostro endpoint MCP) può semplicemente chiamare return unauthorized("gifts:read"), e il client riceverà una challenge corretta. La funzione unauthorized() restituisce un oggetto NextResponse (compatibile con lo standard Response). Nei prossimi esempi talvolta lanceremo questo oggetto come eccezione e, nei route handler, intercetteremo proprio Response, in modo da non duplicare il codice per formare la risposta 401 in ogni route.
4. Ricezione e verifica del token Bearer
Ora la parte più interessante: come ricevere e verificare un Bearer token.
Dove eseguire la verifica
Il trasporto MCP è probabilmente implementato:
- in un route handler Next.js (app/mcp/route.ts), che accetta POST e delega all’MCP SDK;
- in un server Express/Fastify, che ascolta /mcp e inoltra il JSON all’handler MCP.
In tutti questi casi proprio il livello HTTP deve:
- estrarre Authorization dall’intestazione;
- in caso di assenza/errore restituire 401 tramite la nostra unauthorized;
- in caso di successo — costruire l’oggetto contesto (userId, scopes, roles) e passarlo all’MCP SDK (tramite gli argomenti dell’handler/contesto).
L’MCP SDK stesso (ad esempio @modelcontextprotocol/sdk) potrebbe non sapere affatto cosa sia un JWT. Questa è responsabilità vostra.
Opzioni di verifica: JWT vs introspection
Ci sono due stili principali:
- Verificare localmente firma e claim del JWT, usando le JWK dell’Auth Server.
- Chiamare /introspect del server di autorizzazione e chiedere: “Questo token è ancora valido? Quali scope ha?”.
Nel corso supporremo che l’Auth Server emetta JWT e pubblichi jwks_uri, mentre l’MCP‑server verifichi localmente firma e claim (più veloce e autonomo).
Utility verifyAccessToken in TypeScript
Usiamo la libreria jose (ESM‑friendly). Ci serve un helper del tipo:
// 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,
};
}
In questo helper:
- scarichiamo le JWK dell’Auth Server tramite jwks_uri;
- verifichiamo la firma e i claim standard (iss, aud);
- estraiamo sub (user id) e scope (stringa separata da spazio, quindi usiamo split(" ")).
audience deve coincidere con resource del nostro .well-known/oauth-protected-resource, a garanzia che il token sia emesso proprio per il nostro MCP‑server.
Verifica semplice dell’intestazione Authorization
Creiamo ora un piccolo helper che estragga il token dall’intestazione e lo faccia passare per 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");
}
}
Notate: qui lanciamo unauthorized(...) (cioè un oggetto Response) come eccezione, in modo che il route handler possa intercettarlo in modo conciso e restituirlo come risposta.
5. audience e scope: collegare il token a risorsa e azioni
Audience (aud): “a chi” è destinato il token
Il claim aud risponde alla domanda: questo token è destinato a questa risorsa? Nel nostro caso:
- aud nel token viene impostato dall’Auth Server a http://localhost:3000;
- il nostro .well-known/oauth-protected-resource pubblica resource: "http://localhost:3000";
- verifyAccessToken verifica che sia così.
Se il token è destinato a un’altra risorsa (ad esempio https://api.other-app.com), il vostro MCP‑server deve rifiutarlo perché “non indirizzato a me”.
Errore tipico: dimenticare di sincronizzare resource e aud, per cui sembra tutto configurato ma ChatGPT riceve sempre 401. Torneremo su questo nel blocco “Errori tipici”.
Scope: “che cosa esattamente” si può fare
Il claim scope nel token è l’elenco dei permessi che l’utente ha concesso al client. Nel nostro esempio:
- gifts:read — diritto di leggere i propri regali;
- gifts:write — diritto di creare/aggiornare i regali.
In .well-known/oauth-protected-resource questi valori compaiono come scopes_supported, così che il client sappia in anticipo cosa può richiedere.
Il server di autorizzazione, nel suo documento di discovery (.well-known/openid-configuration), pubblica anch’esso scopes_supported, ma è l’elenco degli scope globali dell’IdP (da non confondere con .well-known/oauth-protected-resource del server di risorse).
È importante non confondere questi due elenchi: scopes_supported della risorsa descrive i permessi necessari al vostro MCP‑server, mentre scopes_supported dell’IdP è il “catalogo” completo degli scope del provider. Il client in genere prende l’intersezione dei due.
A livello di MCP‑server dovete:
- decidere, per ogni strumento, quali scope sono necessari;
- per ogni invocazione, verificare che il token contenga quegli scope.
Scriviamo un 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(" "));
}
Ora possiamo chiamare requireScope(user, ["gifts:read"]) prima di eseguire lo strumento.
6. Integrazione con gli strumenti MCP: dal token a list_my_gifts
Route MCP in Next.js
Supponiamo di avere un MCP‑server basato su un certo SDK, che sa gestire richieste HTTP. In Next.js potrebbe apparire così:
// 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();
}
}
Qui è importante che:
- estraiamo utente e scope dal token (getUserFromRequest);
- li passiamo all’MCP‑server tramite il contesto { user };
- in caso di assenza/errore del token restituiamo la nostra 401 con WWW-Authenticate.
L’API concreta dell’MCP SDK può differire, ma l’idea è sempre la stessa: avvolgere la chiamata MCP con un middleware che sappia già “chi” sta bussando.
Strumento list_my_gifts con verifica dello scope
Ora diamo un’occhiata all’implementazione dello strumento. Supponiamo di usare un SDK TypeScript per MCP, con qualcosa del genere:
// lib/mcpServer.ts (frammento)
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 },
};
}
);
Eseguiamo tre passi chiave:
- richiediamo gifts:read prima del codice principale;
- usiamo ctx.user.sub come identificatore utente (dal token);
- restituiamo i dati solo di quell’utente.
Così il vostro strumento smette di essere una “API generica” e diventa personalizzato — legato all’identità dell’Auth Server.
7. Riepilogo del flusso: da 401 alla chiamata riuscita
Per fissare il tutto, componiamo un mini‑schema del flusso che ora realizza il vostro MCP‑server protetto.
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
Fate attenzione al parametro resource nelle richieste all’Auth Server: viene copiato in aud del token e deve coincidere con resource in .well-known/oauth-protected-resource.
8. Piccola verifica pratica con curl
Per maggior tranquillità si possono fare due richieste a mano.
La prima — tentativo di chiamare l’MCP senza token:
curl -i http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'
Ci aspettiamo di vedere lo stato 401 e la nostra WWW-Authenticate con resource_metadata e scope="gifts:read".
La seconda — con un token valido (ottenuto dall’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":{}}}'
Ora, se abc123 è un JWT valido con iss e aud="http://localhost:3000" corretti e lo scope include gifts:read, otterrete la risposta JSON dello strumento e in structuredContent.gifts ci saranno i regali dell’utente corrente.
9. Errori tipici nella configurazione di MCP Server come risorsa protetta
Di seguito — una serie di “trappole” in cui si inciampa spesso proprio implementando il codice che abbiamo appena visto: .well-known, WWW-Authenticate, verifica del token e controllo degli scope.
Errore n. 1: resource e audience non sincronizzati.
Spesso in .well-known/oauth-protected-resource si pubblica un certo valore di resource, mentre nell’Auth Server i token hanno un altro aud. Di conseguenza jwtVerify scarta il token anche se firma e scadenza sono ok. È facile rompere tutto quando si cambia dominio/porta del MCP‑server e si dimentica di aggiornare o .well-known o la configurazione dell’Auth Server. Nel nostro esempio è la stessa stringa http://localhost:3000 nel campo resource in .well-known e in EXPECTED_AUD dentro verifyAccessToken. Conviene definire un’unica costante RESOURCE_ID da usare in entrambi i punti, per evitare discrepanze.
Errore n. 2: assenza di WWW-Authenticate con 401.
A volte gli sviluppatori restituiscono semplicemente 401 o 403 senza l’intestazione WWW-Authenticate. Dal punto di vista del browser può andare, ma ChatGPT e MCP Jam non capiranno dove andare a prendere il token e quali scope servono. Risultato: considereranno il vostro MCP‑server “rotto” e non mostreranno all’utente l’UI di collegamento. Il minimo indispensabile è WWW-Authenticate: Bearer con resource_metadata=".../.well-known/oauth-protected-resource". Meglio aggiungere anche scope="...", per rendere il flusso più trasparente. Il nostro helper unauthorized() garantisce proprio che, con 401, quell’intestazione sia sempre presente.
Errore n. 3: fidarsi del token senza verificare firma e iss.
Talvolta, specie all’inizio, la tentazione è forte: “È un token del mio Auth Server, faccio solo JSON.parse(atob(..)) e basta”. Non si può: accettereste qualsiasi token col formato “giusto”, anche contraffatto. L’approccio corretto è caricare le chiavi tramite jwks_uri e verificare firma e iss/aud tramite una libreria (jose, jsonwebtoken, ecc.). Solo dopo ci si può fidare dei claim.
Errore n. 4: mescolare la verifica del token con la logica di business.
A volte la verifica del token si “spalma” nel codice degli strumenti: uno controlla lo scope, un altro no; da qualche parte si dimentica di verificare aud, altrove si accetta un id utente passato come argomento del tool. Questo porta a bug strani e potenziali vulnerabilità. Meglio tenere una separazione netta: il middleware a livello HTTP si occupa del token (firma, iss, aud, scadenza), e nello strumento ci si affida a ctx.user come “fonte di verità”, integrando solo con controlli di business (ad esempio ruolo/tenant).
Errore n. 5: discrepanza tra scopes_supported e gli scope realmente usati.
Altro caso comune: in .well-known/oauth-protected-resource pubblicate un set di scope, nell’Auth Server ce n’è un altro, e negli strumenti ne controllate un terzo. ChatGPT/MCP Jam costruiscono la richiesta di autorizzazione in base agli scopes_supported pubblicati, e poi il vostro server protesta perché manca lo scope richiesto. Cercate di minimizzare il numero di scope e di gestirli come “fonte unica di verità” — ad esempio con un enum in TypeScript usato sia per generare .well-known sia nella configurazione dei client sull’Auth Server.
Errore n. 6: affidarsi solo agli securitySchemes dell’Apps SDK e dimenticare la verifica lato server.
Apps SDK consente di descrivere securitySchemes per gli strumenti (noauth, oauth2, scope), e ChatGPT mostrerà un UX corretto. Ma queste annotazioni non rendono il server automaticamente sicuro. Anche se un tool è dichiarato come richiedente un token OAuth, il vostro MCP‑server deve comunque verificare token, issuer, audience e scope a ogni richiesta. Altrimenti si potrebbero aggirare i controlli inviando la richiesta direttamente all’URL dell’MCP.
Errore n. 7: dimenticare la breve durata degli access token e la gestione della scadenza.
Se gli access token vivono troppo a lungo, riducete la sicurezza; se troppo poco e il server non gestisce bene la scadenza, l’utente incapperà spesso in errori. Il modello corretto è: access token di breve durata più disponibilità dell’MCP‑server a restituire 401 con WWW-Authenticate quando exp è già nel passato. Il client (ChatGPT) ripeterà quindi il flusso OAuth e aggiornerà il token.
GO TO FULL VERSION