DEV Community

Cover image for Por qué osFoundry aplica la lista de herramientas permitidas de Claude Code en el servidor MCP y no en los flags de la CLI
BBT for BBT Español

Posted on

Por qué osFoundry aplica la lista de herramientas permitidas de Claude Code en el servidor MCP y no en los flags de la CLI

Esta es la configuración MCP que la app de escritorio de osFoundry le pasa a Claude Code cuando lo ejecuta como worker en modo agente. El bridge de este ejemplo es mi reimplementación en Python, así que el puerto y el token salen de esa ejecución. La forma es la de src-tauri/src/mcp_bridge.rs:

{"mcpServers": {"osfoundry": {
  "type": "http",
  "url": "http://127.0.0.1:51377/mcp",
  "headers": {"Authorization": "Bearer 8dd5de…"},
  "timeout": 120000
}}}
Enter fullscreen mode Exit fullscreen mode

Cuatro detalles sostienen todo el modelo de seguridad. El host es 127.0.0.1, nunca 0.0.0.0. El puerto lo asigna el sistema operativo (bind("127.0.0.1:0")). El token son 64 caracteres hexadecimales generados para esta ejecución y para ninguna otra. Y no hay ninguna lista de herramientas. La línea de comandos que lo acompaña tampoco lleva --allowedTools:

claude -p "<conversation>" --output-format stream-json --verbose --include-partial-messages \
  --permission-mode bypassPermissions --add-dir <folder> \
  --mcp-config '<the JSON above>' --strict-mcp-config
Enter fullscreen mode Exit fullscreen mode

La lista de permitidas existe. Vive en el servidor. A continuación, el porqué y una reimplementación que puedes ejecutar.

¿Por qué la lista de permitidas está en el servidor?

El modo agente ejecuta Claude Code con --permission-mode bypassPermissions, porque no hay nadie delante de una terminal para aprobar cada paso. El comentario en build_claude_command explica por qué no pasa ninguna lista: con bypassPermissions, Claude Code ignora --allowedTools. El flag sería puramente decorativo. La cabecera de mcp_bridge.rs lo dice en menos palabras: "Never trust the client; the server decides."

La razón general vale para cualquier CLI de agentes. Un flag es una instrucción para el cliente, y el cliente es justo quien ejecuta el código que dirige el modelo. No es el sitio para la única comprobación que no puede fallar. El servidor ve cada tools/call y no lo controla el modelo, así que el trabajo le toca a él.

osFoundry lo hace en dos puntos:

  1. tools/list devuelve solo las herramientas de la lista de la ejecución (agent_cli::tool_policy las filtra antes). Al modelo nunca se le dice que existen las demás.
  2. tools/call comprueba el nombre contra esa misma lista antes de ejecutar nada. Un modelo puede emitir igualmente el nombre de una herramienta que nunca se le mostró, sacado de sus datos de entrenamiento o de una prompt injection en un archivo que ha leído. El primer filtro no hace nada contra eso. El segundo sí.

¿Qué ve la CLI en la primera llamada?

Tres POST, cada uno en una conexión nueva, porque el servidor responde con Connection: close:

→ initialize                      ← 200 {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"osfoundry",…}}
→ notifications/initialized       ← 202, Content-Length: 0
→ tools/list                      ← 200 {"tools":[{"name":"memory_search","description":…,"inputSchema":{…}}]}
Enter fullscreen mode Exit fullscreen mode

El 202 con cuerpo vacío no es pedantería. El código Rust deja constancia de que el cliente MCP de Codex (rmcp) cierra su transporte si una notificación recibe {} con 200, y la ejecución entera moría en el handshake. Claude Code lo tolera. Codex no. Cualquier mensaje JSON-RPC sin id, o con un método notifications/, recibe un 202 sin cuerpo.

Después Claude Code expone cada herramienta como mcp__<server>__<tool>, así que memory_search pasa a ser mcp__osfoundry__memory_search. El nombre del servidor en la configuración es lo que pone osfoundry en medio.

La reimplementación

En esta máquina no hay ningún binario claude instalado. Así que el lado cliente es un script que hace lo mismo que un agente headless con un servidor MCP HTTP: parsear --mcp-config, hacer el handshake y llamar a herramientas. El servidor es la lógica del bridge en Rust escrita con la librería estándar de Python. Archivos completos: bridge.py, fake_cli.py y run_demo.py, unas 230 líneas en total.

La parte que importa, de bridge.py:

class Run:
    def __init__(self, workspace_id, channel_id, allowed, poll=0.5):
        self.workspace_id, self.channel_id = workspace_id, channel_id  # bound now, never read from a request
        self.allowed = [t for t in allowed if t in REGISTRY]
        self.token = secrets.token_hex(32)                              # 64 hex chars, new every run
        ...
            def do_POST(self):
                body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
                auth = self.headers.get("Authorization", "")
                tok = auth[7:].strip() if auth[:7].lower() == "bearer " else ""
                if not hmac.compare_digest(tok.encode(), run.token.encode()):
                    return self.send(401, {"error": "unauthorized"})
                req = json.loads(body)
                i, method = req.get("id"), req.get("method", "")
                if i is None or method.startswith("notifications/"):
                    return self.send(202)
                ...
                if method == "tools/call":
                    p = req.get("params") or {}
                    name = p.get("name") or ""
                    if name not in run.allowed:  # the only gate that counts
                        return self.send(200, rpc_err(i, -32601, f"tool '{name}' is not available to this agent"))
                    text = execute_tool(run.workspace_id, run.channel_id, name, p.get("arguments") or {})
                    return self.send(200, rpc_ok(i, {"content": [{"type": "text", "text": text}]}))

        self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)  # loopback, OS-assigned port
Enter fullscreen mode Exit fullscreen mode

execute_tool hace las veces de orchestrator::execute_tool. En la app, esa es la única función por la que pasa toda llamada a herramienta, venga de quien venga, con aprobaciones, plan mode, overrides de canal y sandbox. El bridge no añade ninguna vía de ejecución propia. Solo decide si una llamada llega o no a esa función.

El registro tiene tres herramientas: memory_search, execute_command y delete_file. La ejecución arranca con allowed=["memory_search"]. Luego la CLI falsa hace cuatro cosas:

tools seen by the CLI: ['mcp__osfoundry__memory_search']
call 1: memory_search {"query":"budget"}
  → {"result":{"content":[{"type":"text","text":"Q4 budget review: Thu 14:00."}]}}
call 2: execute_command {"cmd":"rm -rf ~/work"}
  → {"error":{"code":-32601,"message":"tool 'execute_command' is not available to this agent"}}
call 3: memory_search {"query":"contract","workspace_id":"ws-client"}
  → {"result":{"content":[{"type":"text","text":"no matching notes"}]}}
call 4 (wrong token): (401, {'error': 'unauthorized'})
audit: [('ws-personal','ch-ops','memory_search',{'query':'budget'}),
        ('ws-personal','ch-ops','memory_search',{'query':'contract','workspace_id':'ws-client'})]
after the run: connect to 51377 -> refused
Enter fullscreen mode Exit fullscreen mode

La llamada 2 es el caso que un flag no puede atrapar. El modelo nombra una herramienta que nunca se le listó, y el servidor la rechaza antes de execute_tool. La lista de auditoría lo demuestra: execute_command no aparece nunca.

La llamada 3 intenta apuntar a otro workspace a través de los argumentos. La nota "Client X contract renews 2027-01-31" existe, pero solo en ws-client. El bridge pasa su propio workspace_id fijado, así que la búsqueda se hace en ws-personal y no encuentra nada. En el código Rust, workspace_id y channel_id están en SessionCtx desde start() en adelante, y nada los lee de la petición.

La última línea comprueba que el endpoint muere con la ejecución. En la app, McpSession envía la señal de apagado en Drop, así que ni un panic ni un return temprano pueden dejar vivo un listener que ejecute herramientas.

¿Cuánto cuesta?

Todas las cifras salen de run_demo.py en un Apple M1 (8 GB) con Python 3.9.6, mientras otros trabajos de render mantenían la carga media en torno a 10. Cada petición abre una conexión TCP nueva, como obliga el bridge real con Connection: close. Los tiempos son de reloj de pared, medidos con time.perf_counter() alrededor de cada llamada.

Paso p50 p95 n
Arranque del bridge (bind + hilo del servidor) 1.20 ms 2.69 ms 200
Handshake (initialize, notificación, tools/list) 1.82 ms 4.98 ms 200
tools/call, permitida 0.41 ms 1.06 ms 1000
tools/call, rechazada por la lista de permitidas 0.41 ms 1.08 ms 1000
Token incorrecto, 401 0.40 ms 1.09 ms 1000
Apagado, serve_forever(poll_interval=0.5) 500.91 ms 501.98 ms 200
Apagado, poll_interval=0.01 10.88 ms 11.07 ms 200

Levantar un bridge por ejecución cuesta unos 3 ms, arranque más handshake. Un proceso de Claude Code tarda muchísimo más que eso solo en arrancar, así que un servidor por ejecución sale gratis en la práctica. La llamada rechazada cuesta lo mismo que la permitida. La comprobación es una búsqueda en una lista y se pierde en el ruido de un round trip TCP.

La única sorpresa fue el apagado. serve_forever de Python solo comprueba si debe pararse una vez por poll_interval, 0.5 s por defecto, así que cada fin de ejecución esperaba medio segundo. El bridge en Rust no tiene este problema: su bucle de accept es un tokio::select! sobre accept() y un canal watch, de modo que el apagado lo despierta al instante. En Python, pasa un poll_interval más pequeño o cierra el socket desde la ruta de salida de la ejecución.

¿Por qué HTTP y no stdio?

La mayoría de los servidores MCP que conectas a Claude Code son procesos stdio. El de osFoundry no, y mcp_bridge.rs explica por qué. El ejecutor de herramientas necesita el AppHandle de la app, que no se puede serializar ni mover a otro proceso. Un servidor stdio sería un segundo proceso que de todos modos tendría que reenviar cada llamada a la app. Claude Code soporta servidores "type": "http" con cabeceras bearer, así que la app sirve MCP desde dentro de sí misma en un puerto loopback. El precio es el puerto y el token, y por eso ambos son por ejecución.

Si lo montas tú

  • Pon la lista de permitidas en el handler de tools/call del servidor y filtra también tools/list. Trata los flags de la CLI como --allowedTools como simples pistas, sobre todo con cualquier modo de permisos tipo "bypass".
  • Fija workspace, usuario y canal al iniciar la sesión. Nunca los tomes de argumentos de herramientas ni de cabeceras que controla el cliente.
  • Genera el bearer por ejecución y compáralo en tiempo constante (hmac.compare_digest). No reutilices un token de agente de larga duración para un endpoint que ejecuta herramientas.
  • Haz bind a 127.0.0.1:0 y deja que el sistema operativo elija el puerto. Ata la vida del listener al proceso hijo y comprueba que el apagado es realmente inmediato.
  • Responde a las notificaciones con un 202 sin cuerpo. Algunos clientes cortan la conexión con cualquier otra cosa.
  • Pasa --strict-mcp-config, o el equivalente de tu CLI, para que los servidores MCP propios del usuario no se cuelen en una ejecución que querías acotar.

Cómo acota osFoundry las herramientas por agente fuera del caso de la CLI está en la página de alcance de herramientas. Cómo gestiona las claves y los logins de suscripción para External AI está en trae tus propias claves.

Top comments (0)