為什麼 osFoundry 把 Claude Code 的工具白名單放在 MCP 伺服器上,而不是 CLI 參數裡
這是 osFoundry 桌面 app 以 agent mode worker 身分執行 Claude Code 時,交給它的 MCP 設定。這裡的 bridge 是我用 Python 重建的版本,所以 port 和 token 都來自那次執行。結構則和 src-tauri/src/mcp_bridge.rs 裡的一樣: {"mcpServers": {"osfoundry": { "type": "http", "url
這是 osFoundry 桌面 app 以 agent mode worker 身分執行 Claude Code 時,交給它的 MCP 設定。這裡的 bridge 是我用 Python 重建的版本,所以 port 和 token 都來自那次執行。結構則和 src-tauri/src/mcp_bridge.rs 裡的一樣:
{"mcpServers": {"osfoundry": {
"type": "http",
"url": "http://127.0.0.1:51377/mcp",
"headers": {"Authorization": "Bearer 8dd5de…"},
"timeout": 120000
}}}
整個安全模型靠四個細節撐著。host 是 127.0.0.1,絕不是 0.0.0.0。port 由作業系統分配(bind("127.0.0.1:0"))。token 是 64 個十六進位字元,只為這次執行產生,別的地方都不用。而且裡面完全沒有工具清單。搭配的指令列也沒有 --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
白名單是有的,只是放在伺服器上。下面說明原因,另外附一個你可以自己跑的重建版本。
為什麼白名單要放在伺服器上?
agent mode 用 --permission-mode bypassPermissions 執行 Claude Code,因為沒有人坐在終端機前逐步核准。build_claude_command 裡的註解說明了它為什麼不傳白名單:在 bypassPermissions 下,Claude Code 會忽略 --allowedTools。所以這個參數只是裝飾。mcp_bridge.rs 的檔頭用更少的字講了同一件事:"Never trust the client; the server decides."
這個道理對任何 agent CLI 都成立。參數是給 client 的指示,而 client 正是執行模型驅動程式碼的那一方。那個無論如何都必須守住的檢查,不該放在這裡。伺服器看得到每一個 tools/call,而且不是由模型在跑,所以這份工作交給它。
osFoundry 在兩個地方做這件事:
-
tools/list只回傳這次執行清單裡的工具(agent_cli::tool_policy會先把範圍縮小)。模型從頭到尾不會知道其他工具存在。 -
tools/call在任何東西執行之前,先拿名稱和同一份清單比對。模型還是可能吐出一個從沒給它看過的工具名稱,來源可能是訓練資料,或是它讀到的某個檔案裡的 prompt injection。第一道過濾對此無能為力,第二道可以。
第一次呼叫時 CLI 會看到什麼?
三個 POST,每個都走新的連線,因為伺服器回應 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":{…}}]}
body 為空的 202 不是在吹毛求疵。Rust 原始碼裡記錄著,Codex 的 MCP client(rmcp)如果在 notification 收到 200 加 {},就會把 transport 關掉,結果整個執行在 handshake 就掛了。Claude Code 吃得下這種回應,Codex 不行。任何沒有 id 的 JSON-RPC 訊息,或 method 是 notifications/ 的訊息,都會收到沒有 body 的 202。
接著 Claude Code 會把每個工具以 mcp__<server>__<tool> 的形式公開,所以 memory_search 會變成 mcp__osfoundry__memory_search。中間的 osfoundry 來自設定裡的伺服器名稱。
重建版本
這台機器上沒有安裝 claude 執行檔。所以 client 端是一支腳本,做的事和 headless agent 面對 HTTP MCP 伺服器時一樣:解析 --mcp-config、跑 handshake、呼叫工具。伺服器則是用 Python 標準函式庫重寫的 Rust bridge 邏輯。完整檔案:bridge.py、fake_cli.py 和 run_demo.py,總共約 230 行。
關鍵的部分,出自 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 代替的是 orchestrator::execute_tool。在 app 裡,那是所有呼叫者的工具呼叫都必須經過的唯一函式,包含核准、plan mode、頻道覆寫和 sandbox。bridge 沒有加上自己的執行路徑,它只決定一個呼叫能不能到達那個函式。
registry 裡有三個工具:memory_search、execute_command 和 delete_file。執行以 allowed=["memory_search"] 啟動。接著假的 CLI 做了四件事:
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 就是參數擋不住的情況。模型叫了一個從沒列給它的工具,伺服器在 execute_tool 之前就拒絕了。audit 清單就是證據:execute_command 從來沒出現在裡面。
call 3 想透過引數指向另一個 workspace。"Client X contract renews 2027-01-31" 這則筆記確實存在,但只在 ws-client 裡。bridge 傳的是自己綁定的 workspace_id,所以搜尋在 ws-personal 裡跑,什麼都沒找到。在 Rust 程式碼裡,workspace_id 和 channel_id 從 start() 開始就放在 SessionCtx,沒有任何地方從 request 讀取它們。
最後一行確認 endpoint 會跟著執行一起結束。在 app 裡,McpSession 會在 Drop 時送出 shutdown 訊號,所以 panic 或提早 return 都不會留下一個還能執行工具的 listener。
成本有多少?
所有數字都來自在 Apple M1(8 GB)、Python 3.9.6 上跑的 run_demo.py,當時其他 render 工作讓 load average 維持在 10 左右。每個 request 都開一條新的 TCP 連線,就像真正的 bridge 用 Connection: close 強制的那樣。時間是每次呼叫前後用 time.perf_counter() 量的 wall-clock。
| 步驟 | p50 | p95 | n |
|---|---|---|---|
| 啟動 bridge(bind + 伺服器執行緒) | 1.20 ms | 2.69 ms | 200 |
| Handshake(initialize、notification、tools/list) | 1.82 ms | 4.98 ms | 200 |
tools/call,允許 |
0.41 ms | 1.06 ms | 1000 |
tools/call,被白名單拒絕 |
0.41 ms | 1.08 ms | 1000 |
| 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 |
每次執行啟動一個 bridge 大約花 3 ms,包含啟動和 handshake。Claude Code 行程光是開機就比這久得多,所以每次執行一個伺服器,實際上等於免費。被拒絕的呼叫和被允許的呼叫成本一樣。檢查只是一次清單查找,淹沒在 TCP 來回的雜訊裡。
唯一的意外是 shutdown。Python 的 serve_forever 每個 poll_interval 才檢查一次 shutdown,預設 0.5 s,所以每次執行結束都要等半秒。Rust bridge 沒有這個問題:它的 accept loop 是對 accept() 和一個 watch channel 做 tokio::select!,所以 shutdown 會立刻把它叫醒。在 Python 裡,傳一個更小的 poll_interval,或從執行的結束路徑直接關掉 socket。
為什麼用 HTTP 而不是 stdio?
你接到 Claude Code 上的 MCP 伺服器,大多是 stdio 行程。osFoundry 的不是,mcp_bridge.rs 解釋了原因。工具執行器需要 app 的 AppHandle,而它無法序列化,也不能搬到另一個行程。stdio 伺服器會是第二個行程,終究還是得把每個呼叫轉回 app。Claude Code 支援帶 bearer header 的 "type": "http" 伺服器,所以 app 直接在自己內部、透過 loopback port 提供 MCP。代價是 port 和 token,這也是兩者都按每次執行產生的原因。
如果你要自己串接
- 把白名單放在伺服器的
tools/callhandler 裡,也要過濾tools/list。把--allowedTools這類 CLI 參數當成提示就好,尤其是在任何「bypass」類的 permission mode 下。 - 在 session 開始時就綁定 workspace、使用者和頻道。絕對不要從工具引數或 client 控制的 header 取得這些值。
- 每次執行都產生新的 bearer,並用常數時間比對(
hmac.compare_digest)。不要把長期有效的 agent token 重複用在執行工具的 endpoint 上。 - bind 到
127.0.0.1:0,讓作業系統挑 port。把 listener 的生命週期綁在子行程上,並確認 shutdown 路徑真的是立即的。 - 對 notification 回應沒有 body 的 202。有些 client 收到其他回應就會斷線。
- 加上
--strict-mcp-config,或你的 CLI 中對應的設定,避免使用者自己的 MCP 伺服器混進你原本想限定範圍的執行。
在 CLI 以外的情境,osFoundry 怎麼為每個 agent 限定工具範圍,請看 tool scope 頁面。它如何處理 External AI 的金鑰和訂閱登入,請看 bring your own keys。
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.