यह वह MCP config है जो osFoundry का desktop app Claude Code को देता है, जब वह उसे agent-mode worker की तरह चलाता है। यहाँ bridge मेरा Python rebuild है, इसलिए port और token उसी run के हैं। Structure वही है जो src-tauri/src/mcp_bridge.rs में है:

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

पूरा security model चार details पर टिका है। Host 127.0.0.1 है, कभी 0.0.0.0 नहीं। Port OS assign करता है (bind("127.0.0.1:0"))। Token 64 hex characters का है, जो सिर्फ़ इसी run के लिए बना है, और कहीं इस्तेमाल नहीं होता। और इसमें कोई tool list है ही नहीं। साथ वाली command line में भी --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 

Allowlist मौजूद है। बस वह server पर रहती है। नीचे इसकी वजह है, साथ में एक working rebuild जिसे आप ख़ुद चला सकते हैं।

Allowlist server पर क्यों है?

Agent mode Claude Code को --permission-mode bypassPermissions के साथ चलाता है, क्योंकि हर step approve करने के लिए terminal पर कोई बैठा नहीं होता। build_claude_command का comment बताता है कि वह allowlist क्यों pass नहीं करता: bypassPermissions में Claude Code --allowedTools को ignore कर देता है। यानी flag सिर्फ़ दिखावे का रह जाता। mcp_bridge.rs का header यही बात कम शब्दों में कहता है: "Never trust the client; the server decides."

आम वजह किसी भी agent CLI पर लागू होती है। Flag client के लिए एक instruction है, और client ही वह हिस्सा है जो model-driven code चलाता है। जिस एक check का हर हाल में टिकना ज़रूरी है, उसके लिए यह ग़लत जगह है। Server हर tools/call देखता है और उसे model नहीं चलाता, इसलिए यह काम उसी को मिलता है।

osFoundry यह दो जगह करता है:

  1. tools/list सिर्फ़ वही tools लौटाता है जो run की list में हैं (agent_cli::tool_policy पहले उन्हें छाँट देता है)। Model को कभी बताया ही नहीं जाता कि बाकी tools भी हैं।
  2. tools/call कुछ भी चलने से पहले नाम को उसी list से match करता है। Model फिर भी ऐसा tool name निकाल सकता है जो उसे कभी दिखाया नहीं गया, training data से या किसी पढ़ी हुई file में छिपे prompt injection से। पहला filter इसके बारे में कुछ नहीं कर सकता। दूसरा कर सकता है।

पहली call पर CLI को क्या दिखता है?

तीन POST, हर एक नए connection पर, क्योंकि server 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 source में लिखा है कि Codex का MCP client (rmcp) अपना transport बंद कर देता है अगर किसी notification को 200 के साथ {} मिले, और इस वजह से पूरा run handshake पर ही मर जाता था। Claude Code इसे झेल लेता है। Codex नहीं। कोई भी JSON-RPC message जिसमें id न हो, या जिसका method notifications/ हो, उसे बिना body वाला 202 मिलता है।

इसके बाद Claude Code हर tool को mcp__<server>__<tool> के रूप में expose करता है, यानी memory_search बन जाता है mcp__osfoundry__memory_search। बीच में osfoundry config में दिए server name से आता है।

Rebuild

इस machine पर कोई claude binary install नहीं है। इसलिए client side एक script है, जो वही करती है जो एक headless agent किसी HTTP MCP server के साथ करता है: --mcp-config parse करना, handshake चलाना, tools call करना। Server, Rust bridge का logic है जिसे सिर्फ़ Python standard library से लिखा गया है। पूरी files: bridge.py, fake_cli.py और run_demo.py, कुल मिलाकर लगभग 230 lines।

काम का हिस्सा, 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 में यही वह एक function है जिससे हर caller की tool call गुज़रती है, approval, plan mode, channel overrides और sandbox के साथ। Bridge अपना कोई अलग execution path नहीं जोड़ता। वह सिर्फ़ यह तय करता है कि कोई call उस function तक पहुँचेगी या नहीं।

Registry में तीन tools हैं: memory_search, execute_command और delete_file। Run allowed=["memory_search"] के साथ शुरू होता है। फिर fake 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 वह case है जिसे कोई flag नहीं पकड़ सकता। Model ऐसे tool का नाम लेता है जो उसकी list में कभी था ही नहीं, और server उसे execute_tool से पहले ही मना कर देता है। Audit list इसका सबूत है: execute_command उसमें कहीं नहीं दिखता।

Call 3 arguments के ज़रिए किसी दूसरे workspace पर निशाना साधने की कोशिश करती है। Note "Client X contract renews 2027-01-31" मौजूद है, पर सिर्फ़ ws-client में। Bridge अपना bound workspace_id pass करता है, इसलिए search ws-personal में चलती है और कुछ नहीं मिलता। Rust code में workspace_id और channel_id start() से ही SessionCtx में रहते हैं, और कोई भी चीज़ उन्हें request से नहीं पढ़ती।

आख़िरी line check करती है कि endpoint run के साथ ही ख़त्म हो जाए। App में McpSession Drop में shutdown signal भेजता है, इसलिए कोई panic या early return ऐसा listener पीछे नहीं छोड़ सकता जो tools चलाता रहे।

इसकी cost कितनी है?

सारे numbers run_demo.py से हैं, Apple M1 (8 GB) पर Python 3.9.6 के साथ, जबकि दूसरे render jobs load average को लगभग 10 पर रखे हुए थे। हर request नया TCP connection खोलती है, जैसा असली bridge Connection: close से ज़बरदस्ती करवाता है। Times wall-clock हैं, हर call के आसपास time.perf_counter() से नापे गए।

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, allowlist ने मना किया 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

हर run के लिए एक bridge शुरू करने में लगभग 3 ms लगते हैं, start और handshake मिलाकर। एक Claude Code process को सिर्फ़ boot होने में इससे कहीं ज़्यादा समय लगता है, इसलिए per-run server practically मुफ़्त है। मना की गई call की cost भी उतनी ही है जितनी allowed call की। Check बस एक list lookup है, जो TCP round trip के noise में खो जाता है।

एकमात्र surprise shutdown था। Python का serve_forever shutdown को हर poll_interval में सिर्फ़ एक बार check करता है, default 0.5 s, इसलिए हर run के अंत में आधा second इंतज़ार होता था। Rust bridge में यह problem नहीं है: उसका accept loop accept() और एक watch channel पर tokio::select! है, इसलिए shutdown उसे तुरंत जगा देता है। Python में छोटा poll_interval pass करें, या run के exit path से socket बंद कर दें।

stdio के बजाय HTTP क्यों?

Claude Code से जोड़े जाने वाले ज़्यादातर MCP servers stdio processes होते हैं। osFoundry का नहीं है, और mcp_bridge.rs इसकी वजह बताता है। Tool executor को app का AppHandle चाहिए, जिसे न serialize किया जा सकता है न किसी दूसरे process में भेजा जा सकता है। stdio server एक दूसरा process होता, जिसे वैसे भी हर call वापस app में forward करनी पड़ती। Claude Code bearer headers वाले "type": "http" servers support करता है, इसलिए app loopback port पर अपने अंदर से ही MCP serve करता है। इसकी कीमत port और token है, और इसीलिए दोनों per-run होते हैं।

अगर आप इसे ख़ुद wire करें

  • Allowlist को server के tools/call handler में रखें और tools/list को भी filter करें। --allowedTools जैसे CLI flags को सिर्फ़ hint मानें, ख़ासकर किसी भी "bypass" permission mode में।
  • Session शुरू होते ही workspace, user और channel bind कर दें। इन्हें कभी tool arguments या client के control वाले headers से न लें।
  • हर run के लिए नया bearer बनाएँ, और उसे constant time में compare करें (hmac.compare_digest)। Tool-execution endpoint के लिए किसी long-lived agent token को दोबारा इस्तेमाल न करें।
  • 127.0.0.1:0 पर bind करें और port OS को चुनने दें। Listener की ज़िंदगी child process से बाँध दें, और check करें कि shutdown path सच में तुरंत होता है।
  • Notifications का जवाब बिना body वाले 202 से दें। कुछ clients किसी और जवाब पर connection गिरा देते हैं।
  • --strict-mcp-config pass करें, या आपके CLI में जो इसके बराबर हो, ताकि user के अपने MCP servers उस run में न जुड़ें जिसे आप scope करना चाहते थे।

CLI वाले case के बाहर osFoundry हर agent के लिए tools कैसे scope करता है, यह tool scope page पर है। External AI के लिए keys और subscription logins वह कैसे संभालता है, यह bring your own keys में है।