DEV Community

Cover image for Perché osFoundry applica la allowlist dei tool di Claude Code sul server MCP e non nei flag della CLI
BBT for BBT Italiano

Posted on

Perché osFoundry applica la allowlist dei tool di Claude Code sul server MCP e non nei flag della CLI

Questa è la config MCP che l'app desktop di osFoundry passa a Claude Code quando lo lancia come worker in agent mode. Il bridge qui è la mia ricostruzione in Python, quindi porta e token vengono da quella esecuzione. La struttura è quella di 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

Il modello di sicurezza sta in quattro dettagli. L'host è 127.0.0.1, mai 0.0.0.0. La porta la assegna il sistema operativo (bind("127.0.0.1:0")). Il token è di 64 caratteri esadecimali, generato per questa esecuzione e per nessun'altra. E non c'è nessuna lista di tool. Neanche la riga di comando che l'accompagna ha --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 allowlist esiste. Sta sul server. Qui sotto trovi il perché, più una ricostruzione funzionante che puoi eseguire.

Perché la allowlist sta sul server?

L'agent mode esegue Claude Code con --permission-mode bypassPermissions, perché non c'è nessuno al terminale ad approvare ogni passo. Il commento in build_claude_command spiega perché non passa nessuna allowlist: con bypassPermissions, Claude Code ignora --allowedTools. Il flag sarebbe quindi decorativo. L'header di mcp_bridge.rs dice lo stesso in meno parole: "Never trust the client; the server decides."

Il motivo generale vale per qualsiasi CLI di agenti. Un flag è un'istruzione al client, e il client è proprio la parte che esegue codice guidato dal modello. È il posto sbagliato per l'unico controllo che deve reggere. Il server vede ogni tools/call e non è il modello a farlo girare, quindi il compito tocca a lui.

osFoundry lo fa in due punti:

  1. tools/list restituisce solo i tool presenti nella lista dell'esecuzione (agent_cli::tool_policy li restringe prima). Al modello non viene mai detto che gli altri esistono.
  2. tools/call confronta il nome con la stessa lista prima che parta qualsiasi cosa. Un modello può comunque emettere il nome di un tool che non gli è mai stato mostrato, preso dai dati di training o da una prompt injection in un file che ha letto. Il primo filtro non può farci niente. Il secondo sì.

Cosa vede la CLI alla prima chiamata?

Tre POST, ognuna su una connessione nuova, perché il server risponde 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

Il 202 con body vuoto non è pignoleria. Il sorgente Rust annota che il client MCP di Codex (rmcp) chiude il transport se una notifica riceve {} con 200, e così l'intera esecuzione moriva all'handshake. Claude Code lo tollera. Codex no. Qualsiasi messaggio JSON-RPC senza id, o con un metodo notifications/, riceve un 202 senza body.

Poi Claude Code espone ogni tool come mcp__<server>__<tool>, quindi memory_search diventa mcp__osfoundry__memory_search. È il nome del server nella config a mettere osfoundry nel mezzo.

La ricostruzione

Su questa macchina non c'è nessun binario claude installato. Il lato client è quindi uno script che fa quello che fa un agente headless con un server MCP HTTP: legge --mcp-config, esegue l'handshake, chiama i tool. Il server è la logica del bridge Rust riscritta con la sola libreria standard di Python. I file completi sono bridge.py, fake_cli.py e run_demo.py, circa 230 righe in tutto.

La parte che conta, da 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 fa le veci di orchestrator::execute_tool. Nell'app è l'unica funzione da cui passano le chiamate ai tool di qualsiasi chiamante, con approvazione, plan mode, override del canale e sandbox. Il bridge non aggiunge nessun percorso di esecuzione suo. Decide solo se una chiamata arriva a quella funzione.

Il registry ha tre tool: memory_search, execute_command e delete_file. L'esecuzione parte con allowed=["memory_search"]. Poi la finta CLI fa quattro cose:

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 chiamata 2 è il caso che un flag non può intercettare. Il modello nomina un tool che non gli è mai stato elencato, e il server lo rifiuta prima di execute_tool. Lo dimostra la lista di audit: execute_command lì non compare mai.

La chiamata 3 prova a puntare a un altro workspace tramite gli argomenti. La nota "Client X contract renews 2027-01-31" esiste, ma solo in ws-client. Il bridge passa il proprio workspace_id vincolato, quindi la ricerca gira in ws-personal e non trova niente. Nel codice Rust, workspace_id e channel_id stanno in SessionCtx da start() in poi, e niente li legge dalla richiesta.

L'ultima riga verifica che l'endpoint muoia insieme all'esecuzione. Nell'app, McpSession invia il segnale di shutdown in Drop, quindi un panic o un return anticipato non possono lasciare in giro un listener che esegue tool.

Quanto costa?

Tutti i numeri vengono da run_demo.py su un Apple M1 (8 GB) con Python 3.9.6, mentre altri job di render tenevano il load average intorno a 10. Ogni richiesta apre una nuova connessione TCP, come impone il bridge reale con Connection: close. I tempi sono wall-clock, misurati con time.perf_counter() attorno a ogni chiamata.

Passo p50 p95 n
Avvio del bridge (bind + thread del server) 1.20 ms 2.69 ms 200
Handshake (initialize, notifica, tools/list) 1.82 ms 4.98 ms 200
tools/call, consentita 0.41 ms 1.06 ms 1000
tools/call, rifiutata dalla allowlist 0.41 ms 1.08 ms 1000
Token sbagliato, 401 0.40 ms 1.09 ms 1000
Shutdown, serve_forever(poll_interval=0.5) 500.91 ms 501.98 ms 200
Shutdown, poll_interval=0.01 10.88 ms 11.07 ms 200

Avviare un bridge per ogni esecuzione costa circa 3 ms, tra avvio e handshake. Un processo Claude Code ci mette molto di più solo per partire, quindi in pratica un server per esecuzione è gratis. La chiamata rifiutata costa quanto quella consentita. Il controllo è una ricerca in una lista, che si perde nel rumore di un round trip TCP.

L'unica sorpresa è stato lo shutdown. serve_forever di Python controlla lo shutdown solo una volta per poll_interval, 0.5 s di default, quindi ogni fine esecuzione aspettava mezzo secondo. Il bridge Rust non ha questo problema: il suo accept loop è un tokio::select! su accept() e un canale watch, quindi lo shutdown lo sveglia subito. In Python, passa un poll_interval più piccolo, oppure chiudi il socket dal percorso di uscita dell'esecuzione.

Perché HTTP e non stdio?

La maggior parte dei server MCP che colleghi a Claude Code sono processi stdio. Quello di osFoundry no, e mcp_bridge.rs spiega perché. L'executor dei tool ha bisogno dell'AppHandle dell'app, che non si può serializzare né spostare in un altro processo. Un server stdio sarebbe un secondo processo che dovrebbe comunque inoltrare ogni chiamata all'app. Claude Code supporta server "type": "http" con header bearer, quindi l'app serve MCP da dentro se stessa su una porta di loopback. Il prezzo sono porta e token, ed è per questo che entrambi sono per esecuzione.

Se lo implementi tu

  • Metti la allowlist nell'handler tools/call del server e filtra anche tools/list. Tratta i flag della CLI come --allowedTools come suggerimenti, soprattutto con qualsiasi permission mode di tipo "bypass".
  • Vincola workspace, utente e canale all'avvio della sessione. Non prenderli mai da argomenti dei tool o da header controllati dal client.
  • Genera il bearer per ogni esecuzione e confrontalo in tempo costante (hmac.compare_digest). Non riusare un token di agente a lunga durata per un endpoint che esegue tool.
  • Fai bind su 127.0.0.1:0 e lascia scegliere la porta al sistema operativo. Lega la vita del listener al processo figlio e verifica che il percorso di shutdown sia davvero immediato.
  • Rispondi alle notifiche con un 202 senza body. Alcuni client chiudono la connessione con qualsiasi altra risposta.
  • Passa --strict-mcp-config, o l'equivalente della tua CLI, così i server MCP dell'utente non si aggiungono a un'esecuzione che volevi delimitare.

Come osFoundry delimita i tool per agente al di fuori del caso CLI è descritto nella pagina sul tool scope. Come gestisce chiavi e login in abbonamento per l'External AI è spiegato in bring your own keys.

Top comments (0)