Servidor MCP de tu servicio|INEMA.CLUB-PRO

En esta lección vas a

  1. 1Quién emite (servidor de autorización) y quién verifica (tu servidor MCP)
  2. 2401 con WWW-Authenticate y el documento RFC 9728
  3. 3Scope del servidor completo y scope por tool (403 insufficient_scope)
  4. 4iss (RFC 9207) y credencial atada al emisor: lo que corresponde al cliente

Objetivo: proteger el servidor HTTP con token: 401 con metadatos, scope mínimo por tool y secreto fuera del repositorio.

01Del 401 al token con el scope correcto

Haz clic en cada etapa.

Haz clic en cada bloque del diagrama para ver qué hace.

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.

02El servidor con compuerta

Ensayo: dos tokens vienen de variables de entorno. En producción, cambia el verificador por la comprobación del JWT de tu IdP.

Archivo · kit/http-auth.mjs
// 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); });
Fragmento · scope por tool (kit/servidor.mjs)
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')

03Sin token, token de lectura, token de reserva

El bloque genera dos tokens aleatorios solo para esta ronda y llama al servidor tres veces.

Terminal · tokens
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
Archivo · kit/teste-auth.mjs
// 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();
Ejemplo del repositorio
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.

04Secreto fuera del repositorio

1. Entorno, no código

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.

05Práctica

La compuerta del kit y la de tu servicio.

E15 · Tres llamadas

5 min

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.

E16 · Sin variable, sin servidor

2 min

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.

E17 · Scopes de tu servicio

15 min

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.

06Prueba de la clase

Pregunta 1 de 4

En el OAuth del MCP, ¿cuál es el papel de tu servidor MCP?competencia

07En tres frases

PapelEl servidor MCP verifica el token; quien lo emite es el IdP.
ScopeMínimo, por tool, con 403 cuando falta.
SecretoEn el entorno, nunca en el repositorio; audiencia verificada.

Para ir más allá

Clase anteriorSiguiente clase