Cómo proteger tu servidor MCP con Keycloak y OAuth en Docker
Índice de contenidos
- Puntos clave
- Qué papel tiene Keycloak en la autorización de MCP
- Qué parte de la especificación MCP no cumple Keycloak
- Cómo levantar Keycloak y PostgreSQL con Docker
- Cómo crear el realm y el ámbito mcp:tools con kcadm
- Cómo proteger el servidor FastMCP con Keycloak
- Qué responde el servidor MCP sin token
- Cómo se conecta un cliente MCP real con OAuth
- Qué pasa si llega un token emitido para otra aplicación
- Por qué los valores por defecto de KeycloakAuthProvider fallan en un realm nuevo
- Por qué mcp:tools no debe ser un ámbito por defecto del realm
- Qué cambia con CIMD, VS Code y Claude Code
- Qué revisar antes de llevarlo a producción
- Preguntas frecuentes
- ¿Keycloak es compatible con la última especificación de MCP?
- ¿Necesito el registro dinámico de clientes?
- ¿Basta con que el token esté firmado por mi realm?
- Conclusión
- Fuentes
Keycloak 26.7.4 puede ser el servidor de autorización de un servidor MCP: publica sus metadatos, admite registro dinámico de clientes y emite tokens JWT que FastMCP valida. No implementa RFC 8707, así que la audiencia del token se fija con un ámbito y un mapper, y el servidor MCP debe comprobarla siempre.
Keycloak puede autorizar el acceso a tu servidor MCP, pero cumple la especificación actual solo en parte: no reconoce el parámetro resource de RFC 8707, que es lo que ata cada token a un servidor concreto. En esta guía monto Keycloak 26.7.4 con PostgreSQL en Docker, creo el realm con kcadm.sh, protejo un servidor FastMCP 4.0.10 y conecto un cliente MCP real que descubre el servidor de autorización, se registra solo y obtiene un token. Después pruebo qué ocurre sin token, con un token caducado y con uno emitido para otra aplicación. Todo corrió el 28 de septiembre de 2026 en una máquina Linux arm64. Tienes también la versión en inglés de esta guía.
Puntos clave
- Keycloak 26.7.4 cumple por completo solo la revisión 2025-03-26 de MCP; en las revisiones 2025-06-18, 2025-11-25 y 2026-07-28 le falta RFC 8707, según su propia guía.
- Keycloak ignora el parámetro
resourcesin devolver error: la audiencia del token sale de un ámbito de cliente con un mapper de audiencia. - Con el mapper, el token del cliente llevó
audigual ahttp://localhost:24200/mcpy FastMCP rechazó con 401 un token de otra aplicación del realm. - Con los valores por defecto de
KeycloakAuthProvider(ámbitoopenid, sin audiencia), el registro dinámico falló con 403 en un realm recién creado, y ese mismo servidor aceptó el token de otra aplicación. - El registro dinámico anónimo viene cerrado: hay que declarar los hosts de confianza y los ámbitos permitidos.
- Si el ámbito
mcp:toolses un ámbito por defecto del realm, cualquier cliente puede pedir un token para tu servidor MCP.
Qué papel tiene Keycloak en la autorización de MCP
En la autorización de MCP, tu servidor es un servidor de recursos OAuth 2.1 y Keycloak es el servidor de autorización que emite los tokens. La especificación de autorización de MCP 2026-07-28[1] obliga al servidor MCP a publicar sus metadatos de recurso protegido (RFC 9728), con la lista de servidores de autorización que acepta. El cliente lee ese documento, consulta los metadatos de Keycloak (RFC 8414) y sigue un flujo de código de autorización con PKCE.
La especificación también dice qué debe comprobar el servidor MCP en cada petición. Dice textualmente que los servidores "MUST validate that access tokens were issued specifically for them as the intended audience". Un token válido pero emitido para otro servicio tiene que acabar en un 401.
Si todavía no tienes un servidor, empieza por cómo construir tu propio servidor MCP. Para los cambios de la revisión 2026-07-28 más allá de la autorización, tienes el resumen de la especificación MCP 2026-07-28.
Qué parte de la especificación MCP no cumple Keycloak
La guía de Keycloak para MCP[2] cruza cada estándar que exige MCP con lo que Keycloak soporta. De las cinco revisiones de MCP, cuatro cubren la autorización, y Keycloak solo cumple del todo la 2025-03-26:
| Estándar | Qué exige MCP 2026-07-28 | Keycloak 26.7.4 |
|---|---|---|
| OAuth 2.1 | Obligatorio | Soportado |
| Metadatos del servidor de autorización (RFC 8414) | Obligatorio, o OpenID Connect Discovery | Soportado |
| Resource Indicators (RFC 8707) | Obligatorio | No soportado |
| Identificación del emisor (RFC 9207) | Recomendado | Soportado |
| Registro dinámico de clientes (RFC 7591) | Opcional y deprecado | Soportado |
| Client ID Metadata Document (CIMD) | Recomendado | Experimental |
RFC 8707 es la pieza que falta. Con ella, el cliente manda resource=http://localhost:24200/mcp y el servidor de autorización pone esa URL en la audiencia del token. La guía de Keycloak lo reconoce sin rodeos: "However, Keycloak cannot recognize resource parameter". Mientras la comunidad añade el soporte, propone usar el parámetro scope: un ámbito de cliente opcional con un mapper de audiencia que escribe la URL del servidor MCP en aud.
El atajo funciona, pero tiene dos consecuencias. La primera es que el ámbito hace de selector de recurso, así que cada servidor MCP del realm necesita su propio nombre de ámbito. La segunda es que Keycloak no valida lo que pide el cliente en resource: lo comprobé pidiendo un token con resource=http://localhost:24299/otro-servidor y scope=mcp:tools, y el token salió con la audiencia de mi servidor, sin error.
Cómo levantar Keycloak y PostgreSQL con Docker
El compose.yaml tiene tres servicios: db con postgres:18-alpine (PostgreSQL 18.6) y un healthcheck con pg_isready, keycloak y el servidor MCP. Keycloak arranca en modo start-dev, que sirve HTTP sin TLS y vale para desarrollo, no para producción:
keycloak:
image: quay.io/keycloak/keycloak:26.7.4
command: start-dev
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: cambia_esta_clave
KC_HOSTNAME: http://localhost:24280
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: cambia_esta_clave
ports:
- "127.0.0.1:24280:8080"
depends_on:
db: {condition: service_healthy}
networks: [mcpnet]
Las dos variables KC_HOSTNAME* resuelven el problema que más tiempo hace perder en este montaje. El emisor (iss) del token depende de la URL con la que Keycloak se ve a sí mismo, y el navegador del usuario entra por localhost:24280, mientras que el contenedor del servidor MCP solo alcanza keycloak:8080. KC_HOSTNAME fija el emisor en la URL pública, y KC_HOSTNAME_BACKCHANNEL_DYNAMIC deja que el servidor MCP descargue las claves públicas por la red interna.
En mi máquina los contenedores, la red y los puertos llevaban un prefijo para convivir con otros laboratorios; el resto de ficheros es el que ves aquí. La imagen de quay.io es multiarquitectura y corrió en arm64 con Java 21.0.12.1 y Quarkus 3.33.3.2. Uso la 26.7.4, del 16 de septiembre de 2026. Según sus notas de versión[3], corrige seis CVE, entre ellas una escalada en la que el rol impersonation podía suplantar a un administrador del realm.
Cómo crear el realm y el ámbito mcp:tools con kcadm
kcadm.sh viene dentro de la imagen de Keycloak y habla con la API de administración, así que todo el realm se crea con un script que puedes repetir. La primera mitad crea el realm mcp, una usuaria de prueba y el ámbito mcp:tools con su mapper de audiencia:
KC="docker exec keycloak /opt/keycloak/bin/kcadm.sh"
MCP_URL="http://localhost:24200/mcp"
$KC config credentials --server http://localhost:8080 --realm master \
--user admin --password cambia_esta_clave
$KC create realms -s realm=mcp -s enabled=true
$KC create users -r mcp -s username=ana -s enabled=true \
-s email=ana@example.com -s emailVerified=true \
-s firstName=Ana -s lastName=Prueba
$KC set-password -r mcp --username ana --new-password clave_de_ana
SCOPE_ID=$($KC create client-scopes -r mcp -i \
-s name=mcp:tools -s protocol=openid-connect \
-s 'attributes."include.in.token.scope"=true')
$KC create client-scopes/$SCOPE_ID/protocol-mappers/models -r mcp \
-s name=audiencia-mcp -s protocolMapper=oidc-audience-mapper \
-s protocol=openid-connect \
-s "config.\"included.custom.audience\"=$MCP_URL" \
-s 'config."access.token.claim"=true' \
-s 'config."id.token.claim"=false'
El valor de included.custom.audience tiene que coincidir con la URL del servidor MCP que anuncian sus metadatos, con la ruta /mcp incluida. Fíjate en que no añado mcp:tools a los ámbitos por defecto del realm, que es lo primero que hice y lo que desaconsejo más abajo.
La segunda mitad abre el registro dinámico anónimo, que Keycloak trae cerrado. La documentación del servicio de registro de clientes[4] lo explica: "By default, there is not any whitelisted host, so anonymous client registration is de-facto disabled". El script añade como hosts de confianza localhost, 127.0.0.1 y la puerta de enlace de la red de Docker, que es la IP desde la que llegan las peticiones del anfitrión, y permite pedir mcp:tools al registrarse:
policy_id() {
$KC get components -r mcp -q name="$1" --fields id,subType \
--format csv --noquotes | grep ',anonymous$' | cut -d, -f1
}
GW=$(docker network inspect mcpnet \
-f '{{(index .IPAM.Config 0).Gateway}}')
$KC update components/$(policy_id "Trusted Hosts") -r mcp \
-s "config.\"trusted-hosts\"=[\"localhost\",\"127.0.0.1\",\"$GW\"]"
$KC update components/$(policy_id "Allowed Client Scopes") -r mcp \
-s 'config."allowed-client-scopes"=["mcp:tools"]'
$KC create clients -r mcp -s clientId=portal-web \
-s publicClient=false -s serviceAccountsEnabled=true \
-s standardFlowEnabled=false -s secret=secreto_del_portal
El último comando crea portal-web, una aplicación cualquiera del mismo realm con cuenta de servicio. No tiene nada que ver con MCP: la uso después para probar un token con la audiencia equivocada.
Cómo proteger el servidor FastMCP con Keycloak
FastMCP 4.0.10, publicado el 25 de septiembre de 2026, trae KeycloakAuthProvider, que sirve los metadatos de recurso protegido y valida los tokens como JWT. Le paso un JWTVerifier propio por dos motivos. Las claves públicas se descargan por la URL interna de Docker, y quiero fijar la audiencia y el ámbito en lugar de aceptar los valores por defecto:
import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
from fastmcp.server.dependencies import get_access_token
REALM = os.environ["KC_REALM_URL"] # URL pública (el issuer)
INTERNO = os.environ["KC_INTERNAL_URL"] # la misma, dentro de Docker
AUDIENCE = os.environ.get("MCP_AUDIENCE") or None
SCOPES = os.environ.get("MCP_SCOPES", "mcp:tools").split()
verifier = JWTVerifier(
jwks_uri=f"{INTERNO}/protocol/openid-connect/certs",
issuer=REALM,
audience=AUDIENCE,
required_scopes=SCOPES,
)
auth = KeycloakAuthProvider(
realm_url=REALM,
base_url=os.environ["MCP_BASE_URL"],
token_verifier=verifier,
)
mcp = FastMCP("notas-protegidas", auth=auth)
JWTVerifier comprueba, por este orden, la firma contra las claves del realm, la caducidad, el emisor, la audiencia y los ámbitos. Las dos herramientas del servidor son mínimas. quien_soy devuelve las claims del token para ver con qué identidad llega cada llamada, y sumar es una herramienta cualquiera:
@mcp.tool
def quien_soy() -> dict:
"""Devuelve las claims del token con el que llamas."""
c = get_access_token().claims
keys = ("preferred_username", "azp", "aud", "scope")
return {k: c.get(k) for k in keys}
@mcp.tool
def sumar(a: int, b: int) -> int:
"""Suma dos enteros."""
return a + b
if __name__ == "__main__":
mcp.run(transport="http", host="0.0.0.0", port=8000)
La imagen parte de python:3.13-slim, instala fastmcp==4.0.10 con pip y corre como nobody. En el compose.yaml, el servicio recibe las URL por variables de entorno:
mcp:
build: ./server
environment:
KC_REALM_URL: http://localhost:24280/realms/mcp
KC_INTERNAL_URL: http://keycloak:8080/realms/mcp
MCP_BASE_URL: http://localhost:24200
MCP_AUDIENCE: ${MCP_AUDIENCE-http://localhost:24200/mcp}
MCP_SCOPES: ${MCP_SCOPES-mcp:tools}
ports:
- "127.0.0.1:24200:8000"
depends_on: [keycloak]
networks: [mcpnet]
MCP_AUDIENCE y MCP_SCOPES tienen valor por defecto para poder arrancar el mismo servidor con otra configuración en las pruebas de más abajo. Levanta todo con docker compose up -d --build y ejecuta el script del realm cuando Keycloak responda.
Qué responde el servidor MCP sin token
Una petición sin cabecera Authorization recibe un 401 con la pista que necesita el cliente para empezar el flujo:
$ curl -si -X POST http://localhost:24200/mcp \
-H 'Content-Type: application/json' -d '{}'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer scope="mcp:tools",
resource_metadata="http://localhost:24200/.well-known/
oauth-protected-resource/mcp"
He partido la cabecera en tres líneas para que quepa; el servidor la manda en una sola. El documento que apunta resource_metadata es el de RFC 9728, y dice qué recurso es este y quién emite sus tokens:
{
"resource": "http://localhost:24200/mcp",
"authorization_servers": ["http://localhost:24280/realms/mcp"],
"scopes_supported": ["mcp:tools"],
"bearer_methods_supported": ["header"]
}
scopes_supported sale de required_scopes del verificador, y es lo que el cliente pedirá después a Keycloak. Por eso importa tanto qué ámbito pones ahí.
Cómo se conecta un cliente MCP real con OAuth
El cliente de FastMCP hace el flujo completo con OAuth: descubre, se registra, abre el navegador y guarda el token. Le pido que se registre como cliente público (token_endpoint_auth_method a none), porque sin ese campo Keycloak lo dio de alta como cliente confidencial con un secreto que el cliente guarda en memoria:
import asyncio
from fastmcp import Client
from fastmcp.client.auth import OAuth
async def main():
auth = OAuth(
client_name="cliente-fastmcp-jacar",
callback_port=24210,
additional_client_metadata={"token_endpoint_auth_method": "none"},
)
async with Client("http://localhost:24200/mcp", auth=auth) as client:
tools = await client.list_tools()
print("herramientas:", [t.name for t in tools])
print("quien_soy:", (await client.call_tool("quien_soy")).data)
r = await client.call_tool("sumar", {"a": 2, "b": 40})
print("sumar:", r.data)
asyncio.run(main())
En el laboratorio no había nadie delante del navegador, así que la variable BROWSER apuntaba a un script de Playwright que rellenaba el login de ana y pulsaba Yes en la pantalla de consentimiento. Con el logger httpx2 en nivel INFO, que registra cada petición HTTP, esta es la secuencia que vi, con las URL recortadas:
POST /mcp 401
GET /.well-known/oauth-protected-resource/mcp 200
GET /.well-known/oauth-authorization-server/realms/mcp 200
POST /realms/mcp/clients-registrations/openid-connect 201
GET /realms/mcp/protocol/openid-connect/auth?...
code_challenge_method=S256
resource=http://localhost:24200/mcp
scope=mcp:tools offline_access 200
POST /realms/mcp/protocol/openid-connect/token 200
POST /mcp (x4) 200
herramientas: ['quien_soy', 'sumar']
sumar: 42

El cliente mandó resource, como pide la especificación, y Keycloak lo ignoró. La audiencia correcta llegó por el ámbito mcp:tools. Estas son las claims del token, sin los identificadores de sesión:
{
"iss": "http://localhost:24280/realms/mcp",
"aud": "http://localhost:24200/mcp",
"typ": "Bearer",
"azp": "5c8263fc-1d27-4b5b-bc08-b3fd3ec7de66",
"scope": "offline_access mcp:tools"
}
El token dura 300 s, el valor por defecto del realm. Pasado ese plazo, el mismo token recibió un 401 y el servidor registró token expired.
El cliente agregó además offline_access al ámbito y Keycloak le entregó un token de refresco, que sirve para renovar el acceso sin repetir el login. Cada ejecución con almacenamiento en memoria registró un cliente nuevo en Keycloak, cinco en cinco ejecuciones. La política de registro anónimo corta en 200 clientes por realm, así que en uso real conviene un almacén persistente para el token y el registro.
Qué pasa si llega un token emitido para otra aplicación
Un token firmado por tu realm no tiene por qué ser para tu servidor MCP. portal-web puede pedir un token con su cuenta de servicio, y ese token lleva aud igual a account y el ámbito openid profile email. Contra el servidor configurado arriba, el resultado es el esperado:
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", ...
WARNING Bearer token rejected for client portal-web:
audience mismatch (got 'account',
expected 'http://localhost:24200/mcp')
Después arranqué el mismo servidor con MCP_AUDIENCE vacío y MCP_SCOPES=openid, que reproduce la validación de KeycloakAuthProvider cuando no le pasas ni verificador ni audiencia. El token de portal-web pasó con un 200, y quien_soy devolvió azp igual a portal-web. La página de Keycloak en la documentación de FastMCP[5] ya lo avisa: sin audience, "your server accepts tokens issued for any audience".
Por qué los valores por defecto de KeycloakAuthProvider fallan en un realm nuevo
Esos valores por defecto tienen además un problema previo. KeycloakAuthProvider exige el ámbito openid si no le dices otra cosa, y lo anuncia en sus metadatos. El cliente lo pide al registrarse, y la política "Allowed Client Scopes" de un realm recién creado lo rechaza, porque openid no es un ámbito de cliente de Keycloak:
RuntimeError: Client failed to connect: Registration failed: 403
{"error":"insufficient_scope","error_description":"Policy
'Allowed Client Scopes' rejected request to client-registration
service. Details: Not permitted to use specified clientScope"}
La corrección es la del servidor de arriba: exige un ámbito propio como mcp:tools y fija audience con la URL del servidor MCP. La documentación de FastMCP apunta a un proyecto auxiliar, fastmcp-keycloak-local, con un realm ya preparado; no lo probé.
Por qué mcp:tools no debe ser un ámbito por defecto del realm
Mi primera versión del script añadía mcp:tools a los ámbitos opcionales por defecto del realm, porque así los clientes del registro dinámico lo reciben sin tocar políticas. El efecto secundario es que todos los clientes creados después también lo reciben. portal-web pidió scope=mcp:tools y obtuvo un token con aud igual a ["http://localhost:24200/mcp", "account"], válido para el servidor MCP.
Con mcp:tools fuera de los valores por defecto y dentro de la política de registro anónimo, el cliente MCP sigue recibiendo el ámbito. Un cliente nuevo que lo pide recibe {"error":"invalid_scope","error_description":"Invalid scopes: mcp:tools"}.
Qué cambia con CIMD, VS Code y Claude Code
La revisión 2026-07-28 de MCP depreca el registro dinámico en favor de Client ID Metadata Document (CIMD): el client_id es una URL HTTPS que apunta a un JSON con los metadatos del cliente. Según las notas de versión de Keycloak[6], la 26.6.0 añadió CIMD como función experimental y la 26.7.0 documentó Claude Code como cliente MCP junto a VS Code. Los dos son clientes públicos con PKCE y callback en localhost.
CIMD se activa con --features=cimd. Lo comprobé en un Keycloak 26.7.4 aparte: el documento de metadatos pasó a anunciar client_id_metadata_document_supported: true y el log avisó de "Experimental features enabled: cimd:v1". Después hay que crear un perfil de cliente con el ejecutor client-id-metadata-document y una política con la condición client-id-uri, como describe la guía de Keycloak. El flujo completo con CIMD no lo probé: el cliente del SDK de MCP exige una URL HTTPS para el documento, y este laboratorio corre en HTTP.
Qué revisar antes de llevarlo a producción
Este montaje es de desarrollo. Antes de exponerlo, revisa estos puntos:
- Modo de arranque:
starten lugar destart-dev, con TLS y unKC_HOSTNAMEHTTPS - Audiencia:
audiencefijado en el verificador, con la URL exacta que anuncian los metadatos - Un ámbito por servidor MCP: sin RFC 8707, el nombre del ámbito es lo único que distingue un servidor de otro
- Registro anónimo: hosts de confianza, ámbitos permitidos y el límite de 200 clientes
- Almacén de tokens del cliente: persistente y cifrado, o cada arranque registrará un cliente nuevo
- Parches: la 26.7.4 corrige seis CVE; sigue cada versión de parche de la rama 26.7
Si Keycloak se te queda grande para un laboratorio casero, la comparativa de Pocket ID, Authelia y Authentik ayuda a elegir, y cómo instalar Authentik cubre la alternativa con más funciones. Revisa en cada caso qué dicen de RFC 8707 y del registro dinámico antes de ponerlos delante de un servidor MCP.
Preguntas frecuentes
¿Keycloak es compatible con la última especificación de MCP?
En parte. Su guía declara soporte completo solo para la revisión 2025-03-26. En las revisiones 2025-06-18, 2025-11-25 y 2026-07-28 le falta RFC 8707, y el sustituto es un ámbito con mapper de audiencia por cada servidor MCP.
¿Necesito el registro dinámico de clientes?
No siempre. Si controlas los clientes, puedes darlos de alta a mano y pasar el client_id al cliente MCP. El registro dinámico sirve para clientes que no conoces de antemano, está deprecado en MCP 2026-07-28 y el relevo, CIMD, sigue siendo experimental en Keycloak.
¿Basta con que el token esté firmado por mi realm?
No. Firma, emisor y caducidad demuestran que Keycloak emitió el token, pero no para quién. En la prueba, un token de otra aplicación del realm pasó en cuanto quité la comprobación de audiencia.
Conclusión
Keycloak 26.7.4 sirve como servidor de autorización para un servidor MCP, siempre que asumas lo que falta. Publica sus metadatos, registra clientes MCP por registro dinámico con consentimiento y PKCE, y emite tokens que FastMCP valida. No entiende resource, así que la separación entre servidores la construyes tú con un ámbito por servidor y un mapper de audiencia, y la hace cumplir el servidor MCP.
El paso siguiente es copiar el script del realm, cambiar la URL del mapper por la de tu servidor y arrancar FastMCP con audience fijado. Después repite la prueba del token de portal-web en tu entorno: si pasa, tu servidor acepta tokens que no son suyos.
Fuentes: [1] Guía de Keycloak para integrarse con MCP[2], [2] Especificación de autorización de MCP 2026-07-28[1], [3] Notas de versión de Keycloak[6], [4] Versión 26.7.4 de Keycloak[3], [5] Servicio de registro de clientes de Keycloak[4], [6] Integración de Keycloak en FastMCP[5], [7] FastMCP 4.0.10 en PyPI[7], [8] RFC 8707: Resource Indicators for OAuth 2.0[8], [9] Pull request 45309 de Keycloak sobre el registro dinámico[9].
Fuentes
- especificación de autorización de MCP 2026-07-28
- guía de Keycloak para MCP
- notas de versión
- documentación del servicio de registro de clientes
- página de Keycloak en la documentación de FastMCP
- notas de versión de Keycloak
- FastMCP 4.0.10 en PyPI
- RFC 8707: Resource Indicators for OAuth 2.0
- Pull request 45309 de Keycloak sobre el registro dinámico
Código fuente
Accede a todo el código fuente de este artículo en GitHub.
Ver en GitHub