Saltar al contenido principal
intermediatePart 6

El token válido equivocado: autenticar un servidor de herramientas MCP con authentik

· 17 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
authentik+Model Context Protocol

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:8770 can call issue_refund directly, 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.

Reproducibilidad

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​

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

El token de herramientas decodificado, con un emisor terminado en agent-tools, una audiencia distinta de la del cliente del gateway, y scope mcp

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.

Adjuntar un scope no es concederlo

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:

~/mcp-tools/office_tools.py
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 privadas

JWTVerifier 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

El servidor reiniciando, con un log de arranque que informa del transporte y el puerto y no menciona la autenticación

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

Tres peticiones al servidor MCP que imprimen no token 401, gateway token 401 y tools token 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)'

El listado de herramientas devuelto a quien llama autorizado: cinco herramientas, find_customer, list_availability, book_slot, open_invoices e issue_refund

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)

El log del servidor nombrando al cliente rechazado e imprimiendo el emisor recibido y el esperado, seguido de una respuesta genérica invalid_token

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.

No filtres este log con grep

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:

Un script de prueba que usa BearerAuth con un token del ayudante de shell e imprime las cinco herramientas descubiertas

Eso funciona, así que se puede compartir. Un ayudante, usado por todos los scripts de la serie:

~/mcp-tools/mcp_auth.py
"""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.

Esto reintroduce el problema de la caducidad

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

Una llamada tools/call a find_customer, autenticada con un bearer token, que devuelve la fila de Maria Alvarez desde la base de datos

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.

Habilidad desbloqueada 🏅

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_refund to anything that isn't billing." La mitad ya es cierta: la llamada está autenticada. La otra mitad no. required_scopes es una propiedad del servidor, no de una herramienta, así que cualquiera con mcp:invoke puede llamar a issue_refund con la misma facilidad que a find_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ó.
Finished this tutorial?
Mark it complete to earn A tool server that checks who is asking on your skill path.

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.

Para seguir leyendo​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.