Dale a cada herramienta MCP su propio ámbito y devuelve un rechazo sobre el que el cliente pueda actuar
- 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 6 puso required_scopes=["mcp:invoke"] en el
verificador de JWT y dio por autorizado el servidor. Lo está, en el sentido de que quien llama
sin autenticarse no obtiene nada. No lo está, en el sentido que importa: mcp:invoke abre todas
las herramientas del servidor. El mismo token que deja a un agente ejecutar find_customer le
deja ejecutar issue_refund.
Ese es todo el hueco. Un agente de solo lectura y un agente que emite reembolsos tienen credenciales idénticas, y lo único que se interpone entre "busca a Maria" y "reembolsa la factura 2" es que nadie lo pidió.
Este post lo cierra, primero mal y luego bien, porque el "mal" es lo que la mayoría despliega y la diferencia solo se nota en lo que el cliente puede hacer ante un rechazo.
Ambas versiones rechazan la misma llamada — un token de solo lectura pidiendo issue_refund. Se
diferencian en dónde se comprueba el ámbito, y esa única decisión determina lo que recibe el
cliente:
Requisitos previos
Las partes 5 y 6, en concreto:
- Authentik emitiendo tokens de client-credentials a un proveedor
agent-tools(parte 5) - Un servidor FastMCP verificando esos tokens contra el endpoint JWKS (parte 6)
~/mcp-auth/token.sh, que recibe una cadena de ámbitos y devuelve un token de acceso
El servidor de la parte 6 se queda en :8770 todo el rato. Las dos versiones de abajo corren en
:8771 y :8772 para que puedas comparar las tres sin parar nada.
Paso 1 — Dos ámbitos nuevos
Los ámbitos son Property Mappings en Authentik. Customization → Property Mappings → New Property Mapping → Scope Mapping, dos veces:
| Nombre | Nombre del ámbito | Descripción |
|---|---|---|
office-read | office:read | Consultar clientes y facturas |
office-refund | office:refund | Emitir reembolsos |

Crearlos no basta. Un proveedor solo emitirá un ámbito que se le haya asignado, así que abre Applications → Providers → agent-tools → Edit y mueve ambos a Selected Scopes.

Fíjate en la línea bajo el selector: "Select which scopes can be used by the client. The client
still has to specify the scope to access the data." Las dos mitades importan. Seleccionar un
ámbito aquí no lo mete en todos los tokens — permite que el cliente lo pida. Un cliente que no pide
nada no obtiene nada, y por eso cada llamada a token.sh de abajo pasa una cadena de ámbitos
explícita.
Confirma que el token lleva de verdad lo que pediste:
~/mcp-auth/token.sh "mcp:invoke office:read" \
| cut -d. -f2 | base64 -d 2>/dev/null | jq .scope
"mcp:invoke office:read"
Si eso vuelve sin office:read, el ámbito no está en el proveedor — arréglalo antes de escribir
una línea de código de servidor, o te pasarás una hora depurando una aplicación de permisos que
funciona perfectamente sobre un token al que nunca se le asignó el ámbito.
Paso 2 — La implementación obvia
Mantén required_scopes=["mcp:invoke"] en el verificador como precio de entrada, y luego pregunta
por herramienta si quien llama tiene lo que esa operación necesita. FastMCP expone el token ya
verificado a través de get_access_token(), así que un decorador puede leer los claims que el
verificador ya comprobó:
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_access_token
def requires(scope: str):
def decorate(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
token = get_access_token()
held = set(getattr(token, "scopes", None) or [])
if scope not in held:
raise ToolError(
f'insufficient_scope: this tool requires "{scope}"; '
f'token carries {sorted(held) or "nothing"}'
)
return await fn(*args, **kwargs)
return wrapper
return decorate
Y luego una línea por herramienta:
@mcp.tool
@requires("office:read")
async def find_customer(query: str) -> list[dict]: ...
@mcp.tool
@requires("office:refund")
async def issue_refund(invoice_id: int) -> str: ...
Lánzalo en :8771 y prueba ambas herramientas con ambos tokens:

--- token: mcp:invoke + office:read ---
find_customer : ALLOWED -> [{'id': 1, 'name': 'Maria Alvarez', ...}]
issue_refund : REFUSED -> insufficient_scope: this tool requires "office:refund"
--- token: mcp:invoke + office:refund ---
find_customer : REFUSED -> insufficient_scope: this tool requires "office:read"
issue_refund : ALLOWED -> Refunded 80.00 on invoice 2.
Eso es aplicación real de permisos. El token de reembolso no puede leer, el de lectura no puede reembolsar, y el rechazo nombra el ámbito que falta. Para muchos despliegues, aquí es donde se para.
Paso 3 — Por qué ese rechazo no basta
Mira la capa HTTP en lugar de la librería cliente.
TOK=$(~/mcp-auth/token.sh "mcp:invoke office:read")
curl -sS -i -X POST http://127.0.0.1:8771/mcp \
-H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"issue_refund","arguments":{"invoice_id":2}}}'

HTTP/1.1 200 OK
content-type: text/event-stream
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"insufficient_scope: this tool
requires \"office:refund\"; token carries ['mcp:invoke', 'office:read']","type":"text"},
"isError":true}}
HTTP 200. La petición tuvo éxito; la que declinó fue la herramienta. Esa es la semántica correcta de JSON-RPC y es inútil para un cliente OAuth.
Un cliente OAuth que quiere escalar — volver al servidor de autorización y pedir office:refund —
está atento a un 401 o 403 con una cabecera WWW-Authenticate que le diga qué solicitar. No
se pone a extraer inglés de un resultado de herramienta. Así que el rechazo es legible para un
humano que lee logs e invisible para la maquinaria diseñada para manejar justo este caso.
Y además llega tarde. El cuerpo de la herramienta corre después del enrutado, después de montar la sesión, después de que el servidor se ha comprometido a una respuesta con éxito. Para entonces ya no queda línea de estado que cambiar.
Paso 4 — El desafío que la especificación realmente quiere
Conseguir un 403 significa comprobar los ámbitos antes de que responda la capa JSON-RPC, lo
que significa un middleware ASGI envolviendo la app de FastMCP:
class ScopeChallengeMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope["method"] != "POST":
return await self.app(scope, receive, send)
# Buffer the body so we can inspect it and still pass it on.
chunks, more = [], True
while more:
msg = await receive()
chunks.append(msg.get("body", b""))
more = msg.get("more_body", False)
body = b"".join(chunks)
needed = None
try:
rpc = json.loads(body)
if rpc.get("method") == "tools/call":
needed = TOOL_SCOPES.get((rpc.get("params") or {}).get("name"))
except Exception:
pass
if needed and needed not in token_scopes(dict(scope.get("headers") or [])):
challenge = (
f'Bearer error="insufficient_scope", scope="{needed}", '
f'resource_metadata="{RESOURCE_METADATA}", '
f'error_description="This operation requires the {needed} scope"'
)
await send({"type": "http.response.start", "status": 403,
"headers": [(b"content-type", b"application/json"),
(b"www-authenticate", challenge.encode())]})
await send({"type": "http.response.body",
"body": json.dumps({"error": "insufficient_scope",
"scope": needed}).encode()})
return
# Replay the buffered body downstream.
replayed = False
async def replay():
nonlocal replayed
if not replayed:
replayed = True
return {"type": "http.request", "body": body, "more_body": False}
return await receive()
await self.app(scope, replay, send)
app = ScopeChallengeMiddleware(mcp.http_app(stateless_http=True))
La misma petición, contra :8772:

HTTP/1.1 403 Forbidden
www-authenticate: Bearer error="insufficient_scope", scope="office:refund",
resource_metadata="http://127.0.0.1:8772/.well-known/oauth-protected-resource",
error_description="This operation requires the office:refund scope"
Ahora el cliente tiene algo sobre lo que actuar: el estado dice rechazado, scope= dice qué
pedir, y resource_metadata dice dónde averiguar cómo. Eso es autorización escalada como
protocolo, no como mensaje de log.
Paso 5 — Lo que costó
El middleware funciona. También es peor código que el decorador, de tres formas concretas, y fingir lo contrario sería deshonesto.
No sabe qué es una herramienta. El middleware ASGI ve bytes y cabeceras. Para averiguar qué herramienta se está llamando, parsea él mismo el sobre JSON-RPC y busca el nombre en una tabla que tiene que mantener:
TOOL_SCOPES = {
"find_customer": "office:read",
"open_invoices": "office:read",
"issue_refund": "office:refund",
}
Esa tabla es una segunda fuente de verdad. Añade una herramienta y olvida la entrada y queda sin proteger — en silencio, porque el middleware simplemente deja pasar cualquier cosa que no reconoce. El decorador no podía tener ese fallo: el requisito estaba sobre la función.
Decodifica el token sin verificarlo. El middleware corre antes del verificador, así que los claims verificados todavía no existen. Parte el JWT y decodifica en base64 el payload sin comprobar la firma:
payload = auth.split(None, 1)[1].split(".")[1]
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
Esto no es el agujero que parece — el verificador sigue corriendo después y sigue rechazando un token falsificado, así que nada llega a una herramienta con una firma mala. Pero la decisión de ámbito se toma sobre bytes no autenticados, y la única razón por la que eso es sobrevivible es la segunda comprobación detrás. Es una verruga, no una vulnerabilidad, y es de esas cosas que vale la pena dejar escritas antes de que alguien más tarde quite el verificador "redundante".
Almacena en memoria cada cuerpo de petición. Para leer el sobre y aun así pasarlo hacia abajo,
el middleware vacía receive() en memoria y lo reproduce. Bien para llamadas JSON-RPC; piénsalo
mejor antes de poner esto delante de subidas grandes.
Resolución de problemas — los errores que esta ejecución produjo de verdad
insufficient_scope en una herramienta que sí concediste
Revisa el token, no el servidor:
~/mcp-auth/token.sh "mcp:invoke office:refund" | cut -d. -f2 | base64 -d 2>/dev/null | jq .scope
Si falta office:refund, el ámbito existe como Property Mapping pero nunca se movió a los
Selected Scopes del proveedor. Authentik descarta en silencio los ámbitos que un cliente no
tiene permitido solicitar en lugar de dar error, así que el token vuelve válido y corto.
El middleware nunca se dispara
Solo inspecciona POST. Los clientes MCP abren primero un GET para el flujo de eventos, y esa
petición no lleva sobre JSON-RPC — si estás mirando la petición equivocada concluirás que el
middleware está muerto. Confírmalo con el curl de arriba, que es un único POST.
De repente todas las herramientas devuelven 403
TOOL_SCOPES está indexado por el nombre registrado de la herramienta, que es el nombre de la
función, no la etiqueta decorada. Renombra una función y la tabla deja de coincidir. El caso de
paso libre es la dirección peligrosa (sin proteger), pero un error tipográfico en la tabla produce
este otro.
El cuerpo del 403 está vacío en algunos clientes
El desafío vive en la cabecera WWW-Authenticate. Los clientes que solo registran el cuerpo
de la respuesta te mostrarán {"error":"insufficient_scope"} y nada sobre qué ámbito. Usa
curl -i.
Qué te dio esto y qué no
Te dio autorización por herramienta de verdad: dos tokens que se diferencian en un ámbito, cada uno capaz de ejecutar exactamente una de dos herramientas, demostrado en la capa HTTP en lugar de afirmado. Y con el middleware, un rechazo del que un cliente puede recuperarse de forma programática.
No te dio un diseño limpio. Ambas implementaciones son concesiones que apuntan en direcciones opuestas:
decorador de herramienta (:8771) | middleware ASGI (:8772) | |
|---|---|---|
| El requisito de ámbito vive | en la función | en una tabla aparte |
| Lee claims verificados | sí | no — decodifica sin verificar, el verificador corre después |
| Rechazo | HTTP 200, isError: true | HTTP 403 + WWW-Authenticate |
| El cliente puede escalar | no | sí |
| Herramienta nueva sin proteger por defecto | no | sí |
El resumen honesto es que el comportamiento correcto del protocolo requiere salir de la abstracción que te da el framework, y la versión ergonómica no puede producirlo. Si tus clientes no implementan escalado — y la mayoría de los clientes de agentes hoy no lo hacen — el decorador es el mejor trato. Si lo hacen, lo pagas con una tabla que tienes que acordarte de actualizar.
Tampoco te dio una autorización que sobreviva a que la herramienta haga otra cosa.
issue_refund está protegido por office:refund; nada impide que un futuro find_customer se
edite para escribir. Los ámbitos controlan la entrada, no el comportamiento — que es el mismo
límite que la parte 4 trazó alrededor de la
política-en-la-herramienta.
Qué sigue
El hueco evidente que queda es que ambas versiones confían en la lista de ámbitos del token y en
nada más. Ninguna pregunta quién es quien llama ni sobre qué está actuando — un token con
office:refund reembolsa cualquier factura, de cualquier cliente, por cualquier importe. Eso es
autorización a nivel de objeto, y no vive en los ámbitos de OAuth en absoluto.



