El token válido equivocado: autenticar un servidor de herramientas MCP con authentik
- 1Lock down Docker networks
- 2Run containers as non-root
- 3Identity in front of every port
- 4Membership, not just an account
- 5An identity for the agent, not a key
- 6A tool server that checks who is asking
- 7Where the agent can go, not just what it can call
- 8Move the daemon off root
- 9Run the model's own code without trusting it
- 🏆A scope per tool, and a refusal clients can act on
La Parte 5 le dio a un agente su propia
identidad en el gateway: un token emitido por authentik con client credentials, con un scope, y
que expira a los cinco minutos. Terminó nombrando lo que no había cubierto — el servidor de
herramientas MCP de la
parte 4 de orquestación de agentes sigue fiándose de
cualquier cosa que alcance su puerto, issue_refund incluido.
Aquel post fue explícito sobre el límite de lo que había construido:
The MCP server still trusts everyone. Scoping happens in the client. Anything that can reach
127.0.0.1:8770can callissue_refunddirectly, agent or not.
Repartir la caja de herramientas por rol impidió que un agente alcanzara una herramienta que
no le tocaba. No hizo nada contra un curl. Esta parte cierra eso.
La misma máquina que las Partes 3 a 5: una WEC Instance, 8 vCPU AMD EPYC 7601, 15 GB RAM,
Ubuntu 22.04, Python 3.10. authentik 2026.8.1 en el puerto host 9100. fastmcp 4.0.2,
mcp 2.1.1, langchain 1.4.0. El servidor MCP es office_tools.py de la
parte 3 de orquestación de agentes, sin más
cambios que las líneas que se muestran aquí, todavía en 127.0.0.1:8770.
HTTP plano sobre una LAN privada, como en toda esta serie.
Un cliente o dos
Lo obvio es reutilizar el cliente agent-gateway de la Parte 5 y añadirle un segundo scope. Ya
existe, el agente ya tiene sus credenciales, y funcionaría.
No lo hagas. Si una sola credencial abre el gateway y el servidor de herramientas, entonces una clave de gateway filtrada también emite reembolsos, y el radio de daño de perderla se duplica. El hilo que recorre los grupos de la Parte 4 y los grant types de la Parte 5 es que una credencial debería hacer un solo trabajo.
Así que el servidor de herramientas recibe su propio cliente, su propio scope y — porque en authentik cada proveedor es su propio emisor — su propio emisor. Esto último acaba importando más de lo esperado, y llegamos a ello en el Paso 4.
Requisitos previos
- El authentik de la Parte 3, con el cliente
agent-gatewayde la Parte 5 - El servidor MCP y el virtualenv de la parte 3 de orquestación de agentes
fastmcp4.0.0 o posterior — el ayudante de client credentials del Paso 5 no existe antes
Paso 1 — Un segundo cliente, en tres partes
Esto es el procedimiento de la Parte 5 con otros nombres, así que aquí va deliberadamente escueto. Si algo no te resulta familiar, la Parte 5 recorre las mismas tres pantallas con figuras.
El proveedor. Applications → Providers → New Provider → OAuth2/OpenID → Next. Nombre
agent-tools, Client Type Confidential, Redirect URIs vacío, y en Grant Types desmarca
todo excepto Client credentials.
El scope. Customization → Property Mappings → New Property Mapping → Scope Mapping. Mapping
Name mcp-invoke, Scope name mcp:invoke, expresión return {}. Los dos campos de nombre son
cosas distintas: el primero es la etiqueta de authentik, el segundo es la cadena que acaba en el
token.
La aplicación. Applications → Applications → New Application ▾ → with Existing
Provider…, con nombre Agent Tools, slug agent-tools escrito a mano, proveedor
agent-tools, oculta del dashboard. Después edita el proveedor → Advanced protocol settings →
Scopes, y mueve mcp-invoke a Selected.
Verifica los grant types desde la base de datos y no desde el formulario, que es la lección de la Parte 4:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.providers.oauth2.models import OAuth2Provider
for p in OAuth2Provider.objects.all():
print(f'{p.name}: {p.grant_types}')" 2>/dev/null | tail -5
Provider for Langfuse: ['authorization_code', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code']
Provider for LiteLLM: ['authorization_code', 'implicit', 'hybrid', 'refresh_token', 'client_credentials', 'password', 'urn:ietf:params:oauth:grant-type:device_code']
agent-gateway: ['client_credentials']
agent-tools: ['client_credentials']
Dos clientes recortados a un solo flujo cada uno y — visible en la misma salida — el proveedor
de LiteLLM de la Parte 4 todavía con password y otros cinco que nadie eligió. La Parte 5 dijo
que había que volver y arreglarlos. Esto es el aspecto que tiene no hacerlo.
Paso 2 — Un token, y por qué es un token distinto
Lee las credenciales y guárdalas junto a las del gateway, en su propio directorio:
cd ~/authentik
read -r TID TSEC < <(docker compose exec -T server ak shell -c "
from authentik.providers.oauth2.models import OAuth2Provider as P
p = P.objects.get(name='agent-tools')
print(p.client_id, p.client_secret)" 2>/dev/null | tail -1)
mkdir -p ~/mcp-auth && cd ~/mcp-auth
umask 077
cat > .env <<EOF
AK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/
AK_CLIENT_ID=$TID
AK_CLIENT_SECRET=$TSEC
EOF
cp ~/agent-auth/token.sh ~/mcp-auth/token.sh
El ayudante es el de la Parte 5, sin cambios — recibe un scope y devuelve un access token.
~/mcp-auth/token.sh mcp:invoke | jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'
{
"iss": "http://10.80.4.212:9100/application/o/agent-tools/",
"sub": "545a532b77a0f877339a5e4312970069deeb9ded604494c510debdfdce9c970d",
"aud": "xNxmOEyPMPVThmxgLsyvTCHa3qvnpVjQSOY0bZck",
"exp": 1789050030,
"iat": 1789049730,
"scope": "mcp:invoke"
}

Tres campos difieren del token del gateway y los tres cargan peso. El emisor termina en
/agent-tools/ y no en /agent-gateway/. La audiencia es otro client ID. El scope es
mcp:invoke. exp menos iat son 300 segundos, igual que en la Parte 5 — recuerda ese número,
vuelve en el Paso 5.
Si el token vuelve con "scope": "", el mapping existe pero no está adjunto al proveedor. El
propio texto de ayuda de authentik en ese panel: "Select which scopes can be used by the
client. The client still has to specify the scope to access the data." Adjuntarlo lo hace
disponible; el cliente todavía tiene que pedirlo.
Paso 3 — El verificador
FastMCP valida JWT con JWTVerifier, que recibe las cuatro cosas que authentik nos acaba de
decir. Haz copia de seguridad del servidor primero, que es el artefacto sobre el que se apoyan
dos partes ya publicadas:
cp ~/mcp-tools/office_tools.py ~/mcp-tools/office_tools.py.bak
Su configuración va en un fichero, no en el código:
cd ~/mcp-tools
umask 077
cat > .env.mcp <<'EOF'
MCP_JWKS_URI=http://10.80.4.212:9100/application/o/agent-tools/jwks/
MCP_ISSUER=http://10.80.4.212:9100/application/o/agent-tools/
MCP_AUDIENCE=<el client id de agent-tools>
EOF
Después, dos cambios en office_tools.py — un import, y un verificador donde estaba el
constructor pelado:
from fastmcp import FastMCP, Context
from fastmcp.server.auth.providers.jwt import JWTVerifier
verifier = JWTVerifier(
jwks_uri=os.environ["MCP_JWKS_URI"],
issuer=os.environ["MCP_ISSUER"],
audience=os.environ["MCP_AUDIENCE"],
required_scopes=["mcp:invoke"],
)
mcp = FastMCP("office-tools", auth=verifier)
Ese es todo el cambio del lado del servidor. JWTVerifier descarga el JWKS, empareja el kid
de cada token con una clave, y comprueba firma, emisor, audiencia, caducidad y scope antes de
que se ejecute ninguna herramienta.
ssrf_safe y las direcciones privadasJWTVerifier acepta un argumento ssrf_safe que por defecto es False, y activarlo suena a
buena idea sin matices. En un montaje como este rompe el verificador. La protección SSRF de
FastMCP rechaza por IP, y su propio texto de error es "Private, loopback, link-local, and
reserved IPs are not allowed" — la comprobación cubre explícitamente 10.x, 172.16-31.x,
192.168.x y 127.x. Nuestro JWKS está en 10.80.4.212, así que la petición no sale del
proceso.
Si quieres la protección activa y tu proveedor de identidad es genuinamente interno, el módulo
lee FASTMCP_SSRF_TRUST_PROXY, que se salta la resolución DNS y la lista de bloqueo por
completo.
Reinicia con el nuevo entorno cargado:
pkill -f office_tools.py
cd ~/mcp-tools && set -a && source .env.mcp && set +a && \
nohup ./.venv/bin/python -u office_tools.py > server.log 2>&1 &
sleep 4 && tail -8 server.log

Lee ese log de arranque con atención, por lo que le falta. Informa del transporte, el modo y el puerto, y no dice absolutamente nada sobre autenticación. Un servidor con verificador y uno sin él escriben las mismas líneas. No hay ningún mensaje de "auth activada" que buscar, así que la única forma de saber si el verificador ha entrado es hacer una petición — que es el paso siguiente.
Paso 4 — Tres peticiones
La misma forma que los tres códigos de estado de la Parte 5, porque se lee bien y porque la del medio es la interesante:
TOOLS=$(~/mcp-auth/token.sh mcp:invoke)
GW=$(~/agent-auth/token.sh gateway:invoke)
req() {
curl -sS -o /dev/null -w "$1 HTTP %{http_code}\n" -X POST http://127.0.0.1:8770/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
${2:+-H "Authorization: Bearer $2"} \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
}
req "no token "
req "gateway token" "$GW"
req "tools token " "$TOOLS"
no token HTTP 401
gateway token HTTP 401
tools token HTTP 200

La primera línea es el hueco que la parte 4 de orquestación de agentes admitió, ahora cerrado. Antes de este cambio, esa petición devolvía el catálogo completo de cinco herramientas a quien lo pidiera — y con el token correcto, sigue haciéndolo:
curl -sS -X POST http://127.0.0.1:8770/mcp \
-H "Authorization: Bearer $TOOLS" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| tr -d '\r' | sed -n 's/^data: //p' | jq '.result.tools | length, (.[].name)'

Las mismas cinco herramientas que antes. La autenticación cambió quién puede preguntar, no lo que hace el servidor.
La segunda línea es en la que conviene detenerse. Ese es un token de authentik válido, sin caducar y correctamente firmado que funciona perfectamente contra el gateway, y el servidor de herramientas lo rechaza. Pregúntale al servidor por qué — enviando la petición otra vez primero, para que la respuesta quede al final del log y no enterrada bajo lo que haya llegado desde entonces:
GW=$(~/agent-auth/token.sh gateway:invoke)
curl -sS -o /dev/null -w 'gateway token: HTTP %{http_code}\n' -X POST http://127.0.0.1:8770/mcp \
-H "Authorization: Bearer $GW" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
tail -12 ~/mcp-tools/server.log
WARNING Bearer token rejected for client aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ
: issuer mismatch (got 'http://10.80.4.212:9100/application/o/agent-gateway/',
expected 'http://10.80.4.212:9100/application/o/agent-tools/')
INFO Auth error returned: invalid_token (status=401)

Falló por iss, no por el scope. Ahí está el proveedor aparte dando su fruto. En authentik
cada proveedor es su propio emisor, así que un token del cliente equivocado se rechaza en la
primera puerta, antes de mirar la audiencia o el scope. Si hubiéramos tomado el camino fácil y
añadido un segundo scope al cliente del gateway, la misma petición habría llegado varias
comprobaciones más allá antes de que required_scopes la echara. Mismo resultado, más
superficie.
Otras dos cosas en ese log. Nombra al cliente infractor por su ID, e imprime tanto el emisor que
recibió como el que esperaba — así que depurar es un tail, no una adivinanza. Y quien llama
recibe solo invalid_token, que es lo correcto: decirle a un cliente qué emisor esperabas es
decirle a un atacante qué falsificar.
grep -iE 'invalid|token|401' server.log devuelve la última línea y esconde la primera. Te
quedas con invalid_token (status=401) y concluyes que el servidor no dice por qué. Sí lo dice,
en la línea de arriba, repartida en cuatro líneas que tu patrón no captura. Léelo sin filtrar.
Paso 5 — La mitad del cliente, y un ayudante que no puede ayudar
El servidor de herramientas ya está protegido, lo que significa que todos los scripts de la parte 3 y la parte 4 están rotos. Se conectan sin token.
FastMCP documenta exactamente lo que hace falta para esto: ClientCredentialsOAuthProvider, que
ejecuta él mismo el flujo de client credentials y — lo que importa dados nuestros tokens de 300
segundos — "when the token expires it is re-acquired automatically on the next request."
Aquí no funciona. Pruébalo y obtienes:
mcp.client.auth.exceptions.OAuthTokenError: Token exchange failed (404): Not Found
El log del servidor explica lo que pasó de verdad:
GET /.well-known/oauth-authorization-server 404
POST /token 404
GET /.well-known/oauth-protected-resource 404
GET /.well-known/oauth-protected-resource/mcp 404
GET /.well-known/openid-configuration 404
El provider recibe la URL del servidor MCP, no un endpoint de token, porque "the token
endpoint is discovered from the server's OAuth metadata." El nuestro no publica ninguna — y la
propia documentación de FastMCP lo dice, en otra página: "TokenVerifier focuses exclusively
on token validation without providing OAuth discovery metadata."
O sea que las dos páginas son coherentes y la combinación es un callejón sin salida documentado.
Solo que hay que leer ambas para enterarse, y el fallo no te cuenta nada de eso: cuatro 404 que
solo ves si estás mirando el servidor, un repliegue que hace POST /token contra el servidor de
recursos, y un error que nunca menciona el descubrimiento.
El camino que señala la documentación es que el cliente obtenga su token por separado.
BearerAuth recibe un token que ya tienes y lo adjunta a cada petición — vale la pena probarlo
suelto antes de cablearlo en cuatro scripts:

Eso funciona, así que se puede compartir. Un ayudante, usado por todos los scripts de la serie:
"""Un único cliente MCP autenticado, compartido por todos los agentes de la serie."""
import os
import subprocess
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
MCP_URL = "http://127.0.0.1:8770/mcp"
TOKEN_SH = os.path.expanduser("~/mcp-auth/token.sh")
def fetch_token(scope: str = "mcp:invoke") -> str:
"""Emite un token de corta duración desde authentik con client credentials."""
out = subprocess.run([TOKEN_SH, scope], capture_output=True, text=True, check=True)
return out.stdout.strip()
def authed_client() -> Client:
return Client(MCP_URL, auth=BearerAuth(token=fetch_token()))
Cada agente cambia entonces en dos líneas — un import, y lo que le pasa a MCPAdapter:
from mcp_auth import authed_client
async with MCPAdapter(authed_client()) as adapter:
MCPAdapter acepta un Client ya configurado igual de bien que una URL, cosa que
la parte 3 usó para el caché sin necesitarlo
para nada más. Aquí es lo que deja entrar el token.
BearerAuth es la forma explícita. FastMCP también acepta el token como cadena pelada —
Client(url, auth=token) — y añade el esquema él mismo; su documentación es específica en que
"do not include the Bearer prefix" si haces eso. La clase merece el import extra aquí solo
porque deja la intención clara en el punto de llamada.
BearerAuth guarda una cadena fija. Un token emitido al arrancar el agente está muerto 300
segundos después, y nada lo renueva — la renovación automática era justo lo que
ClientCredentialsOAuthProvider nos habría dado. Para una ejecución corta da igual. Para un
agente que espera a una persona, como
hace el reembolso de la parte 3,
perfectamente puede importar. O subes la vida del token en el proveedor, o lo emites por llamada
en vez de por cliente.
Paso 6 — Comprueba que una herramienta se ejecuta de verdad
Descubrir no es ejecutar. Un token que lista herramientas todavía podría fallar en la llamada, así que comprueba lo que toca la base de datos:
TOOLS=$(~/mcp-auth/token.sh mcp:invoke)
curl -sS -X POST http://127.0.0.1:8770/mcp \
-H "Authorization: Bearer $TOOLS" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_customer","arguments":{"query":"Maria"}}}' \
| tr -d '\r' | sed -n 's/^data: //p' | jq -c '.result.content[0].text // .error'
"[{\"id\":1,\"name\":\"Maria Alvarez\",\"email\":\"maria@example.com\"}]"

Quita la cabecera Authorization y la misma llamada devuelve 401. Una fila real con token, nada
sin él — eso es autenticación llegando hasta la ejecución, no solo hasta el catálogo.
Puedes poner un verificador de JWT delante de un servidor de herramientas MCP para que rechace a quien llama sin identificarse y rechace tokens válidos emitidos a otro cliente — y leer el log del servidor para saber cuál de emisor, audiencia, scope o caducidad hizo el rechazo.
Resolución de problemas — los errores que dio esta ejecución
Token exchange failed (404): Not Found
ClientCredentialsOAuthProvider contra un servidor con JWTVerifier. El descubrimiento no
encontró nada y el cliente se replegó a adivinar. Mira el Paso 5; usa BearerAuth.
2 validation errors ... Missing required argument
El token estaba bien — esto viene de dentro de la herramienta. params.name es el nombre de la
herramienta, params.arguments son sus parámetros, y ambos anidan un campo llamado name.
find_customer recibe query.
El log solo dice invalid_token
Lo filtraste con grep. Mira el aviso del Paso 4.
Todos los scripts de las partes 3 y 4 fallan de golpe
Es lo esperado. Se conectan sin token. Paso 5.
Qué te dio esto y qué no
Hecho: un servidor MCP que autentica a cada quien llama, una credencial que no es la del gateway, un rechazo que ocurre en el emisor y no en lo hondo de la comprobación de scope, y un log que nombra a qué cliente se le negó el paso y por qué.
Sin hacer:
- Sin autorización por herramienta, y esto es menos de lo que prometió la parte 4 de
orquestación de agentes. Aquel post cerraba diciendo que el paso siguiente era
"authenticate the tool call, so the server knows which agent is asking and refuses
issue_refundto anything that isn't billing." La mitad ya es cierta: la llamada está autenticada. La otra mitad no.required_scopeses una propiedad del servidor, no de una herramienta, así que cualquiera conmcp:invokepuede llamar aissue_refundcon la misma facilidad que afind_customer, y el servidor sigue sin poder distinguir al scheduler del agente de facturación. Repartir herramientas por agente, como hizo la parte 4, sigue siendo un arreglo del lado del cliente. - Los tokens siguen caducando a mitad de ejecución, como describe el Paso 5.
- Sigue siendo HTTP plano. Un bearer token en claro es una credencial en claro.
- El proveedor del propio gateway sigue con permisos de más, visible en la salida del Paso 1 y sin arreglar desde que la Parte 5 lo señaló.
Qué viene después
La autorización por herramienta es el hueco obvio y no tiene una respuesta obvia.
required_scopes protege el servidor; proteger issue_refund de forma distinta a
find_customer significa o un segundo servidor con su propio cliente, o autorización dentro del
cuerpo de cada herramienta leyendo los claims ya verificados. Lo primero son más cajas y una
frontera limpia. Lo segundo es una comprobación en cada herramienta que escribas jamás, que es
precisamente el modo de fallo del que
avisaba la parte 4 para la política dentro de la
herramienta.
