Completar Keycloak para MCP: el recurso protegido que ningún SDK te da hecho, y la extensión empresarial que se salta el flujo entero

Continuación de Keycloak en una plataforma de IA, que dejaba señalado el hueco: los indicadores de recurso no están soportados y los metadatos de recurso protegido corresponden al servidor MCP. Este artículo trata de cerrarlo. Verificado contra la revisión 2026-07-28 de la especificación, los SDK oficiales de Python y TypeScript, Keycloak 26.7.3 y LiteLLM 1.102.0.

TL;DR

La especificación reparte tres obligaciones y el servidor de autorización solo cubre una. El servidor MCP debe publicar sus metadatos de recurso protegido. El cliente debe mandar el indicador de recurso en las dos peticiones, y debe hacerlo aunque el servidor de autorización no lo soporte. El servidor MCP debe validar que el token se emitió para él. Keycloak no entiende el indicador y considera los metadatos ajenos, así que las tres acaban en la capa del recurso.

El SDK de Python monta los metadatos solo, pero degradados. En cuanto se configura la URL del servidor de recursos, la ruta aparece. Lo que publica esa ruta automática lleva un solo servidor de autorización, reutiliza como catálogo de ámbitos los que exige el middleware, y deja el nombre y la documentación a nulo. Para publicar un documento completo hay que montar la ruta a mano.

La validación de audiencia viene apagada en Python y no existe en TypeScript. En Python, si se configura la URL del recurso y no se activa el flag correspondiente, el SDK emite un aviso de obsolescencia y se comporta como si estuviera desactivado; el propio docstring promete que la versión 3 lo pondrá a cierto. En TypeScript la verificación del portador hace tres cosas, y ninguna es mirar la audiencia. Ninguno de los dos trae un verificador de firma con claves públicas.

El paso del token está prohibido por escrito, y el sustituto tiene nombre. La especificación dice que el servidor MCP no debe aceptar ni retransmitir tokens que no se emitieran para él, y que si llama a APIs de más arriba el token debe ser otro. El mecanismo es el intercambio de token, que Keycloak soporta en su versión estándar con una limitación que hay que conocer: la audiencia es un identificador de cliente, no una URL de recurso.

Hay una extensión oficial que cambia el dibujo entero, y está estable. La autorización gestionada por la empresa sustituye la redirección al servidor de autorización del MCP por un intercambio en el proveedor de identidad corporativo, que evalúa la política y emite una concesión basada en aserción de identidad. Es estable desde junio de 2026 y hay servidores en producción.

Keycloak la implementa a medias y en experimental. Solo sabe actuar como receptor, no como emisor, detrás de una bandera de función, y contra el borrador 01 cuando el grupo de trabajo va por el 04. La documentación oficial dice literalmente que no se use en producción.

LiteLLM ya la implementa del lado cliente y no lo documenta. En 1.102.0 hay un modo de autenticación hacia servidores MCP que ejecuta las dos etapas de esa concesión. No hay un solo fichero de documentación que lo mencione.

Estás aquí: el lado del recurso

El artículo anterior recorrió el proveedor de identidad. Este recorre lo que hay al otro lado del token, que es donde la especificación de MCP pone casi todas sus obligaciones normativas.

El orden de lectura natural es Cuando el MCP crece para el montaje básico, Keycloak en una plataforma de IA para la pieza de identidad, y este para la capa que falta. El gateway MCP visto desde dentro está en su propio artículo.

La analogía: el visado y el control de frontera

Un consulado emite visados. Un puesto fronterizo los comprueba. Son dos oficinas distintas, y el error clásico consiste en suponer que porque el visado sea auténtico sirve para entrar por cualquier puerta.

El visado lleva escrito para qué país vale. Esa es la audiencia del token, y aquí aparece el problema: el consulado con el que trabajamos no sabe escribir el destino que pide el viajero, porque no entiende ese campo del formulario. Sabe escribir un destino si se le pide con otro nombre, mediante un sello preacordado, que es el rodeo de los ámbitos y el mapeador de audiencia.

Y queda la otra mitad, que es la que casi nadie monta. El puesto fronterizo tiene que existir, tiene que anunciar dónde está y de qué consulados acepta visados, y tiene que leer el destino escrito en el visado antes de dejar pasar. Si el puesto fronterizo se limita a comprobar que el sello es auténtico y no mira el destino, cualquier visado válido del mismo consulado sirve para entrar. Eso es exactamente lo que hacen por defecto los dos SDK oficiales.

Parte 1. El reparto de obligaciones

De la revisión 2026-07-28, con las palabras normativas tal cual:

ObligaciónDe quiénEstado con Keycloak
Publicar metadatos de recurso protegido (RFC 9728)Servidor MCP, MUSTFuera de su alcance, por diseño
Mandar el indicador de recurso (RFC 8707) en autorización y en tokenCliente, MUST, incluso si el servidor de autorización no lo soportaNo lo entiende
Validar que el token se emitió para uno mismoServidor MCP, MUSTResponsabilidad del recurso
No aceptar ni retransmitir otros tokensServidor MCP, MUST NOTResponsabilidad del recurso
Validar el emisor de la respuesta de autorización (RFC 9207)Cliente, MUST; servidor de autorización SHOULD emitirloSoportado
Consentimiento por cada cliente registrado dinámicamenteProxy MCP con identificador estático, MUSTConfigurable

Lo que más despista de ese reparto es la segunda línea. La especificación obliga al cliente a mandar el parámetro con independencia de que el servidor de autorización lo soporte. Con Keycloak, ese parámetro se pierde: la documentación oficial dice que no puede reconocerlo, y el comportamiento estándar ante un parámetro desconocido es ignorarlo. El cliente cumple, el token sale, y lo que no sale es la audiencia correcta. El fallo aparece más tarde y en otro sitio, que es el peor tipo de fallo.

Conviene saber dónde está ese trabajo. El asunto es la incidencia 14355 del proyecto, abierta, con hito en la 26.8.0. Hubo una implementación completa en la petición de cambios 35711, con mapeador propio y un punto de extensión para resolver recursos, que pasó a borrador en octubre de 2025 por falta total de pruebas y porque se decidió rehacerla por fases, empezando por un solo recurso por cliente. En marzo de 2026 se abrió una incidencia nueva para soporte experimental, también con hito en la 26.8.0 y sin petición de cambios asociada. En 26.7.x no hay bandera de función que lo active.

Novedades de la revisión que afectan al montaje

Cuatro cosas cambiaron respecto a la revisión anterior y conviene recogerlas:

  • RFC 9207. Sección nueva de validación de la respuesta de autorización. El cliente debe registrar el emisor del documento de metadatos validado, y la comparación es literal: no se permite normalizar mayúsculas, elidir el puerto por defecto, añadir o quitar la barra final ni recodificar caracteres antes de comparar.
  • Los tokens sin conexión salen del catálogo. Sección nueva sobre tokens de refresco: los servidores MCP no deberían incluir el ámbito correspondiente ni en la cabecera de reto ni en los ámbitos soportados de sus metadatos.
  • El registro dinámico queda obsoleto. Pasa de opcional a opcional y deprecado, retenido por compatibilidad con servidores de autorización que no soporten documentos de metadatos de identificador de cliente. Y se añade un requisito nuevo al registro: el tipo de aplicación es obligatorio, y un cliente no debe reutilizar credenciales de otro servidor de autorización, tiene que registrarse de nuevo.
  • La elevación por pasos se reescribe. Desaparece el menú de tres estrategias de servidor y se sustituye por dos reglas: el atributo de ámbito del reto describe lo que hace falta para el recurso pedido, sin obligación de incluir lo ya concedido, y la acumulación de ámbitos pasa a ser responsabilidad del cliente. Con un requisito nuevo para el servidor: debe tener en cuenta las jerarquías de ámbitos, donde uno amplio implica los estrechos.

Parte 2. Lo que los SDK dan, y lo que no

Aquí está la parte incómoda, y es la razón principal para escribir este artículo. Verificado leyendo el código del SDK de Python y del de TypeScript 2.0.0-alfa.

Los metadatos de recurso protegido

En Python existe el modelo completo y se monta solo. La clase de metadatos lleva los campos de la RFC: recurso, servidores de autorización con un mínimo de uno, URL del juego de claves, ámbitos soportados, métodos de portador con valor por defecto de cabecera, nombre, documentación, política, condiciones, y los campos de certificado de cliente y de DPoP. El manejador sirve el documento con una directiva de caché de una hora, y la URL se construye insertando la ruta conocida delante del camino del recurso, como pide la RFC.

En cuanto se configura la URL del servidor de recursos, la ruta aparece sin más trabajo. Pero lo que publica esa ruta automática está degradado en tres puntos: pasa un único servidor de autorización, toma como catálogo de ámbitos los que el middleware exige (que no son lo mismo que los que el recurso soporta), y no pasa ni el nombre ni la documentación, que salen a nulo. Para publicar un documento completo hay que llamar a la función de creación de rutas a mano.

En TypeScript existe y no se monta solo. La función que construye el documento emite solo cinco campos: recurso, servidores de autorización, ámbitos soportados, nombre y documentación. No emite los métodos de portador soportados, ni la URL del juego de claves, ni nada de DPoP. Y servirlo es explícito: o se llama a la función de respuesta desde el manejador propio, o se monta el enrutador de metadatos. No hay ningún punto del SDK que lo haga por su cuenta.

La validación de audiencia

Esta es la que hay que corregir el primer día.

En Python viene desactivada. El verificador de tokens es un protocolo de un solo método, es decir un hueco que rellena el implementador. El campo de recurso del token de acceso es el indicador que uno mismo pone. Y la comparación solo ocurre si se pide: hay una función que normaliza como URL e ignora la barra final, pero la URL del servidor de recursos le llega vacía salvo que se active el flag de validación.

El detalle que hay que leer dos veces está en la configuración: si la URL del recurso está puesta y el flag no, el SDK lanza un aviso de obsolescencia y se comporta como si el flag fuera falso. El docstring dice que la versión 3 pondrá el valor por defecto a cierto. Hasta entonces, un servidor Python configurado con autorización acepta tokens emitidos para otro recurso, salvo que el implementador active el flag o valide la audiencia dentro de su propio verificador.

En TypeScript no se valida en absoluto. La verificación del token portador hace exactamente tres cosas: separar el prefijo, comprobar los ámbitos requeridos y exigir que la expiración esté presente y no vencida. Una búsqueda de audiencia o de juego de claves sobre los paquetes de servidor y de middleware no devuelve ninguna validación. Todo queda en manos del verificador que se enchufe.

Ninguno de los dos trae un verificador de firma con claves públicas. En Python el único ejemplo es introspección contra el servidor de autorización, y su comprobación de audiencia está detrás de un flag que también viene a falso. En TypeScript la interfaz está vacía. Es decir: el componente que valida el token, que es el que sostiene el requisito normativo más importante de la especificación, es código propio en los dos casos.

El reto de autenticación

Python construye el reto con el error y su descripción, y añade la URL de los metadatos solo si está configurada. Devuelve 401 con token inválido y 403 con ámbito insuficiente, ambos por la misma función, así que los dos llevan la URL de metadatos. Lo que no emite nunca es el parámetro de ámbito, que la revisión 2026-07-28 pide que se incluya, y que es justo lo que el cliente necesita para saber qué pedir.

TypeScript sí emite el ámbito cuando hay ámbitos requeridos, además de la URL de metadatos, y mapea correctamente el token inválido a 401 y el ámbito insuficiente a 403.

Es decir, cada SDK tiene bien una mitad distinta. El de Python valida mejor y avisa peor; el de TypeScript avisa mejor y no valida.

Los ámbitos, que no son por herramienta

En los dos SDK los ámbitos son una lista estática a nivel de montaje del transporte, no por herramienta. No hay declaración de ámbito por herramienta ni ninguna ayuda de elevación por pasos del lado servidor. Emitir un 403 con el ámbito concreto que exige esa llamada, que es lo que la especificación describe, es código propio.

Ahí está el origen de casi toda la autorización fina que hay que construir, y por eso la Parte 6.

Qué revisión anuncia cada uno

Python ya está en 2026-07-28. La revisión aparece en las versiones conocidas y en la lista de versiones modernas, descritas como las que usan el sobre sin estado por petición. El método de descubrimiento del servidor está registrado con manejador por defecto, y las cabeceras de método y nombre se validan contra el cuerpo.

TypeScript la tiene, pero en una lista aparte. La constante de última versión de protocolo sigue en 2025-11-25, porque esa lista es solo la del saludo inicial. La era moderna vive en un módulo separado, con un comentario que explica la razón: mantenerlas deliberadamente separadas para que añadir una revisión ahí nunca filtre una cadena de versión moderna a un saludo de la era 2025.

Y conviene recordar el desajuste que ya salió en el artículo del gateway MCP: LiteLLM 1.102.0 sigue anunciando 2025-06-18.

Parte 3. Construir el recurso protegido

Con lo anterior, la lista de lo que hay que escribir queda corta y concreta.

1. Un verificador de token que valide la firma y la audiencia. Contra el juego de claves del realm, comprobando emisor, expiración y que la audiencia contiene la URL canónica del servidor MCP y solo cosas que le conciernen. Aquí entra el efecto secundario del rodeo de Keycloak: si un cliente pide dos ámbitos de dos recursos distintos, sale un token con dos audiencias, porque cada mapeador aporta la suya al array. No hay condición ni ejecutor de política de cliente en 26.7 que limite el número de audiencias por token. La defensa práctica es del recurso: rechazar tokens cuya audiencia incluya recursos ajenos, en lugar de limitarse a comprobar que la propia está presente.

2. El documento de metadatos completo, con todos los servidores de autorización que se acepten, el catálogo real de ámbitos que el recurso entiende, y el nombre y la documentación rellenos. En Python, montando la ruta a mano en lugar de dejar la automática. En TypeScript, montándola, a secas.

3. El reto de autenticación con el ámbito. En Python hay que añadirlo, porque el SDK no lo emite. Y hay que tener en cuenta las jerarquías de ámbitos, que es requisito nuevo de la revisión.

4. La decisión entre validación local e introspección. Validar la firma en local no cuesta red, pero la revocación no surte efecto hasta que el token expira: no hay listas de revocación para tokens firmados. La introspección cuesta una ida y vuelta por petición y solo la pueden invocar clientes confidenciales. La combinación razonable es vida de token corta y introspección en las operaciones que cambien estado.

5. El enlace del estado al usuario. La revisión nueva es explícita sobre esto, porque al desaparecer las sesiones de protocolo el estado se lleva en manejadores que viajan como argumento ordinario de herramienta. Los servidores deben verificar toda petición entrante y no deben tratar la posesión de un manejador como autenticación; y deberían atar el manejador al usuario del lado servidor, por ejemplo guardando el estado con una clave que combine el identificador de usuario derivado del token verificado con el manejador, y rechazar el manejador si lo presenta otro. Es un cambio de forma de trabajar respecto a las sesiones de antes.

Parte 4. El salto del gateway al servidor MCP

La figura real en una plataforma de inferencia no es cliente contra servidor MCP: es cliente contra gateway, y gateway contra servidor MCP. Ese segundo salto es donde se decide si la arquitectura es correcta.

La especificación no deja margen. El servidor MCP no debe aceptar ni retransmitir tokens que no se emitieran para él. Y si hace peticiones a APIs de más arriba, puede actuar como cliente OAuth de ellas, pero el token que use ahí es otro token, emitido por el servidor de autorización de arriba, y no debe reenviar el que recibió.

El mecanismo estándar para eso es el intercambio de token, y aquí hay que conocer tres detalles de Keycloak.

Primero, el modelo de permisos cambió. La versión antigua exigía permisos de administración finos y una autorización explícita de intercambio sobre el cliente destino. La versión estándar, soportada desde la 26.2, no los exige: basta con que el cliente solicitante sea confidencial y tenga activado el interruptor correspondiente. A cambio hay una condición que sorprende: el token del sujeto tiene que llevar al cliente solicitante en su audiencia, salvo que intercambie su propio token. Es decir, el gateway necesita aparecer en la audiencia del token de usuario, lo que se consigue con un mapeador de audiencia en un ámbito por defecto del gateway. Solo entonces puede reducir la audiencia a la del servidor MCP concreto.

Segundo, la audiencia es un identificador de cliente, no una URL de recurso. El parámetro filtra audiencias, es decir reduce, que es lo que se quiere. Pero toma el identificador de un cliente registrado en el realm. La consecuencia práctica es que el servidor MCP tiene que estar dado de alta como cliente, y su identificador debería ser su URL canónica, para que la audiencia resultante coincida con lo que el recurso valida. Es un truco de nomenclatura, y hay que documentarlo para quien venga detrás. La propia documentación lo reconoce: el intercambio de token todavía no soporta el parámetro de recurso.

Tercero, el tipo de token del sujeto está limitado. La versión estándar solo acepta tokens de acceso como sujeto.

Hay además una función experimental nueva en la 26.7 que añade un tipo de ámbito parametrizado para validar si el usuario solicitante está autorizado a actuar en nombre de otro. Interesante para delegación, pero experimental.

Lo que hace hoy el gateway

En LiteLLM 1.102.0 los modos de autenticación hacia servidores MCP son doce, con valor por defecto ninguno. Los que importan para esta discusión son cuatro.

oauth2_token_exchange implementa el intercambio estándar. Manda el tipo de concesión correcto, el token del sujeto y su tipo, y la audiencia, nunca el recurso. La omisión es deliberada según el propio código: fabricar un destino arriesga un error de destino inválido. Cachea el token resultante con una clave que combina el token del sujeto con toda la configuración, y con vida útil igual a la del token menos un minuto. Y bloquea, nunca degrada: sin token entrante devuelve 401, que el borde convierte en un reto con la URL de metadatos de recurso; un rechazo del proveedor de identidad devuelve 401; un error de configuración del gateway devuelve 500; un fallo de transporte, 503.

true_passthrough y oauth_delegate reenvían la cabecera de autorización del cliente tal cual. Eso es paso de token, con el nombre que le da la especificación, y su uso legítimo es estrecho: cuando el token que el cliente presenta ya se emitió para el servidor MCP de destino y el gateway es un mero transporte. Fuera de ese caso, incumple.

oauth2_id_jag es la sorpresa, y merece su propia parte.

Parte 5. La extensión empresarial, que cambia el dibujo

En junio de 2026 el proyecto MCP publicó una extensión de autorización que resuelve un problema distinto del que resuelve el flujo estándar, y que en una organización con proveedor de identidad propio es el problema de verdad.

El flujo normal es de usuario: cada empleado autoriza cada cliente contra cada servidor MCP. Funciona para aplicaciones de consumo y no funciona en una empresa, porque el alta de una persona exige autorizar decenas de servicios uno a uno y la baja exige revocarlos uno a uno.

La extensión io.modelcontextprotocol/enterprise-managed-authorization invierte eso. Está en el directorio de especificaciones estables del repositorio de extensiones, viene del SEP-990, y el flujo es este:

  1. El cliente MCP autentica al usuario contra el proveedor de identidad corporativo por el flujo normal, y guarda la aserción de identidad, que puede ser un token de identidad de OpenID o una aserción SAML.
  2. Cuando el servidor indica que hace falta autorización gestionada por la empresa, el cliente intercambia esa aserción en el proveedor corporativo por una concesión de autorización basada en aserción de identidad. El proveedor evalúa ahí la política de la organización: pertenencia a grupos, roles, acceso condicional.
  3. El cliente presenta esa concesión al servidor de autorización del MCP y obtiene un token de acceso.
  4. La frase que define la extensión, literal: no se redirige al usuario al endpoint de autorización del servidor de autorización del MCP.

El servidor de autorización del MCP valida la firma contra el juego de claves del proveedor corporativo, más audiencia, emisor y expiración, y usa el sujeto como identificador estable del usuario, con el correo como alternativa para enlazar cuentas anteriores.

Lo que eso compra es exactamente lo que pide un expediente de cumplimiento: política en un solo sitio, decisión auditable en el proveedor de identidad, y revocación centralizada que surte efecto en todos los clientes a la vez. El empleado que pierde el acceso deja de recibir concesiones, sin tocar ningún servidor.

El estándar de debajo es un borrador del grupo de trabajo de OAuth del IETF, con el nombre de concesión de autorización por aserción de identidad en JWT, revisión 04 de mayo de 2026, firmado por gente de Okta, Ping Identity y un autor independiente. Perfila el encadenamiento de identidad entre dominios de confianza combinando el intercambio de token con el perfil de JWT.

Y aquí viene el problema

Keycloak lo implementa solo a medias. Tiene página propia en la documentación, detrás de una bandera de función. Y tres limitaciones que hay que tener delante antes de diseñar nada:

  • Solo actúa como receptor. Acepta aserciones emitidas por un proveedor externo y emite tokens locales. El soporte nativo para actuar como emisor, dice la documentación, todavía no está completamente implementado. En el flujo de la extensión, el emisor es el proveedor corporativo: si ese proveedor es Keycloak, la pieza que falta es justo la que hace falta.
  • Está basado en el borrador 01 mientras el grupo de trabajo va por el 04.
  • La documentación oficial dice que no se use en producción.

La asimetría es llamativa y vale la pena señalarla: Keycloak no soporta un estándar con ocho años de publicado como son los indicadores de recurso, y sí soporta parcialmente un borrador de hace meses.

Lo que ya está en el gateway, sin documentar

En LiteLLM 1.102.0, el modo oauth2_id_jag ejecuta las dos etapas del flujo. En la primera pide al proveedor corporativo un intercambio de token con el tipo de token solicitado propio de esta concesión, mandando el token de identidad del usuario como sujeto y, opcionalmente, audiencia, recurso y ámbito. En la segunda presenta la aserción resultante al servidor de autorización del recurso con el tipo de concesión de portador de JWT. En las dos el gateway se autentica como cliente con JWT firmado por clave privada.

Dos avisos operativos que salen del código y que no están en ningún sitio más:

  • El sujeto sale del token de identidad del llamante, o del que se capturó en el inicio de sesión. Y solo el proveedor OIDC genérico captura aserciones: con Google, Microsoft o SAML no hay ninguna, y todos los usuarios fallan. El código emite un aviso al cargar la configuración.
  • Un servidor mal configurado rehúsa con 500 en lugar de caer a la credencial estática. Es la decisión correcta, y conviene saberla antes de que pase.

Y el aviso principal: no hay documentación. Ni un fichero en el repositorio que mencione este modo. Quien lo use está leyendo código, como este artículo.

Parte 6. La autorización por herramienta

Ninguna de las piezas anteriores responde a la pregunta que acaba haciendo el negocio: si esta persona puede llamar a esta herramienta con estos argumentos. El proveedor de identidad da roles y ámbitos, y con eso no se modela un grafo de relaciones, como ya se argumentó en el artículo anterior.

Lo que hay publicado hoy, con fuente:

  • OpenFGA es el único con guía de producto dedicada, actualizada el 9 de septiembre de 2026. El patrón es un tipo de herramienta con una relación de invocación, comprobada en cada llamada, y una consulta de listado para que el cliente solo vea las herramientas que puede invocar. Es patrón y lenguaje de modelado, no implementación de referencia.
  • Pomerium trae un criterio de política por herramienta, con coincidencia exacta, por prefijo, por sufijo y por lista.
  • Kong lo ofrece como producto, con listas de control por herramienta.
  • Traefik Hub llega hasta restricciones a nivel de parámetro, que es el grado más fino publicado.
  • Envoy tiene un filtro MCP que extrae atributos del protocolo para control de acceso fino, declarado en desarrollo activo y sin OAuth propio: el gancho es la autorización externa hacia un motor de políticas.

La opción de menor fricción en una plataforma que ya tiene malla de servicio es la autorización externa contra un motor de políticas. La de mayor expresividad, el modelo de relaciones.

Parte 7. Lo que sigue sin estándar

Dos huecos que no cubre la especificación y que hay que cerrar por fuera.

El cambio de definición de herramienta. Ni la revisión 2026-07-28 ni los SDK fijan hash de la descripción ni del esquema de entrada. La página de buenas prácticas cubre delegado confuso, paso de token, falsificación de petición del lado servidor en el descubrimiento, secuestro de manejadores de estado, confusión de servidores y validación del esquema de la URL de autorización, pero no hay ningún requisito de integridad ni de fijación. La herramienta que lo hace es el escáner de Invariant Labs, con licencia Apache 2.0, que fija hashes para detectar el cambio de definición tras la aprobación y trae además un modo proxy con guardarraíles. Ya salió en el artículo del gateway MCP y sigue siendo la única respuesta.

La guía pública que sí entra en esto es la de la NSA sobre consideraciones de diseño de seguridad en MCP, de mayo de 2026, hecha con el instituto de ingeniería de software de Carnegie Mellon. Dos observaciones suyas son directamente accionables: que la autorización en MCP es opcional y que la especificación no impone ningún requisito de gestión del ciclo de vida de los tokens, de modo que expiración y rotación quedan en manos de la organización; y la advertencia contra el descubrimiento dinámico de herramientas sin verificación de origen ni comprobaciones de autorización, que es el reconocimiento más directo del riesgo en una guía estatal.

Parte 8. Quién cubre qué

Con todo lo anterior, la tabla de decisión de la capa que se pone delante del servidor MCP:

PiezaPublica metadatos de recursoValida audienciaIntercambio de tokenAutorización por herramienta
SDK Python, ruta automáticaSí, degradadoNo por defectoNoNo
SDK TypeScriptNo se monta soloNoNoNo
Traefik Hub (comercial)Sí, automáticoNo documentadoNo documentadoSí, hasta parámetro
Kong, plugin de OAuth para MCPNo documentadoNo documentadoSí, listas por herramienta
PomeriumParcialNo documentadoNo documentadoSí, criterio por herramienta
agentgateway (Solo.io)Sí, por política JWTSí, incluida la concesión de aserciónSí, con expresiones
mcp-context-forge (IBM)No en notas de versiónNo documentadoSí, desde 1.0.6Sí, control por rol
Envoy, filtro MCPNoNoNoVía autorización externa
LiteLLM 1.102.0Sí, y también de servidor de autorizaciónNo sobre el JWT entranteSí, y concesión de aserciónSí, permisos por clave y equipo

Dos avisos sobre esa tabla. El plugin de Kong exige versión mínima 3.12, está en vista previa técnica y necesita licencia de su edición de IA; además introduce un cambio incompatible en 3.13, que pasa a tratar todo el tráfico como MCP para cerrar un posible salto de autenticación. Y de oauth2-proxy no encontré documentación primaria ni a favor ni en contra del modo de recurso protegido: no afirmo que no lo tenga, afirmo que no está documentado donde pude mirar.

Configuración de referencia

Keycloak: el ámbito por recurso, opcional, con su audiencia. Que sea opcional y no por defecto es la pieza clave, porque es lo que hace que el cliente tenga que pedirlo explícitamente y funcione como sustituto del indicador de recurso:

kcadm.sh create client-scopes -r plataforma \
  -s name=mcp:inventario -s protocol=openid-connect \
  -s 'attributes."include.in.token.scope"=true'

kcadm.sh create "client-scopes/<id>/protocol-mappers/models" -r plataforma \
  -s name=aud-mcp-inventario \
  -s protocolMapper=oidc-audience-mapper \
  -s 'config."included.custom.audience"=https://mcp.ejemplo.es/inventario' \
  -s 'config."access.token.claim"=true'

Y el servidor MCP dado de alta como cliente con su URL canónica por identificador, para que el intercambio de token produzca la audiencia correcta:

kcadm.sh create clients -r plataforma \
  -s clientId=https://mcp.ejemplo.es/inventario \
  -s enabled=true -s publicClient=false -s consentRequired=true

El gateway, con intercambio de token en lugar de paso de token:

mcp_servers:
  inventario:
    url: "https://mcp.ejemplo.es/inventario"
    transport: "http"
    auth_type: "oauth2_token_exchange"
    client_id: "litellm-gateway"
    client_secret: os.environ/GW_SECRET
    token_exchange_endpoint: "https://sso.ejemplo.es/realms/plataforma/protocol/openid-connect/token"
    audience: "https://mcp.ejemplo.es/inventario"
    subject_token_type: "urn:ietf:params:oauth:token-type:access_token"
    token_exchange_profile: "rfc8693"

El verificador del recurso, que es lo que nadie da hecho. Lo esencial es la última comprobación, la que rechaza audiencias ajenas en lugar de conformarse con encontrar la propia:

RECURSO = "https://mcp.ejemplo.es/inventario"

async def verificar(token: str) -> AccessToken | None:
    claims = jwt.decode(
        token, await jwks(),
        algorithms=["RS256"],
        audience=RECURSO,          # valida que estoy yo
        issuer=ISSUER,
    )
    aud = claims["aud"]
    aud = [aud] if isinstance(aud, str) else aud
    # y que no hay nadie más: el rodeo de los ámbitos permite
    # tokens con dos audiencias, y eso reabre el delegado confuso
    if set(aud) - {RECURSO, CLIENTE_GATEWAY}:
        return None
    return AccessToken(
        token=token,
        client_id=claims["azp"],
        scopes=claims.get("scope", "").split(),
        expires_at=claims["exp"],
        resource=RECURSO,
    )

Y al montar el servidor, el flag que no viene puesto:

auth = AuthSettings(
    issuer_url=ISSUER,
    resource_server_url=RECURSO,
    validate_token_resource=True,   # sin esto solo hay un warning
    required_scopes=["mcp:inventario"],
)

Checklist

  1. Activar la validación de audiencia en el SDK de Python, o escribirla en el verificador si es TypeScript. Sin eso, el requisito normativo central no se cumple.
  2. Rechazar tokens con audiencias ajenas, no solo comprobar que la propia está presente.
  3. Montar el documento de metadatos a mano, con todos los servidores de autorización y el catálogo real de ámbitos.
  4. Añadir el parámetro de ámbito al reto, en Python, y contemplar jerarquías de ámbitos.
  5. Dar de alta cada servidor MCP como cliente de Keycloak con su URL canónica por identificador, para que el intercambio de token produzca la audiencia correcta.
  6. Sustituir cualquier modo de paso de token por intercambio de token, salvo el caso estrecho en que el token ya se emitió para el destino.
  7. Comprobar que el gateway aparece en la audiencia del token de usuario, o el intercambio fallará.
  8. Atar los manejadores de estado al usuario del lado servidor, y no tratarlos nunca como autenticación.
  9. Decidir entre validación local e introspección sabiendo que la primera retrasa la revocación hasta la expiración, y acortar la vida del token en consecuencia.
  10. Fijar hash de descripción y esquema de entrada por fuera, porque no lo cubre la especificación.
  11. Poner un motor de políticas para la autorización por herramienta, y no intentar modelarla con roles.
  12. Si se plantea la extensión empresarial, comprobar antes quién emite la concesión: Keycloak hoy solo sabe recibirla, en experimental y contra un borrador anterior.

Trampas y cosas que no son lo que parecen

El SDK de Python monta el documento de metadatos solo, y por eso parece resuelto. Lo que publica lleva un solo servidor de autorización y sin nombre ni documentación.

El aviso de obsolescencia de la validación de audiencia no activa nada. Avisa y sigue comportándose como desactivado.

El SDK de TypeScript no valida la audiencia en absoluto. No es configuración, es que no existe código para ello.

El reto de Python no lleva el ámbito, así que un cliente que reciba un 403 no sabe qué pedir.

El rodeo de los ámbitos permite dos audiencias en el mismo token. Cada mapeador aporta la suya, y no hay política de cliente que lo limite. La defensa está en el recurso.

La audiencia del intercambio de token es un identificador de cliente, no una URL. Si el servidor MCP no está dado de alta con su URL por identificador, la audiencia resultante no coincidirá con lo que el recurso valida.

El intercambio de token exige que el gateway esté en la audiencia del token de usuario. Es la condición que más tiempo hace perder la primera vez.

El registro dinámico de clientes está deprecado desde esta revisión, y el sustituto, los documentos de metadatos de identificador de cliente, es experimental en Keycloak y tiene un fallo abierto con documentos que traen campos desconocidos, lo que bloquea el inicio de sesión con clientes reales.

La concesión de aserción de identidad de LiteLLM depende del proveedor OIDC genérico. Con Google, Microsoft o SAML no se captura ninguna aserción y fallan todos los usuarios.

La elevación por pasos ya no es responsabilidad del servidor. La acumulación de ámbitos pasó al cliente en esta revisión.

La señal de elevación de autenticación va en un 401, no en un 403. El ámbito insuficiente es 403; pedir un nivel de autenticación mayor es otra cosa y otro código.

Cierre

El resumen de este artículo cabe en una frase: el proveedor de identidad es la mitad barata del problema, y la mitad cara es el recurso protegido, que ningún SDK da hecho y que en los dos oficiales viene con la validación de audiencia desactivada o ausente.

De ahí sale un orden de trabajo. Primero el verificador de tokens, con audiencia validada en los dos sentidos, porque sin él lo demás es decoración. Segundo, el documento de metadatos completo, montado a mano. Tercero, sustituir cualquier paso de token por intercambio, con el servidor MCP dado de alta con su URL por identificador. Cuarto, un motor de políticas para lo que los roles no modelan.

Y una decisión de calendario que conviene tomar con la información delante. La extensión de autorización gestionada por la empresa es el camino correcto para una organización con proveedor propio, está estable y tiene servidores en producción detrás. Lo que no está listo es Keycloak como emisor. Quien quiera ese flujo hoy tiene dos opciones honestas: delegar la mecánica en un gateway que la implemente, o esperar. Ponerlo en producción con una función que la documentación oficial marca como experimental y desaconseja expresamente no es una de ellas.

Ver también

Fuentes