Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

Why osFoundry enforces Claude Code's tool allowlist on the MCP server, not in CLI flags

This is the MCP config the osFoundry desktop app hands to Claude Code when it runs it as an agent-mode worker. The bridge here is my Python rebuild, so the port and token are from that run. The shape is the one in src-ta

This is the MCP config the osFoundry desktop app hands to Claude Code when it runs it as an agent-mode worker. The bridge here is my Python rebuild, so the port and token are from that run. The shape is the one in src-tauri/src/mcp_bridge.rs:

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

Four details carry the security model. The host is 127.0.0.1, never 0.0.0.0. The port is OS-assigned (bind("127.0.0.1:0")). The token is 64 hex characters minted for this run and nowhere else. And there's no tool list in it at all. The command line that goes with it has no --allowedTools either:

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

The allowlist exists. It lives on the server. Below is why, plus a working rebuild you can run.

Why is the allowlist on the server?

Agent mode runs Claude Code with --permission-mode bypassPermissions, because nobody is at a terminal to approve each step. The comment in build_claude_command says why it passes no allowlist: under bypassPermissions, Claude Code ignores --allowedTools. So the flag would be decorative. The header of mcp_bridge.rs says the same in fewer words: "Never trust the client; the server decides."

The general reason holds for any agent CLI. A flag is an instruction to the client, and the client is the party that runs model-driven code. It's the wrong place for the one check that has to hold. The server sees every tools/call and is not run by the model, so it gets the job.

osFoundry does it in two places:

  1. tools/list returns only the tools in the run's list (agent_cli::tool_policy narrows them first). The model is never told the others exist.
  2. tools/call checks the name against the same list before anything runs. A model can still emit a tool name it was never shown, from training data or a prompt injection in a file it read. The first filter does nothing about that. The second one does.

What does the CLI see on the first call?

Three POSTs, each on a fresh connection, since the server answers 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":{…}}]}

The 202 with an empty body is not pedantry. The Rust source records that Codex's MCP client (rmcp) kills its transport if a notification gets {} with 200, so the whole run died at the handshake. Claude Code tolerates it. Codex doesn't. Any JSON-RPC message without an id, or with a notifications/ method, gets a bodyless 202.

Claude Code then exposes each tool as mcp__<server>__<tool>, so memory_search becomes mcp__osfoundry__memory_search. The server name in the config is what puts osfoundry in the middle.

The rebuild

No claude binary is installed on this machine. So the client side is a script that does what a headless agent does with an HTTP MCP server: parse --mcp-config, run the handshake, call tools. The server is the Rust bridge's logic in the Python standard library. Full files: bridge.py, fake_cli.py and run_demo.py, about 230 lines in all.

The part that matters, from 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 stands in for orchestrator::execute_tool. In the app, that's the one function every caller's tool call goes through, with approval, plan mode, channel overrides and the sandbox. The bridge adds no execution path of its own. It only decides whether a call reaches that function.

The registry has three tools: memory_search, execute_command and delete_file. The run is started with allowed=["memory_search"]. Then the fake CLI does four things:

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

Call 2 is the case a flag can't catch. The model names a tool it was never listed, and the server refuses it before execute_tool. The audit list proves it: execute_command never appears there.

Call 3 tries to aim at another workspace through the arguments. The note "Client X contract renews 2027-01-31" exists, but only in ws-client. The bridge passes its own bound workspace_id, so the search runs in ws-personal and finds nothing. In the Rust code, workspace_id and channel_id sit in SessionCtx from start() onward, and nothing reads them from the request.

The last line checks that the endpoint dies with the run. In the app, McpSession sends the shutdown signal in Drop, so a panic or an early return can't leave a listener that runs tools.

What does it cost?

All numbers are from run_demo.py on an Apple M1 (8 GB) with Python 3.9.6, while other render jobs kept the load average around 10. Each request opens a new TCP connection, as the real bridge forces with Connection: close. Times are wall-clock from time.perf_counter() around each call.

Step 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, allowed 0.41 ms 1.06 ms 1000
tools/call, refused by the allowlist 0.41 ms 1.08 ms 1000
Wrong 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

Starting a bridge per run costs about 3 ms, start plus handshake. A Claude Code process takes far longer than that just to boot, so a per-run server is free in practice. The refused call costs the same as the allowed one. The check is a list lookup, lost in the noise of a TCP round trip.

The one surprise was shutdown. Python's serve_forever checks for shutdown only once per poll_interval, 0.5 s by default, so every run end waited half a second. The Rust bridge doesn't have this problem: its accept loop is a tokio::select! over accept() and a watch channel, so shutdown wakes it at once. In Python, pass a smaller poll_interval, or close the socket from the run's exit path.

Why HTTP and not stdio?

Most MCP servers you attach to Claude Code are stdio processes. osFoundry's isn't, and mcp_bridge.rs explains why. The tool executor needs the app's AppHandle, which can't be serialized or moved to another process. A stdio server would be a second process that has to forward every call back into the app anyway. Claude Code supports "type": "http" servers with bearer headers, so the app serves MCP from inside itself on a loopback port. The cost is the port and the token, which is why both are per-run.

If you wire this up yourself

  • Put the allowlist in the server's tools/call handler and filter tools/list too. Treat CLI flags such as --allowedTools as hints, especially under any "bypass" permission mode.
  • Bind workspace, user and channel when the session starts. Never take them from tool arguments or headers the client controls.
  • Mint the bearer per run, and compare it in constant time (hmac.compare_digest). Don't reuse a long-lived agent token for a tool-execution endpoint.
  • Bind 127.0.0.1:0 and let the OS pick the port. Tie the listener's life to the child process, and check that the shutdown path is actually immediate.
  • Answer notifications with a bodyless 202. Some clients drop the connection on anything else.
  • Pass --strict-mcp-config, or your CLI's equivalent, so the user's own MCP servers don't join a run you meant to scope.

The way osFoundry scopes tools per agent outside the CLI case is on the tool scope page. How it handles keys and subscription logins for External AI is under bring your own keys.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.