Das hier ist die MCP-Konfiguration, die die Desktop-App von osFoundry an Claude Code übergibt, wenn sie es als Worker im Agent-Modus startet. Die Bridge ist hier mein Nachbau in Python, Port und Token stammen also aus diesem Lauf. Die Struktur ist die aus src-tauri/src/mcp_bridge.rs:
{"mcpServers": {"osfoundry": {
"type": "http",
"url": "http://127.0.0.1:51377/mcp",
"headers": {"Authorization": "Bearer 8dd5de…"},
"timeout": 120000
}}}
Vier Details tragen das ganze Sicherheitsmodell. Der Host ist 127.0.0.1, niemals 0.0.0.0. Den Port vergibt das Betriebssystem (bind("127.0.0.1:0")). Das Token besteht aus 64 Hex-Zeichen, erzeugt für genau diesen Lauf und sonst nirgends gültig. Und eine Tool-Liste steht überhaupt nicht drin. Die zugehörige Kommandozeile hat auch kein --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
Die Allowlist gibt es. Sie liegt im Server. Unten steht, warum, plus ein lauffähiger Nachbau zum Ausprobieren.
Warum liegt die Allowlist im Server?
Der Agent-Modus startet Claude Code mit --permission-mode bypassPermissions, weil niemand am Terminal sitzt, der jeden Schritt freigibt. Der Kommentar in build_claude_command erklärt, warum keine Allowlist übergeben wird: Unter bypassPermissions ignoriert Claude Code --allowedTools. Das Flag wäre reine Deko. Der Header von mcp_bridge.rs sagt dasselbe kürzer: "Never trust the client; the server decides."
Der allgemeine Grund gilt für jede Agent-CLI. Ein Flag ist eine Anweisung an den Client, und der Client ist genau die Partei, die modellgesteuerten Code ausführt. Das ist der falsche Ort für die eine Prüfung, die halten muss. Der Server sieht jeden tools/call und wird nicht vom Modell gesteuert, also bekommt er den Job.
osFoundry macht das an zwei Stellen:
-
tools/listliefert nur die Tools aus der Liste des Laufs (agent_cli::tool_policygrenzt sie vorher ein). Dem Modell wird nie gesagt, dass es die anderen gibt. -
tools/callprüft den Namen gegen dieselbe Liste, bevor irgendetwas läuft. Ein Modell kann trotzdem einen Tool-Namen ausgeben, der ihm nie gezeigt wurde, aus den Trainingsdaten oder durch eine Prompt-Injection in einer gelesenen Datei. Dagegen hilft der erste Filter nicht. Der zweite schon.
Was sieht die CLI beim ersten Aufruf?
Drei POSTs, jeder auf einer frischen Verbindung, weil der Server mit Connection: close antwortet:
→ 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":{…}}]}
Das 202 mit leerem Body ist keine Pedanterie. Im Rust-Code ist festgehalten, dass der MCP-Client von Codex (rmcp) seinen Transport abbaut, wenn eine Notification {} mit 200 bekommt, und damit starb der ganze Lauf schon beim Handshake. Claude Code verträgt das. Codex nicht. Jede JSON-RPC-Nachricht ohne id oder mit einer notifications/-Methode bekommt ein 202 ohne Body.
Danach stellt Claude Code jedes Tool als mcp__<server>__<tool> bereit, aus memory_search wird also mcp__osfoundry__memory_search. Der Servername in der Konfiguration sorgt für das osfoundry in der Mitte.
Der Nachbau
Auf diesem Rechner ist kein claude-Binary installiert. Die Client-Seite ist deshalb ein Skript, das das tut, was ein Headless-Agent mit einem HTTP-MCP-Server tut: --mcp-config parsen, den Handshake durchlaufen, Tools aufrufen. Der Server ist die Logik der Rust-Bridge, nachgebaut mit der Python-Standardbibliothek. Vollständige Dateien: bridge.py, fake_cli.py und run_demo.py, zusammen etwa 230 Zeilen.
Der entscheidende Teil aus 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 steht stellvertretend für orchestrator::execute_tool. In der App ist das die eine Funktion, durch die jeder Tool-Aufruf läuft, egal von welchem Aufrufer, inklusive Freigabe, Plan-Modus, Channel-Overrides und Sandbox. Die Bridge fügt keinen eigenen Ausführungspfad hinzu. Sie entscheidet nur, ob ein Aufruf diese Funktion erreicht.
Die Registry enthält drei Tools: memory_search, execute_command und delete_file. Der Lauf startet mit allowed=["memory_search"]. Dann macht die Fake-CLI vier Dinge:
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
Aufruf 2 ist der Fall, den ein Flag nicht abfangen kann. Das Modell nennt ein Tool, das ihm nie aufgelistet wurde, und der Server lehnt es vor execute_tool ab. Die Audit-Liste belegt das: execute_command taucht dort nie auf.
Aufruf 3 versucht, über die Argumente einen anderen Workspace anzusteuern. Die Notiz "Client X contract renews 2027-01-31" existiert, aber nur in ws-client. Die Bridge übergibt ihre eigene gebundene workspace_id, also läuft die Suche in ws-personal und findet nichts. Im Rust-Code liegen workspace_id und channel_id ab start() in SessionCtx, und nichts liest sie aus dem Request.
Die letzte Zeile prüft, dass der Endpoint mit dem Lauf stirbt. In der App sendet McpSession das Shutdown-Signal in Drop, sodass weder ein Panic noch ein frühes Return einen Listener zurücklassen kann, der Tools ausführt.
Was kostet das?
Alle Zahlen stammen aus run_demo.py auf einem Apple M1 (8 GB) mit Python 3.9.6, während andere Render-Jobs den Load Average bei etwa 10 hielten. Jeder Request öffnet eine neue TCP-Verbindung, so wie es die echte Bridge mit Connection: close erzwingt. Die Zeiten sind Wall-Clock, gemessen mit time.perf_counter() um jeden Aufruf.
| Schritt | p50 | p95 | n |
|---|---|---|---|
| Bridge-Start (bind + Server-Thread) | 1.20 ms | 2.69 ms | 200 |
| Handshake (initialize, Notification, tools/list) | 1.82 ms | 4.98 ms | 200 |
tools/call, erlaubt |
0.41 ms | 1.06 ms | 1000 |
tools/call, von der Allowlist abgelehnt |
0.41 ms | 1.08 ms | 1000 |
| Falsches Token, 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 |
Eine Bridge pro Lauf kostet etwa 3 ms, Start plus Handshake. Ein Claude-Code-Prozess braucht allein zum Hochfahren deutlich länger, ein Server pro Lauf ist in der Praxis also gratis. Der abgelehnte Aufruf kostet genauso viel wie der erlaubte. Die Prüfung ist ein Listen-Lookup und geht im Rauschen eines TCP-Roundtrips unter.
Die einzige Überraschung war der Shutdown. Pythons serve_forever prüft nur einmal pro poll_interval auf Shutdown, standardmäßig 0.5 s, also wartete jedes Laufende eine halbe Sekunde. Die Rust-Bridge hat dieses Problem nicht: Ihre Accept-Schleife ist ein tokio::select! über accept() und einen watch-Channel, der Shutdown weckt sie also sofort. In Python übergibst du ein kleineres poll_interval oder schließt den Socket im Exit-Pfad des Laufs.
Warum HTTP und nicht stdio?
Die meisten MCP-Server, die du an Claude Code hängst, sind stdio-Prozesse. Der von osFoundry nicht, und mcp_bridge.rs erklärt, warum. Der Tool-Executor braucht das AppHandle der App, und das lässt sich weder serialisieren noch in einen anderen Prozess verschieben. Ein stdio-Server wäre ein zweiter Prozess, der ohnehin jeden Aufruf zurück in die App weiterreichen müsste. Claude Code unterstützt "type": "http"-Server mit Bearer-Headern, also stellt die App MCP aus sich selbst heraus auf einem Loopback-Port bereit. Der Preis dafür sind Port und Token, und deshalb gelten beide nur pro Lauf.
Wenn du das selbst verdrahtest
- Setz die Allowlist in den
tools/call-Handler des Servers und filtere zusätzlichtools/list. Behandle CLI-Flags wie--allowedToolsals Hinweise, besonders unter jedem "bypass"-Berechtigungsmodus. - Binde Workspace, User und Channel beim Sessionstart. Übernimm sie nie aus Tool-Argumenten oder Headern, die der Client kontrolliert.
- Erzeuge das Bearer-Token pro Lauf und vergleiche es in konstanter Zeit (
hmac.compare_digest). Verwende kein langlebiges Agent-Token für einen Endpoint, der Tools ausführt. - Binde auf
127.0.0.1:0und lass das Betriebssystem den Port wählen. Kopple die Lebensdauer des Listeners an den Kindprozess und prüfe, ob der Shutdown-Pfad wirklich sofort greift. - Beantworte Notifications mit einem 202 ohne Body. Manche Clients brechen bei allem anderen die Verbindung ab.
- Übergib
--strict-mcp-configoder das Pendant deiner CLI, damit die eigenen MCP-Server des Users nicht in einen Lauf rutschen, den du bewusst eingegrenzt hast.
Wie osFoundry Tools pro Agent außerhalb des CLI-Falls eingrenzt, steht auf der Seite zum Tool-Scope. Wie es Keys und Abo-Logins für External AI handhabt, steht unter eigene Keys mitbringen.
Top comments (0)