CodeGym /Cursos /ChatGPT Apps /Configuración de MCP Server como recurso protegido:

Configuración de MCP Server como recurso protegido: .well-known, Bearer, audience/scope

ChatGPT Apps
Nivel 10 , Lección 3
Disponible

1. MCP Server como Resource Server: qué es exactamente lo que configuramos

En la lección anterior configuramos el Auth Server — el componente que emite tokens. Ahora nos ocuparemos de la otra parte de este conjunto: el servidor MCP como servidor de recursos (Resource Server), que recibe y verifica esos tokens.

Desde el punto de vista de OAuth 2.1, tu servidor MCP es un Resource Server. Almacena «recursos» (herramientas MCP, datos del usuario) y acepta peticiones con un token de acceso en la cabecera Authorization: Bearer .... Antes de ejecutar una herramienta, está obligado a comprobar que el token es auténtico, no ha expirado, ha sido emitido por un servidor de autorización de confianza (Auth Server) y está destinado a este servidor MCP, además de tener los permisos necesarios (scope).

Es importante separar dos niveles:

  1. Capa de transporte — aquí se procesan las cabeceras HTTP y los tokens. Ahí tú:
    • recibes/parseas Authorization: Bearer,
    • si falta el token o hay error, devuelves 401 Unauthorized con WWW-Authenticate: Bearer ...,
    • si es válido, creas el contexto de usuario.
  2. Capa del SDK de MCP, que no tiene por qué saber nada sobre JWT. Simplemente recibe una llamada «ya autenticada» y, dentro del handler, puede usar ctx.userId, ctx.scopes, etc.

Analogía: el SDK de MCP es el cocinero en la cocina, y el middleware de OAuth es el guardia en la entrada. El cocinero no comprueba pasaportes, solo prepara los pedidos.

Como ejemplo didáctico, continuamos con GiftGenius: servidor MCP en http://localhost:3000 con la herramienta list_my_gifts, y Auth Server (por ejemplo, Keycloak o un mini AS personalizado) en http://localhost:4000.

2. .well-known/oauth-protected-resource: la tarjeta de presentación de tu recurso MCP

Para qué sirve .well-known para el recurso

Cuando ChatGPT (o MCP Jam) llama por primera vez a tu servidor MCP y recibe un 401, necesita entender dos cosas:

  • a dónde ir a por el token;
  • qué permisos admite ese recurso.

Para no «hardcodear» todo esto en los clientes, se utiliza un endpoint de descubrimiento:

GET /.well-known/oauth-protected-resource

Este endpoint devuelve un JSON con los metadatos del recurso protegido (Protected Resource Metadata) según RFC 9728.

Ejemplo de GiftGenius:

{
  "resource": "http://localhost:3000",
  "authorization_servers": ["http://localhost:4000"],
  "scopes_supported": ["gifts:read", "gifts:write"],
  "bearer_methods_supported": ["header"]
}

OpenAI en sus guías muestra un ejemplo casi idéntico, solo que con HTTPS y dominios reales.

El cliente (ChatGPT/Jam) lee este documento y:

  • entiende que el token debe tener la audience http://localhost:3000,
  • comprende con qué authorization_servers trabajar (issuer URL),
  • ve la lista de scopes compatibles (así es más fácil formar la pantalla de consentimiento y las sugerencias).

Desglose de los campos de metadatos

Resumen de los campos principales:

Campo Propósito
resource
Identificador HTTPS/HTTP canónico del servidor MCP. Luego coincide con el aud del token.
authorization_servers
Lista de URL de tus servidores de autorización (Auth Server/issuer). El cliente irá allí por los metadatos OAuth/OIDC.
scopes_supported
Array de scopes admitidos; el cliente lo usa para un buen UX y para solicitar el token correctamente.
bearer_methods_supported
Formas de enviar el token: normalmente ["header"], es decir, Authorization: Bearer ....

Opcionalmente a veces se publican resource_documentation, jwks_uri, introspection_endpoint, etc., pero para el escenario básico nos bastan los cuatro primeros.

Punto crítico: resource debe coincidir con lo que el Auth Server coloca en el aud del token. Si no coincide, el cliente MCP (y tú mismo) rechazará el token.

Implementación de .well-known en Next.js 16

Supongamos que nuestro servidor MCP vive en una aplicación Next.js (Apps SDK backend, puerto 3000). La forma más sencilla es crear un route handler en 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);
}

En producción, resource debe ser el URL HTTPS del entorno de producción de tu servidor MCP (por ejemplo, https://mcp.giftgenius.com), y debe coincidir con el aud en los tokens del IdP.

3. WWW-Authenticate y 401: cómo MCP comunica «se necesita un token»

Ya hemos creado la «tarjeta de presentación» del recurso en .well-known/oauth-protected-resource. Ahora veamos cómo el servidor MCP le indica al cliente que debe ir a por esa tarjeta — mediante 401 y la cabecera WWW-Authenticate.

Escenario básico: llega sin token

Imaginemos que ChatGPT llama por primera vez a la herramienta list_my_gifts. La petición de red se ve más o menos así:

GET /mcp/tools/list_my_gifts HTTP/1.1
Host: localhost:3000

No hay token. El servidor MCP no debe devolver en silencio un 403 ni una página HTML cualquiera. El comportamiento correcto de un recurso protegido en el mundo OAuth es devolver 401 Unauthorized y, mediante la cabecera WWW-Authenticate, explicar al cliente cómo autorizarse.

Ejemplo de respuesta correcta:

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"}

Detalles importantes:

  • el esquema Bearer indica que queremos un token Bearer de OAuth;
  • el parámetro resource_metadata apunta al URL de .well-known/oauth-protected-resource;
  • el parámetro scope sugiere qué ámbito mínimo se necesita (por ejemplo, gifts:read).

MCP Jam y ChatGPT saben leer esta cabecera. Al verla, ellos:

  1. Llamarán a .well-known/oauth-protected-resource.
  2. Por authorization_servers encontrarán el Auth Server y sus metadatos OpenID/OAuth.
  3. Ejecutarán el flujo Authorization Code + PKCE, abrirán al usuario la página de login y obtendrán el token.

Es decir, WWW-Authenticate es el disparador: sin él el cliente ni siquiera adivinará que aquí hay OAuth.

Middleware para respuestas 401 (Next.js)

Escribamos una pequeña utilidad que se usará en todos los endpoints protegidos. Primero, una función que forme la respuesta:

// 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",
      },
    }
  );
}

Ahora cualquier ruta (por ejemplo, nuestro endpoint MCP) puede simplemente hacer return unauthorized("gifts:read"), y el cliente recibirá el challenge correcto. La función unauthorized() devuelve un objeto NextResponse (compatible con Response estándar). En los ejemplos siguientes a veces lanzaremos este objeto como excepción y, en los route handlers, capturaremos precisamente el Response para no duplicar el código de formación de la respuesta 401 en cada ruta.

4. Recepción y validación del token Bearer

Ahora lo más interesante: cómo recibir y validar un token Bearer.

Dónde hacer la validación

Tu transporte MCP probablemente esté implementado o bien:

  • en un route handler de Next.js (app/mcp/route.ts) que recibe POST y delega al SDK de MCP;
  • en un servidor Express/Fastify que escucha /mcp y pasa el JSON al handler de MCP.

En todos estos casos la capa HTTP debe:

  1. tomar Authorization de la cabecera;
  2. si falta o hay error, devolver 401 mediante nuestro unauthorized;
  3. en caso de éxito, formar el objeto de contexto (userId, scopes, roles) y pasarlo al SDK de MCP (a través de los argumentos del handler/contexto).

El propio SDK de MCP (por ejemplo, @modelcontextprotocol/sdk) puede no saber en absoluto qué es un JWT. Es tu responsabilidad.

Opciones de validación: JWT vs introspección

Hay dos estilos principales:

  1. Validar localmente la firma y los claims del JWT usando las claves JWK del Auth Server.
  2. Ir a /introspect del servidor de autorización y preguntar: «¿Este token sigue vivo? ¿Qué scopes tiene?».

En este curso asumiremos que el Auth Server emite JWT y publica jwks_uri, y que el servidor MCP comprueba localmente la firma y los claims (es más rápido y autónomo).

Utilidad verifyAccessToken en TypeScript

Usamos la popular biblioteca jose (compatible con ESM). Necesitamos algo así:

// 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,
  };
}

En este helper:

  • descargamos las claves JWK del Auth Server mediante jwks_uri;
  • validamos la firma y los claims estándar (iss, aud);
  • extraemos sub (user id) y scope (cadena separada por espacio, por eso hacemos split(" ")).

La audience debe coincidir con resource de nuestro .well-known/oauth-protected-resource, lo que garantiza que ese token está emitido precisamente para nuestro servidor MCP.

Comprobación sencilla de la cabecera Authorization

Ahora creamos un pequeño helper que extraiga el token de la cabecera y lo pase por 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");
  }
}

Atención: aquí lanzamos unauthorized(...) (es decir, un objeto Response) como excepción, para poder capturarlo con concisión en el route handler y devolverlo como respuesta.

5. audience y scope: vinculación del token con el recurso y las acciones

Audience (aud): «para quién» se emite el token

El claim aud responde a: ¿para este recurso está destinado el token? En nuestro caso:

  • aud en el token lo establece el Auth Server como http://localhost:3000;
  • nuestro .well-known/oauth-protected-resource publica resource: "http://localhost:3000";
  • verifyAccessToken comprueba que así sea.

Si el token está destinado a otro recurso (por ejemplo, https://api.other-app.com), tu servidor MCP debe rechazarlo por «no va dirigido a mí».

Un error típico es olvidar sincronizar resource y aud, con lo cual todo parece configurado pero ChatGPT recibe constantemente 401. Volveremos a esto en el bloque de «Errores típicos».

Scopes: «qué exactamente» se puede hacer

El claim scope del token es la lista de permisos que el usuario concedió al cliente. En nuestro ejemplo:

  • gifts:read — permiso para leer tus regalos;
  • gifts:write — permiso para crear/actualizar regalos.

En .well-known/oauth-protected-resource estos valores aparecen como scopes_supported, para que el cliente sepa de antemano qué puede solicitar.

El servidor de autorización, en su documento de descubrimiento (.well-known/openid-configuration), también publica scopes_supported, pero esa ya es la lista global de scopes del IdP. (no confundir con los scopes del resource server de .well-known/oauth-protected-resource)

Es importante no confundir estas dos listas: scopes_supported del recurso describe qué permisos necesita precisamente tu servidor MCP, y scopes_supported del IdP es el «catálogo» global de scopes del proveedor. El cliente suele tomar la intersección de ambos mundos.

A nivel del servidor MCP necesitas:

  • decidir qué scopes requiere cada herramienta;
  • al invocar cada herramienta, comprobar que el token incluye esos scopes.

Escribamos 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(" "));
}

Ahora puedes llamar a requireScope(user, ["gifts:read"]) antes de ejecutar la herramienta.

6. Integración con las herramientas de MCP: del token a list_my_gifts

Ruta MCP en Next.js

Imaginemos que tenemos un servidor MCP basado en algún SDK que sabe manejar peticiones HTTP. Desde el punto de vista de Next.js podría verse así:

// 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();
  }
}

Aquí es importante que:

  • extraemos el usuario y los scopes del token (getUserFromRequest);
  • se los pasamos al servidor MCP mediante el contexto { user };
  • si falta o hay error con el token, devolvemos nuestro 401 con WWW-Authenticate.

La API concreta del SDK de MCP puede variar, pero la idea es la misma: envolver la llamada a MCP con un middleware que ya sepa «quién» llama.

Herramienta list_my_gifts con comprobación de scope

Veamos ahora la implementación de la propia herramienta. Supongamos que usamos un SDK de TypeScript para MCP y tenemos algo como:

// lib/mcpServer.ts (fragmento)
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 },
    };
  }
);

Hacemos tres pasos clave:

  • exigimos gifts:read antes de ejecutar el código principal;
  • usamos ctx.user.sub como identificador del usuario (desde el token);
  • devolvemos datos solo de ese usuario.

Así, tu herramienta deja de ser una «API general» y se vuelve personalizada — vinculada a la identidad del Auth Server.

7. Resumen del flujo: de 401 a la llamada exitosa

Para fijarlo todo, armemos un mini esquema del flujo que ahora implementa tu servidor MCP protegido.

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

Atención al parámetro resource en las peticiones al Auth Server: se copia a la aud del token y debe coincidir con resource en .well-known/oauth-protected-resource.

8. Pequeña comprobación práctica con curl

Para mayor tranquilidad podemos hacer dos peticiones a mano.

Primero: intentar invocar el MCP sin token:

curl -i http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"method":"tools/call","params":{"name":"list_my_gifts","arguments":{}}}'

Esperamos ver el estado 401 y nuestro WWW-Authenticate con resource_metadata y scope="gifts:read".

Segundo: con un token válido (obtenido del 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":{}}}'

Ahora, si abc123 es un JWT válido con iss, aud="http://localhost:3000" correctos y scope incluye gifts:read, obtendrás la respuesta JSON de la herramienta y en structuredContent.gifts aparecerán los regalos del usuario actual.

9. Errores típicos al configurar MCP Server como recurso protegido

A continuación, un conjunto de situaciones frecuentes que suelen aparecer al implementar justo el código que acabamos de escribir: .well-known, WWW-Authenticate, verificación del token y comprobación de scopes.

Error n.º 1: resource y audience desincronizados.
A menudo en .well-known/oauth-protected-resource se publica un valor de resource, y en el Auth Server se emite otro aud en los tokens. Como resultado, jwtVerify descarta el token incluso si la firma y la caducidad están bien. Es especialmente fácil romper esto cuando cambias el dominio/puerto del servidor MCP y olvidas actualizar o bien .well-known o la configuración del Auth Server. En nuestro ejemplo es la misma cadena http://localhost:3000 en el campo resource de .well-known y en EXPECTED_AUD dentro de verifyAccessToken. Conviene definir una única constante RESOURCE_ID y usarla en ambos sitios para evitar divergencias.

Error n.º 2: ausencia de WWW-Authenticate con 401.
A veces los desarrolladores simplemente devuelven 401 o 403 sin la cabecera WWW-Authenticate. Desde la perspectiva del navegador puede ser aceptable, pero ChatGPT y MCP Jam no sabrán a dónde ir a por el token ni qué scopes se requieren. En consecuencia, considerarán tu servidor MCP «roto» y no mostrarán al usuario el UI de enlace. El mínimo necesario: WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource". Mejor añadir también scope="..." para que el flujo sea más claro. Nuestro helper unauthorized() precisamente garantiza que con 401 esa cabecera siempre esté presente.

Error n.º 3: confiar en el token sin validar la firma y iss.
A veces, especialmente al principio, la tentación es grande: «Es un token del Auth Server, hagamos JSON.parse(atob(..)) y listo». No se puede hacer así: entonces aceptarías cualquier token con el formato adecuado, incluso uno falsificado. El enfoque correcto es cargar las claves por jwks_uri y validar la firma y iss/aud mediante una biblioteca (jose, jsonwebtoken, etc.). Solo después puedes confiar en el contenido de los claims.

Error n.º 4: mezclar la validación del token con la lógica de negocio.
A veces la validación del token se dispersa por el código de las herramientas: una herramienta comprueba el scope, otra no; en algún sitio se olvidan de comprobar el aud, y en otro incluso aceptan un id de usuario pasado como argumento de la herramienta. Esto conduce a bugs extraños y posibles vulnerabilidades. Es mejor mantener una separación clara: el middleware a nivel HTTP se ocupa del token (firma, iss, aud, caducidad) y, en la herramienta, te apoyas en ctx.user como «fuente de verdad» y solo añades comprobaciones de negocio (por ejemplo, rol/tenant).

Error n.º 5: discrepancia entre scopes_supported y los scopes realmente usados.
Otro caso común: en .well-known/oauth-protected-resource publicas un conjunto de scopes, en el Auth Server hay otro, y en las herramientas compruebas un tercero. ChatGPT/MCP Jam forman la solicitud de autorización en base a los scopes_supported publicados, y tu servidor luego se queja de que falta el scope requerido. Intenta minimizar el número de scopes y gestionarlos como «fuente única de verdad», por ejemplo, mediante un enum en TypeScript que se use tanto para generar .well-known como para configurar los clientes en el Auth Server.

Error n.º 6: confiar solo en securitySchemes del Apps SDK y olvidar la verificación en el servidor.
Apps SDK permite describir securitySchemes para las herramientas (noauth, oauth2, scopes), y ChatGPT mostrará al usuario el UX correcto. Pero esas anotaciones no vuelven seguro el servidor automáticamente. Incluso si una herramienta declara que requiere un token OAuth, tu servidor MCP sigue obligado a verificar el token, el issuer, la audience y los scopes en cada petición. De lo contrario, se podrían eludir las comprobaciones enviando la petición directamente al URL del MCP.

Error n.º 7: olvidarse de la corta vida de los tokens y del manejo de la expiración.
Si los access tokens viven demasiado tiempo, reduces la seguridad; si viven demasiado poco pero el servidor no maneja bien la expiración, el usuario se topará constantemente con errores. El modelo correcto es un access token de vida corta y que el servidor MCP esté listo para devolver 401 con WWW-Authenticate cuando exp ya haya pasado. El cliente (ChatGPT) repetirá el flujo OAuth y actualizará el token.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION