44to the PolicyEngine API via MCP. Outputs are streamed back in real-time.
55"""
66
7+ import json
8+
79import modal
810
911# Sandbox image with Bun and Claude Code CLI (v2 - with ToS pre-accept)
@@ -57,14 +59,10 @@ def run_claude_code_in_sandbox(
5759 )
5860
5961 # MCP config for Claude Code (type: sse for HTTP SSE transport)
60- mcp_config = f"""{{
61- "mcpServers": {{
62- "policyengine": {{
63- "type": "sse",
64- "url": "{ api_base_url } /mcp"
65- }}
66- }}
67- }}"""
62+ mcp_config = {
63+ "mcpServers" : {"policyengine" : {"type" : "sse" , "url" : f"{ api_base_url } /mcp" }}
64+ }
65+ mcp_config_json = json .dumps (mcp_config )
6866
6967 # Get reference to deployed app (required when calling from outside Modal)
7068 print ("[SANDBOX] looking up Modal app" , flush = True )
@@ -85,29 +83,23 @@ def run_claude_code_in_sandbox(
8583 print ("[SANDBOX] sandbox created" , flush = True )
8684 logfire .info ("run_claude_code_in_sandbox: sandbox created" )
8785
88- # Write MCP config
89- logfire .info ("run_claude_code_in_sandbox: writing MCP config" )
90- sb .exec ("mkdir" , "-p" , "/root/.claude" )
91- config_process = sb .exec (
92- "sh" , "-c" , f"cat > /root/.claude/mcp_servers.json << 'EOF'\n { mcp_config } \n EOF"
93- )
94- config_process .wait ()
95- logfire .info (
96- "run_claude_code_in_sandbox: MCP config written" ,
97- returncode = config_process .returncode ,
98- )
99-
10086 # Run Claude Code with the question
10187 # Note: Can't use --dangerously-skip-permissions as root (Modal runs as root)
10288 # Use shell wrapper with </dev/null to properly close stdin (prevents hanging)
10389 # --max-turns: limit execution to prevent runaway
90+ # Use --mcp-config to pass MCP config directly (more reliable than config file)
10491 print ("[SANDBOX] Starting claude CLI with question" , flush = True )
105- logfire .info ("run_claude_code_in_sandbox: starting claude CLI" )
92+ logfire .info (
93+ "run_claude_code_in_sandbox: starting claude CLI" ,
94+ mcp_url = f"{ api_base_url } /mcp" ,
95+ )
10696
107- # Escape the question for shell
97+ # Escape the question and config for shell
10898 escaped_question = question .replace ("'" , "'\" '\" '" )
99+ escaped_mcp_config = mcp_config_json .replace ("'" , "'\" '\" '" )
109100 cmd = (
110101 f"claude -p '{ escaped_question } ' "
102+ f"--mcp-config '{ escaped_mcp_config } ' "
111103 "--output-format stream-json --verbose --max-turns 10 "
112104 "--allowedTools 'mcp__policyengine__*,Bash,Read,Grep,Glob,Write,Edit' "
113105 "< /dev/null 2>&1"
@@ -129,8 +121,6 @@ def run_policy_analysis(
129121
130122 This is the non-streaming version that returns the full result.
131123 """
132- import json
133- import os
134124 import subprocess
135125
136126 import logfire
@@ -140,24 +130,28 @@ def run_policy_analysis(
140130 with logfire .span (
141131 "run_policy_analysis" , question = question [:100 ], api_base_url = api_base_url
142132 ):
143- # Write MCP config
144- os .makedirs ("/root/.claude" , exist_ok = True )
133+ # MCP config for Claude Code (type: sse for HTTP SSE transport)
145134 mcp_config = {
146135 "mcpServers" : {
147136 "policyengine" : {"type" : "sse" , "url" : f"{ api_base_url } /mcp" }
148137 }
149138 }
150- with open ("/root/.claude/mcp_servers.json" , "w" ) as f :
151- json .dump (mcp_config , f )
139+ mcp_config_json = json .dumps (mcp_config )
152140
153- logfire .info ("Starting Claude Code" , question = question [:100 ])
141+ logfire .info (
142+ "Starting Claude Code" ,
143+ question = question [:100 ],
144+ mcp_url = f"{ api_base_url } /mcp" ,
145+ )
154146
155- # Run Claude Code (no --dangerously-skip-permissions since we run as root)
147+ # Run Claude Code with --mcp-config (no --dangerously-skip-permissions as root)
156148 result = subprocess .run (
157149 [
158150 "claude" ,
159151 "-p" ,
160152 question ,
153+ "--mcp-config" ,
154+ mcp_config_json ,
161155 "--max-turns" ,
162156 "10" ,
163157 "--allowedTools" ,
0 commit comments