E15 · Tres llamadas
5 minEjecuta el bloque tokens.
Listo cuando: HTTP/1.1 401 Unauthorized sin token; con el de lectura, reservar_vaga: recusado (Insufficient scope: required "turmas:reservar"); con el de reserva, reservar_vaga: reservado.
Clase 6 · 20 min
Un servidor HTTP en internet sin token es una tool abierta para cualquiera. En MCP tu servidor es un resource server de OAuth: no emite tokens, solo los verifica. En esta lección proteges el servidor del kit, ves el 401 que inicia el login del cliente y le das a cada token solo el scope que necesita.
Comenzar la lección
Objetivo: proteger el servidor HTTP con token: 401 con metadatos, scope mínimo por tool y secreto fuera del repositorio.
Haz clic en cada etapa.
En el SDK v2 2.3.1, la validación de iss y la credencial atada al emisor son opciones del lado del cliente (finishAuth); el servidor es el resource server.
Ensayo: dos tokens vienen de variables de entorno. En producción, cambia el verificador por la comprobación del JWT de tu IdP.
// http-auth.mjs — el mismo servidor por HTTP, ahora como "resource server" OAuth:
// sin token → 401 con WWW-Authenticate; token sin el scope → 403 insufficient_scope.
// ENSAYO: los tokens vienen de variables de entorno (nunca del repositorio). En producción quien
// emite el token es tu servidor de autorización (IdP) y aquí verificas la firma
// del JWT (o haces introspección RFC 7662), comprobando exp y aud.
import { createServer as criarHttp } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import {
createMcpHandler, requireBearerAuth, oauthMetadataResponse,
getOAuthProtectedResourceMetadataUrl, OAuthError, OAuthErrorCode
} from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
import { createServer } from './servidor.mjs';
const PORTA = Number(process.env.PORTA ?? 3334);
const URL_MCP = new URL(`http://127.0.0.1:${PORTA}/mcp`);
const TOKENS = {
[process.env.TOKEN_LEITURA ?? '']: { clientId: 'leitor', scopes: ['turmas:ler'] },
[process.env.TOKEN_RESERVA ?? '']: { clientId: 'atendente', scopes: ['turmas:ler', 'turmas:reservar'] }
};
if (!process.env.TOKEN_LEITURA || !process.env.TOKEN_RESERVA) {
console.error('defina TOKEN_LEITURA e TOKEN_RESERVA no ambiente'); process.exit(2);
}
const igual = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
const verifier = {
async verifyAccessToken(token) {
const chave = Object.keys(TOKENS).find(k => k && igual(k, token));
if (!chave) throw new OAuthError(OAuthErrorCode.InvalidToken, 'token desconhecido');
return { token, ...TOKENS[chave], expiresAt: Math.floor(Date.now() / 1000) + 3600, resource: URL_MCP };
}
};
const portao = requireBearerAuth({
verifier,
requiredScopes: ['turmas:ler'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(URL_MCP),
expectedResource: URL_MCP
});
// Documento RFC 9728 al que apunta el 401: le dice al cliente qué servidor de autorización usar.
const metadados = {
oauthMetadata: {
issuer: 'https://auth.exemplo.com',
authorization_endpoint: 'https://auth.exemplo.com/authorize',
token_endpoint: 'https://auth.exemplo.com/token',
response_types_supported: ['code'],
authorization_response_iss_parameter_supported: true
},
resourceServerUrl: URL_MCP,
scopesSupported: ['turmas:ler', 'turmas:reservar']
};
const mcp = createMcpHandler(createServer);
const comPortao = {
async fetch(request) {
const doc = oauthMetadataResponse(request, metadados);
if (doc) return doc;
const auth = await portao(request);
if (auth instanceof Response) return auth; // 401 o 403 ya listo
return mcp.fetch(request, { authInfo: auth });
}
};
const node = toNodeHandler(comPortao);
const hostOk = localhostHostValidation();
const origemOk = localhostOriginValidation();
const http = criarHttp((req, res) => {
if (!hostOk(req, res) || !origemOk(req, res)) return;
void node(req, res);
});
http.listen(PORTA, '127.0.0.1', () => console.error(`turmas (com token): ${URL_MCP}`));
process.on('SIGTERM', async () => { await mcp.close(); http.close(); process.exit(0); });annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
// Con token (http-auth.mjs), exige el scope turmas:reservar; sin token (stdio), no se aplica.
scopeChallenge: requireScopes('turmas:reservar')El bloque genera dos tokens aleatorios solo para esta ronda y llama al servidor tres veces.
cd ~/projetos/formacao-webmcp-mcp-v72/kit
export TOKEN_LEITURA=$(openssl rand -hex 16) TOKEN_RESERVA=$(openssl rand -hex 16)
node http-auth.mjs & PID=$!; sleep 1
echo "## sem token"
curl -s -o /dev/null -D - -X POST http://127.0.0.1:3334/mcp \
-H 'Content-Type: application/json' -d '{}' | grep -i -e '^HTTP' -e '^www-authenticate'
echo "## metadados do recurso (RFC 9728)"
curl -s http://127.0.0.1:3334/.well-known/oauth-protected-resource/mcp; echo
echo "## token de leitura"; TOKEN=$TOKEN_LEITURA node teste-auth.mjs
echo "## token de reserva"; TOKEN=$TOKEN_RESERVA node teste-auth.mjs
kill $PID; wait $PID 2>/dev/null// teste-auth.mjs — llama al servidor con token (http-auth.mjs ya en ejecución) y muestra qué puede hacer cada token.
// Uso: TOKEN=<token> node teste-auth.mjs
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const URL_MCP = new URL(`http://127.0.0.1:${process.env.PORTA ?? 3334}/mcp`);
const cliente = new Client({ name: 'teste-auth', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } });
cliente.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirmar: true } }));
await cliente.connect(new StreamableHTTPClientTransport(URL_MCP,
{ requestInit: { headers: { Authorization: `Bearer ${process.env.TOKEN}` } } }));
const busca = await cliente.callTool({ name: 'buscar_turmas', arguments: { termo: 'html' } });
console.log(`buscar_turmas: ${busca.isError ? 'erro' : 'ok'}`);
try {
const r = await cliente.callTool({ name: 'reservar_vaga', arguments: { turma_id: 'html-01', email: 'aluno@exemplo.com' } });
console.log(`reservar_vaga: ${r.content[0].text}`);
} catch (e) {
console.log(`reservar_vaga: recusado (${String(e.message).slice(0, 90)})`);
}
await cliente.close();turmas (com token): http://127.0.0.1:3334/mcp
## sem token
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", scope="turmas:ler", resource_metadata="http://127.0.0.1:3334/.well-known/oauth-protected-resource/mcp"
## metadados do recurso (RFC 9728)
{"resource":"http://127.0.0.1:3334/mcp","authorization_servers":["https://auth.exemplo.com"],"scopes_supported":["turmas:ler","turmas:reservar"]}
## token de leitura
buscar_turmas: ok
reservar_vaga: recusado (Insufficient scope: required "turmas:reservar")
## token de reserva
buscar_turmas: ok
reservar_vaga: reservado: html-01 para aluno@exemplo.com (restam 2)El token de lectura pasa la compuerta del servidor (scope turmas:ler) pero no reserva: le falta turmas:reservar. Es el scope mínimo funcionando por tool.
El token, la clave de firma y el secreto del cliente vienen de una variable de entorno o de una bóveda. El kit se niega a arrancar sin TOKEN_LEITURA y TOKEN_RESERVA.
Archivo .env en el .gitignore antes del primer commit. Antes de publicar, busca: git grep -n -i "token\|secret" -- ':!*.md'.
expectedResource hace que el servidor acepte solo tokens emitidos para él. Sin esto, pasaría un token de otro servicio del mismo IdP.
Un scope por tipo de acción. Leer y reservar por separado; el agente que solo consulta recibe solo lectura.
La compuerta del kit y la de tu servicio.
Ejecuta el bloque tokens.
Listo cuando: HTTP/1.1 401 Unauthorized sin token; con el de lectura, reservar_vaga: recusado (Insufficient scope: required "turmas:reservar"); con el de reserva, reservar_vaga: reservado.
Ejecuta node http-auth.mjs; echo "saída: $?" en una terminal sin las variables.
Listo cuando: defina TOKEN_LEITURA e TOKEN_RESERVA no ambiente y saída: 2.
Lista en el mapa-mcp.md un scope por tool con consecuencia y pon scopeChallenge: requireScopes(...) en ellas.
Listo cuando: grep -c "scopeChallenge:" servidor.mjs → igual al número de tools con consecuencia.
Pregunta 1 de 4
En el OAuth del MCP, ¿cuál es el papel de tu servidor MCP?competencia
Pregunta 2 de 4
¿Qué trae el 401 del servidor para que el cliente empiece el login?competencia
resource_metadata apunta al documento que dice qué servidor de autorización usar y qué scopes existen.resource_metadata apunta al documento que dice qué servidor de autorización usar y qué scopes existen.Repasar: El servidor con compuertaPregunta 3 de 4
¿Dónde ocurre la validación del parámetro iss (RFC 9207)?
Pregunta 4 de 4
Un token con solo turmas:ler intenta reservar_vaga. ¿Respuesta?
Elige una alternativa. La explicación aparece después de tu intento.