Voici la configuration MCP que l'application de bureau osFoundry transmet à Claude Code quand elle le lance comme worker en mode agent. Le bridge utilisé ici est ma réimplémentation en Python, donc le port et le token viennent de cette exécution. La structure est celle 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 }}} 

Quatre détails portent tout le modèle de sécurité. L'hôte est 127.0.0.1, jamais 0.0.0.0. Le port est attribué par l'OS (bind("127.0.0.1:0")). Le token fait 64 caractères hexadécimaux, générés pour cette exécution et valables nulle part ailleurs. Et il n'y a aucune liste d'outils dedans. La ligne de commande qui va avec n'a pas non plus de --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 

La liste d'autorisation existe. Elle vit sur le serveur. Voici pourquoi, avec une réimplémentation que vous pouvez lancer.

Pourquoi la liste d'autorisation est-elle côté serveur ?

Le mode agent lance Claude Code avec --permission-mode bypassPermissions, parce que personne n'est devant un terminal pour valider chaque étape. Le commentaire dans build_claude_command explique pourquoi aucune liste n'est passée : sous bypassPermissions, Claude Code ignore --allowedTools. Le flag serait purement décoratif. L'en-tête de mcp_bridge.rs dit la même chose en moins de mots : "Never trust the client; the server decides."

La raison générale vaut pour n'importe quelle CLI d'agent. Un flag est une consigne donnée au client, et le client est justement celui qui exécute le code piloté par le modèle. Ce n'est pas le bon endroit pour le seul contrôle qui doit tenir. Le serveur voit chaque tools/call et n'est pas piloté par le modèle, c'est donc lui qui s'en charge.

osFoundry le fait à deux endroits :

  1. tools/list ne renvoie que les outils de la liste de l'exécution (agent_cli::tool_policy les restreint d'abord). On ne dit jamais au modèle que les autres existent.
  2. tools/call vérifie le nom contre cette même liste avant d'exécuter quoi que ce soit. Un modèle peut quand même émettre le nom d'un outil qu'on ne lui a jamais montré, tiré de ses données d'entraînement ou d'une prompt injection dans un fichier qu'il a lu. Le premier filtre n'y peut rien. Le second, si.

Que voit la CLI au premier appel ?

Trois POST, chacun sur une nouvelle connexion, puisque le serveur répond 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":{…}}]} 

Le 202 au corps vide n'est pas du zèle. Le code Rust note que le client MCP de Codex (rmcp) coupe son transport si une notification reçoit {} avec un 200, et toute l'exécution mourait au handshake. Claude Code le tolère. Codex, non. Tout message JSON-RPC sans id, ou avec une méthode notifications/, reçoit un 202 sans corps.

Claude Code expose ensuite chaque outil sous la forme mcp__<server>__<tool>, donc memory_search devient mcp__osfoundry__memory_search. C'est le nom du serveur dans la config qui place osfoundry au milieu.

La réimplémentation

Aucun binaire claude n'est installé sur cette machine. Le côté client est donc un script qui fait ce que fait un agent headless face à un serveur MCP HTTP : parser --mcp-config, dérouler le handshake, appeler des outils. Le serveur reprend la logique du bridge Rust avec la bibliothèque standard de Python. Fichiers complets : bridge.py, fake_cli.py et run_demo.py, environ 230 lignes au total.

La partie qui compte, tirée 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 

execute_tool remplace ici orchestrator::execute_tool. Dans l'application, c'est l'unique fonction par laquelle passe chaque appel d'outil, quel que soit l'appelant, avec validation, plan mode, overrides de canal et sandbox. Le bridge n'ajoute aucun chemin d'exécution propre. Il décide seulement si un appel atteint cette fonction.

Le registre contient trois outils : memory_search, execute_command et delete_file. L'exécution démarre avec allowed=["memory_search"]. Ensuite, la fausse CLI fait quatre choses :

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 

L'appel 2 est le cas qu'un flag ne peut pas intercepter. Le modèle nomme un outil qu'on ne lui a jamais listé, et le serveur le refuse avant execute_tool. La liste d'audit le prouve : execute_command n'y apparaît jamais.

L'appel 3 tente de viser un autre workspace via les arguments. La note "Client X contract renews 2027-01-31" existe, mais seulement dans ws-client. Le bridge passe son propre workspace_id lié, donc la recherche tourne dans ws-personal et ne trouve rien. Dans le code Rust, workspace_id et channel_id sont dans SessionCtx dès start(), et rien ne les lit depuis la requête.

La dernière ligne vérifie que l'endpoint meurt avec l'exécution. Dans l'application, McpSession envoie le signal d'arrêt dans Drop, si bien qu'un panic ou un return anticipé ne peut pas laisser traîner un listener qui exécute des outils.

Combien ça coûte ?

Tous les chiffres viennent de run_demo.py sur un Apple M1 (8 GB) avec Python 3.9.6, pendant que d'autres jobs de rendu maintenaient la charge moyenne autour de 10. Chaque requête ouvre une nouvelle connexion TCP, comme l'impose le vrai bridge avec Connection: close. Les temps sont en wall-clock, mesurés avec time.perf_counter() autour de chaque appel.

Étape p50 p95 n
Démarrage du bridge (bind + thread serveur) 1.20 ms 2.69 ms 200
Handshake (initialize, notification, tools/list) 1.82 ms 4.98 ms 200
tools/call, autorisé 0.41 ms 1.06 ms 1000
tools/call, refusé par la liste d'autorisation 0.41 ms 1.08 ms 1000
Mauvais token, 401 0.40 ms 1.09 ms 1000
Arrêt, serve_forever(poll_interval=0.5) 500.91 ms 501.98 ms 200
Arrêt, poll_interval=0.01 10.88 ms 11.07 ms 200

Lancer un bridge par exécution coûte environ 3 ms, démarrage plus handshake. Un processus Claude Code met bien plus longtemps que ça rien que pour démarrer, donc un serveur par exécution est gratuit en pratique. L'appel refusé coûte autant que l'appel autorisé. Le contrôle est une simple recherche dans une liste, noyée dans le bruit d'un aller-retour TCP.

La seule surprise a été l'arrêt. serve_forever de Python ne vérifie la demande d'arrêt qu'une fois par poll_interval, 0.5 s par défaut, donc chaque fin d'exécution attendait une demi-seconde. Le bridge Rust n'a pas ce problème : sa boucle d'accept est un tokio::select! sur accept() et un canal watch, l'arrêt la réveille donc immédiatement. En Python, passez un poll_interval plus petit, ou fermez le socket depuis le chemin de sortie de l'exécution.

Pourquoi HTTP et pas stdio ?

La plupart des serveurs MCP que vous branchez sur Claude Code sont des processus stdio. Celui d'osFoundry, non, et mcp_bridge.rs explique pourquoi. L'exécuteur d'outils a besoin de l'AppHandle de l'application, qui ne peut être ni sérialisé ni déplacé dans un autre processus. Un serveur stdio serait un second processus qui devrait de toute façon renvoyer chaque appel vers l'application. Claude Code prend en charge les serveurs "type": "http" avec en-têtes bearer, donc l'application sert MCP depuis l'intérieur d'elle-même sur un port loopback. Le prix à payer, c'est le port et le token, et c'est pour ça que les deux sont propres à chaque exécution.

Si vous le câblez vous-même

  • Placez la liste d'autorisation dans le handler tools/call du serveur et filtrez aussi tools/list. Traitez les flags de CLI comme --allowedTools comme de simples indications, surtout sous un mode de permissions de type "bypass".
  • Liez workspace, utilisateur et canal au démarrage de la session. Ne les prenez jamais dans des arguments d'outils ou des en-têtes contrôlés par le client.
  • Générez le bearer à chaque exécution et comparez-le en temps constant (hmac.compare_digest). Ne réutilisez pas un token d'agent à longue durée de vie pour un endpoint qui exécute des outils.
  • Faites un bind sur 127.0.0.1:0 et laissez l'OS choisir le port. Liez la durée de vie du listener au processus enfant, et vérifiez que le chemin d'arrêt est vraiment immédiat.
  • Répondez aux notifications par un 202 sans corps. Certains clients coupent la connexion pour tout le reste.
  • Passez --strict-mcp-config, ou l'équivalent de votre CLI, pour que les serveurs MCP personnels de l'utilisateur ne s'invitent pas dans une exécution que vous vouliez restreindre.

La façon dont osFoundry restreint les outils par agent en dehors du cas CLI est décrite sur la page sur la portée des outils. La gestion des clés et des connexions par abonnement pour External AI se trouve dans apportez vos propres clés.