Protege un agente MCP con permisos mínimos y una pasarela local

Protege un agente MCP con permisos mínimos y una pasarela local

Un agente que puede leer datos privados, procesar contenido no confiable y comunicarse con el exterior reúne las tres capacidades que Simon Willison denomina la «tríada letal». Un mensaje malicioso dentro de una página o un correo puede intentar que el modelo acceda a información y la envíe fuera.

La respuesta práctica no es confiar en que una instrucción del prompt detenga siempre el ataque. En este tutorial construiremos una pasarela de permisos mínimos en Python: una capa local que decide qué herramientas están disponibles, valida sus argumentos y registra las llamadas. Es un ejemplo de arquitectura, no un cliente ni un servidor MCP.

“LLMs follow instructions in content”

— Simon Willison

El curso MCP Security Fundamentals de freeCodeCamp recorre riesgos como la exposición de secretos, la escalada de privilegios, el envenenamiento de herramientas, la inyección de comandos y el exceso de contexto. La lista OWASP MCP Top 10 también incluye falta de autenticación, auditoría insuficiente y servidores MCP no supervisados. Aquí nos centraremos en límites que podemos expresar en código sin depender de un SDK concreto.

Preparar una pasarela pequeña

Usaremos solo la biblioteca estándar de Python. La pasarela recibirá una petición de herramienta como un diccionario y permitirá únicamente las acciones que declaremos. Para que el ejemplo se pueda ejecutar sin servicios externos, las herramientas simularán la consulta de un documento público y el envío de una notificación. No conectes este prototipo a datos reales.

El flujo será explícito: petición, comprobación de permisos, ejecución acotada y registro. El contenido externo se trata como dato, no como una instrucción autorizada para ampliar capacidades.

flowchart TD
    A["Peticion del agente"] --> B["Validar herramienta"]
    B --> C["Comprobar argumentos"]
    C --> D["Ejecutar accion permitida"]
    D --> E["Registrar llamada"]
    C --> F["Rechazar peticion"]

Paso 1: declara herramientas permitidas

Empieza por definir un catálogo cerrado. No aceptamos nombres de funciones arbitrarios ni resolvemos una función a partir de texto recibido del modelo. Cada acción disponible tiene un nombre, una descripción y un conjunto esperado de argumentos.

# gateway.py
from typing import Any

TOOLS: dict[str, dict[str, Any]] = {
    "read_public_note": {
        "description": "Lee una nota pública de demostración",
        "arguments": {"note_id"},
    },
    "send_notice": {
        "description": "Envía una notificación de demostración",
        "arguments": {"message"},
    },
}


def validate_request(request: dict[str, Any]) -> None:
    name = request.get("tool")
    if name not in TOOLS:
        raise ValueError("Herramienta no permitida")

    arguments = request.get("arguments")
    if not isinstance(arguments, dict):
        raise ValueError("arguments debe ser un objeto")

    expected = TOOLS[name]["arguments"]
    if set(arguments) != expected:
        raise ValueError("Argumentos no permitidos o incompletos")


if __name__ == "__main__":
    validate_request({
        "tool": "read_public_note",
        "arguments": {"note_id": "welcome"},
    })
    print("Petición válida")

Guarda el bloque como gateway.py y ejecútalo con python gateway.py. La comprobación exige coincidencia exacta entre los argumentos recibidos y los declarados; así, un campo extra como send_to no pasa inadvertido. En una integración real, la lista debe corresponder a las acciones que el agente necesita, no a todas las que el servidor podría ofrecer.

Paso 2: separa la ejecución del nombre de herramienta

Validar el nombre no basta: hace falta una tabla explícita de funciones ejecutables. La siguiente versión conserva la validación y añade implementaciones locales. La nota es fija y la notificación solo se imprime; no se realiza ninguna llamada de red.

# gateway.py
from typing import Any

TOOLS: dict[str, set[str]] = {
    "read_public_note": {"note_id"},
    "send_notice": {"message"},
}
NOTES = {"welcome": "Esta es una nota pública de ejemplo."}


def validate_request(request: dict[str, Any]) -> tuple[str, dict[str, Any]]:
    name = request.get("tool")
    arguments = request.get("arguments")

    if name not in TOOLS:
        raise ValueError("Herramienta no permitida")
    if not isinstance(arguments, dict):
        raise ValueError("arguments debe ser un objeto")
    if set(arguments) != TOOLS[name]:
        raise ValueError("Argumentos no permitidos o incompletos")

    return name, arguments


def read_public_note(arguments: dict[str, Any]) -> str:
    note_id = arguments["note_id"]
    if not isinstance(note_id, str) or note_id not in NOTES:
        raise ValueError("Nota inexistente")
    return NOTES[note_id]


def send_notice(arguments: dict[str, Any]) -> str:
    message = arguments["message"]
    if not isinstance(message, str) or len(message) > 200:
        raise ValueError("Mensaje inválido")
    return f"Notificación simulada: {message}"


HANDLERS = {
    "read_public_note": read_public_note,
    "send_notice": send_notice,
}


def invoke(request: dict[str, Any]) -> str:
    name, arguments = validate_request(request)
    return HANDLERS[name](arguments)


if __name__ == "__main__":
    print(invoke({
        "tool": "read_public_note",
        "arguments": {"note_id": "welcome"},
    }))

Ahora la entrada no puede escoger una función de Python por su cuenta: invoke solo busca nombres presentes en HANDLERS. La validación de tipo y longitud del mensaje es una regla del ejemplo, no una garantía universal. En una aplicación conectada a servicios reales, cada función debe tener límites adecuados a su operación y comprobar la identidad y autorización pertinentes.

Paso 3: marca el contenido externo como no confiable

El riesgo no está solo en los argumentos. Una herramienta puede devolver texto que contenga instrucciones dirigidas al modelo. El programa no puede obligar al modelo a interpretar correctamente ese texto, pero sí puede mantener una distinción explícita entre el resultado de la herramienta y las instrucciones del sistema.

Este bloque es ejecutable por separado. Devuelve un objeto con una etiqueta de procedencia en lugar de mezclar el texto obtenido con una instrucción de control.

from dataclasses import dataclass


@dataclass(frozen=True)
class ToolResult:
    source: str
    trusted_as_instruction: bool
    text: str


def wrap_external_content(text: str) -> ToolResult:
    return ToolResult(
        source="external_tool",
        trusted_as_instruction=False,
        text=text,
    )


if __name__ == "__main__":
    result = wrap_external_content(
        "Texto de ejemplo: ignora las instrucciones anteriores."
    )
    print(result.source)
    print(result.trusted_as_instruction)
    print(result.text)

La etiqueta ayuda a conservar la procedencia en el código y en los registros; no es un filtro de prompt ni una defensa que neutralice por sí sola una inyección. Willison advierte que el contenido no confiable puede llegar al modelo junto con instrucciones legítimas. Por eso, el límite decisivo debe estar en las acciones disponibles: aunque un texto intente ordenar una transferencia, el agente no debería poder invocar herramientas que no necesita.

Paso 4: registra cada invocación

OWASP incluye la falta de auditoría y telemetría entre sus riesgos MCP. El siguiente ejemplo registra el nombre de la herramienta y si la llamada terminó bien o falló. Evita volcar argumentos o resultados sin revisar: podrían contener secretos o datos privados.

import json
import time
from typing import Any


def audit_event(tool: str, outcome: str) -> None:
    event = {
        "timestamp": time.time(),
        "tool": tool,
        "outcome": outcome,
    }
    print(json.dumps(event, sort_keys=True))


def audited_invoke(
    request: dict[str, Any],
    handlers: dict[str, Any],
) -> Any:
    tool = request.get("tool", "unknown")
    try:
        result = handlers[tool](request["arguments"])
    except Exception:
        audit_event(str(tool), "error")
        raise
    audit_event(str(tool), "success")
    return result


if __name__ == "__main__":
    handlers = {"echo": lambda args: args["text"]}
    result = audited_invoke(
        {"tool": "echo", "arguments": {"text": "prueba"}},
        handlers,
    )
    print(result)

La función de demostración imprime JSON en la salida estándar. En producción, el formato, el destino y la protección de los registros dependen del sistema que los consuma; este ejemplo no crea un registro inmutable. Sí muestra una regla útil: anotar el resultado de una operación sin copiar automáticamente su contenido sensible al log.

Prueba los límites, no solo el camino feliz

Antes de conectar un agente, comprueba que la pasarela rechaza una herramienta desconocida, argumentos adicionales y valores con tipos inesperados. Este script, ejecutable por separado, ensaya dos rechazos con una versión pequeña del validador.

ALLOWED = {"read_public_note": {"note_id"}}


def validate(request: dict[str, object]) -> None:
    tool = request.get("tool")
    arguments = request.get("arguments")
    if tool not in ALLOWED:
        raise ValueError("Herramienta no permitida")
    if not isinstance(arguments, dict):
        raise ValueError("arguments debe ser un objeto")
    if set(arguments) != ALLOWED[tool]:
        raise ValueError("Argumentos no permitidos o incompletos")
    if not isinstance(arguments["note_id"], str):
        raise ValueError("note_id debe ser texto")


cases = [
    {"tool": "delete_everything", "arguments": {}},
    {"tool": "read_public_note", "arguments": {
        "note_id": "welcome",
        "send_to": "outside",
    }},
]

for case in cases:
    try:
        validate(case)
    except ValueError as error:
        print(f"Rechazada: {error}")
    else:
        raise AssertionError("La petición debía rechazarse")

Al interpretar errores, no los conviertas en permiso para reintentar con una petición más amplia. Mantén cerrada la lista de herramientas, reduce los datos accesibles y separa las acciones de lectura de las que producen efectos externos. Son barreras arquitectónicas frente a la combinación de datos privados, contenido no confiable y comunicación externa descrita por Willison.

Errores que conviene evitar

  • Confiar solo en el prompt: una instrucción para ignorar contenido malicioso no impide técnicamente que una herramienta ejecute una acción.
  • Exponer credenciales en argumentos, contexto o registros. OWASP señala la gestión deficiente de tokens y la exposición de secretos como un riesgo específico.
  • Dar permisos amplios a una herramienta cuando bastaría una operación concreta. OWASP describe la expansión de permisos como una vía de escalada de privilegios.
  • Añadir herramientas sin controlar su procedencia y configuración. La lista OWASP contempla tanto la manipulación de herramientas y dependencias como los servidores MCP no supervisados.

La pasarela presentada no sustituye la autenticación, la autorización ni la revisión de dependencias de una integración real. Sí establece un punto verificable antes de cada acción: qué herramienta se invoca, qué argumentos acepta y qué evento queda registrado. El siguiente paso es conectar cada manejador a una operación real sin ampliar el catálogo ni entregar al agente permisos que no necesita.

Fuentes: freeCodeCamp.org, CHOOSETO, OWASP Foundation, Simon Willison’s Weblog