From d426eebf1ace13b648297ff5330f7f6e40a1c3d4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 10 Mar 2026 02:09:04 -0400 Subject: [PATCH] Add sandbox MCP transport for running MCP servers inside Daytona sandboxes Adds McpTransport::Sandbox variant that starts an MCP server inside the sandbox, waits for it to listen, gets a Daytona preview URL, and rewrites to HTTP transport for the MCP client connection. Key changes: - Sandbox trait: add get_preview_url(port) for authenticated port access - DaytonaSandbox: implement get_preview_url via Daytona SDK preview links - McpTransport::Sandbox: new variant with command, port, env fields - Session: resolve_sandbox_mcp_servers() starts servers and rewrites to HTTP - Integration test: end-to-end Playwright MCP in Daytona - navigates to example.com and verifies "Example Domain" in accessibility snapshot Config example: [mcp_servers.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + lib/crates/arc-agent/src/sandbox.rs | 15 ++ lib/crates/arc-agent/src/session.rs | 111 +++++++- lib/crates/arc-mcp/src/client.rs | 6 + lib/crates/arc-mcp/src/config.rs | 9 + lib/crates/arc-workflows/Cargo.toml | 1 + .../arc-workflows/src/daytona_sandbox.rs | 20 ++ .../tests/daytona_integration.rs | 243 ++++++++++++++++++ 8 files changed, 404 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f1711a7e..4cf16f93c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,6 +432,7 @@ dependencies = [ "arc-git-storage", "arc-github", "arc-llm", + "arc-mcp", "arc-util", "assert_cmd", "async-trait", diff --git a/lib/crates/arc-agent/src/sandbox.rs b/lib/crates/arc-agent/src/sandbox.rs index ac04523c6..92ac6d50c 100644 --- a/lib/crates/arc-agent/src/sandbox.rs +++ b/lib/crates/arc-agent/src/sandbox.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt::Write; use std::path::Path; use std::sync::Arc; @@ -121,6 +122,10 @@ macro_rules! delegate_sandbox { self.$field.origin_url() } + async fn get_preview_url(&self, port: u16) -> Result)>, String> { + self.$field.get_preview_url(port).await + } + async fn read_file( &self, path: &str, @@ -410,6 +415,16 @@ pub trait Sandbox: Send + Sync { None } + /// Get an authenticated preview URL for a port exposed by this sandbox. + /// Returns `Ok(None)` when the sandbox does not support port previews. + /// Used to connect to services (e.g. MCP servers) running inside the sandbox. + async fn get_preview_url( + &self, + _port: u16, + ) -> Result)>, String> { + Ok(None) + } + /// Record that the agent has explicitly read (seen) the given file path. /// Called by tool executors after agent-visible reads (e.g. `read_file`, `grep`). /// Default is a no-op; `ReadBeforeWriteSandbox` overrides to populate its read set. diff --git a/lib/crates/arc-agent/src/session.rs b/lib/crates/arc-agent/src/session.rs index cd337a81b..87a96984c 100644 --- a/lib/crates/arc-agent/src/session.rs +++ b/lib/crates/arc-agent/src/session.rs @@ -16,12 +16,13 @@ use arc_llm::client::Client; use arc_llm::error::{ProviderErrorKind, SdkError}; use arc_llm::generate::StreamAccumulator; use arc_llm::types::{Message, Request, StreamEvent, ToolChoice}; +use arc_mcp::config::McpServerConfig; use futures::StreamExt; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use std::time::SystemTime; use tokio_util::sync::CancellationToken; -use tracing::debug; +use tracing::{debug, info, warn}; pub struct Session { id: String, @@ -118,8 +119,12 @@ impl Session { // Start MCP servers and register their tools if !self.config.mcp_servers.is_empty() { + // Resolve Sandbox transports: start the server inside the sandbox, + // then rewrite the config to Http using the sandbox's preview URL. + let mcp_servers = self.resolve_sandbox_mcp_servers().await; + let mut manager = arc_mcp::connection_manager::McpConnectionManager::new(); - let results = manager.start_servers(&self.config.mcp_servers).await; + let results = manager.start_servers(&mcp_servers).await; for (server_name, result) in &results { match result { @@ -171,6 +176,108 @@ impl Session { ); } + /// Resolve `McpTransport::Sandbox` configs by starting the MCP server inside the + /// sandbox and rewriting the transport to `Http` with the sandbox's preview URL. + async fn resolve_sandbox_mcp_servers(&self) -> Vec { + let mut resolved = Vec::with_capacity(self.config.mcp_servers.len()); + + for config in &self.config.mcp_servers { + match &config.transport { + arc_mcp::config::McpTransport::Sandbox { command, port, env } => { + let port = *port; + match self.start_sandbox_mcp_server(command, port, env).await { + Ok((url, headers)) => { + info!( + server = %config.name, + url = %url, + "Sandbox MCP server started, connecting via HTTP" + ); + resolved.push(McpServerConfig { + name: config.name.clone(), + transport: arc_mcp::config::McpTransport::Http { url, headers }, + startup_timeout_secs: config.startup_timeout_secs, + tool_timeout_secs: config.tool_timeout_secs, + }); + } + Err(e) => { + warn!( + server = %config.name, + error = %e, + "Failed to start sandbox MCP server" + ); + self.event_emitter.emit( + self.id.clone(), + AgentEvent::McpServerFailed { + server_name: config.name.clone(), + error: e, + }, + ); + } + } + } + _ => resolved.push(config.clone()), + } + } + + resolved + } + + /// Start an MCP server inside the sandbox and return (url, headers) for HTTP connection. + async fn start_sandbox_mcp_server( + &self, + command: &[String], + port: u16, + env: &std::collections::HashMap, + ) -> Result<(String, std::collections::HashMap), String> { + let sandbox = self.sandbox.as_ref(); + + let cmd_str = command.join(" "); + + // Launch the server detached with setsid so Daytona's exec doesn't block + let launch_script = format!( + "setsid sh -c '{cmd_str} > /tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log' \ + /dev/null 2>&1 &\necho $!" + ); + let env_ref = if env.is_empty() { None } else { Some(env) }; + let launch_result = sandbox + .exec_command(&launch_script, 30_000, None, env_ref, None) + .await + .map_err(|e| format!("Failed to launch MCP server: {e}"))?; + + let pid = launch_result.stdout.trim(); + info!(pid, port, "MCP server process launched in sandbox"); + + // Wait for the server to start listening on the port + let poll_cmd = format!("for i in $(seq 1 30); do ss -tln | grep -q ':{port} ' && echo ready && exit 0; sleep 1; done; echo timeout"); + let poll_result = sandbox + .exec_command(&poll_cmd, 60_000, None, None, None) + .await + .map_err(|e| format!("Failed to poll MCP server readiness: {e}"))?; + + if poll_result.stdout.trim() != "ready" { + // Grab stderr for debugging + let stderr = sandbox + .exec_command( + "cat /tmp/mcp_server_stderr.log 2>/dev/null | tail -20", + 10_000, + None, + None, + None, + ) + .await + .map(|r| r.stdout) + .unwrap_or_default(); + return Err(format!( + "MCP server did not start listening on port {port} within 30s. stderr:\n{stderr}" + )); + } + + // Get the preview URL for the port + sandbox.get_preview_url(port).await?.ok_or_else(|| { + "Sandbox does not support preview URLs (not a remote sandbox?)".to_string() + }) + } + async fn build_env_context(&self) -> EnvContext { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let model_name = self.provider_profile.model().to_string(); diff --git a/lib/crates/arc-mcp/src/client.rs b/lib/crates/arc-mcp/src/client.rs index e7d4336a9..33f3268fb 100644 --- a/lib/crates/arc-mcp/src/client.rs +++ b/lib/crates/arc-mcp/src/client.rs @@ -82,6 +82,12 @@ impl McpClient { PendingTransport::Http(transport) } + McpTransport::Sandbox { .. } => { + return Err(anyhow!( + "MCP server '{}': Sandbox transport must be resolved to Http before connecting", + config.name + )); + } }; let transport_type = match &transport { diff --git a/lib/crates/arc-mcp/src/config.rs b/lib/crates/arc-mcp/src/config.rs index f39f17d3b..5b640e99d 100644 --- a/lib/crates/arc-mcp/src/config.rs +++ b/lib/crates/arc-mcp/src/config.rs @@ -46,6 +46,15 @@ pub enum McpTransport { #[serde(default)] headers: HashMap, }, + /// MCP server that runs inside a sandbox and is accessed via HTTP preview URL. + /// During session init, the server is started inside the sandbox and this + /// variant is resolved into an `Http` transport using the sandbox's preview URL. + Sandbox { + command: Vec, + port: u16, + #[serde(default)] + env: HashMap, + }, } #[cfg(test)] diff --git a/lib/crates/arc-workflows/Cargo.toml b/lib/crates/arc-workflows/Cargo.toml index bd6fefbc1..d3daae560 100644 --- a/lib/crates/arc-workflows/Cargo.toml +++ b/lib/crates/arc-workflows/Cargo.toml @@ -55,6 +55,7 @@ tracing.workspace = true walkdir.workspace = true reqwest.workspace = true [dev-dependencies] +arc-mcp = { path = "../arc-mcp" } mockito = "1" tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3" diff --git a/lib/crates/arc-workflows/src/daytona_sandbox.rs b/lib/crates/arc-workflows/src/daytona_sandbox.rs index 3c8da0bfc..3600d11ce 100644 --- a/lib/crates/arc-workflows/src/daytona_sandbox.rs +++ b/lib/crates/arc-workflows/src/daytona_sandbox.rs @@ -785,6 +785,26 @@ impl Sandbox for DaytonaSandbox { self.origin_url.get().map(String::as_str) } + async fn get_preview_url( + &self, + port: u16, + ) -> Result)>, String> { + let sandbox = self.sandbox()?; + let preview = sandbox + .get_preview_link(port) + .await + .map_err(|e| format!("Failed to get preview link for port {port}: {e}"))?; + let mut headers = HashMap::new(); + if !preview.token.is_empty() { + headers.insert("x-daytona-preview-token".to_string(), preview.token); + } + headers.insert( + "X-Daytona-Skip-Preview-Warning".to_string(), + "true".to_string(), + ); + Ok(Some((preview.url, headers))) + } + async fn refresh_push_credentials(&self) -> Result<(), String> { let origin_url = match self.origin_url.get() { Some(url) => url, diff --git a/lib/crates/arc-workflows/tests/daytona_integration.rs b/lib/crates/arc-workflows/tests/daytona_integration.rs index 12dae6d27..f7c237f6c 100644 --- a/lib/crates/arc-workflows/tests/daytona_integration.rs +++ b/lib/crates/arc-workflows/tests/daytona_integration.rs @@ -2012,3 +2012,246 @@ async fn daytona_computer_use_browser_screenshot() { cu.stop().await.ok(); env.cleanup().await.unwrap(); } + +#[tokio::test] +#[ignore] +async fn daytona_playwright_mcp_sandbox_transport() { + use arc_agent::Sandbox; + + // Create sandbox from daytona-medium (has Node.js + Chromium) + let tmp = tempfile::tempdir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + dotenvy::dotenv().ok(); + if let Some(home) = dirs::home_dir() { + dotenvy::from_path(home.join(".arc/.env")).ok(); + } + let client = daytona_sdk::Client::new() + .await + .expect("DAYTONA_API_KEY must be set"); + let config = DaytonaConfig { + snapshot: Some(DaytonaSnapshotConfig { + name: "daytona-medium".into(), + cpu: None, + memory: None, + disk: None, + dockerfile: None, + }), + ..DaytonaConfig::default() + }; + let sandbox = DaytonaSandbox::new(client, config, None, None); + sandbox.initialize().await.unwrap(); + + // 1. Install Playwright MCP server and its browser + eprintln!("Installing @playwright/mcp and Chromium browser..."); + let install = sandbox + .exec_command( + "npm install -g @playwright/mcp@latest 2>&1 && npx playwright install --with-deps chromium 2>&1", + 300_000, + None, + None, + None, + ) + .await + .unwrap(); + eprintln!( + "Install exit_code={}, last_lines:\n{}", + install.exit_code, + install + .stdout + .lines() + .rev() + .take(5) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n") + ); + assert_eq!(install.exit_code, 0, "Playwright install failed"); + + // 2. Start the Playwright MCP server via the sandbox transport resolution path + let mcp_port = 3100u16; + let mcp_config = arc_mcp::config::McpServerConfig { + name: "playwright".into(), + transport: arc_mcp::config::McpTransport::Sandbox { + command: vec![ + "npx".into(), + "@playwright/mcp@latest".into(), + "--port".into(), + mcp_port.to_string(), + "--headless".into(), + "--browser".into(), + "chromium".into(), + ], + port: mcp_port, + env: std::collections::HashMap::new(), + }, + startup_timeout_secs: 30, + tool_timeout_secs: 120, + }; + + // Resolve the sandbox transport: start the server, get preview URL, rewrite to HTTP + let resolved = match &mcp_config.transport { + arc_mcp::config::McpTransport::Sandbox { command, port, .. } => { + let (url, headers) = { + let cmd_str = command.join(" "); + let launch_script = format!( + "setsid sh -c '{cmd_str} > /tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log' \ + /dev/null 2>&1 &\necho $!" + ); + let launch_result = sandbox + .exec_command(&launch_script, 30_000, None, None, None) + .await + .unwrap(); + eprintln!("MCP server PID: {}", launch_result.stdout.trim()); + + // Wait for server to listen + let poll_cmd = format!( + "for i in $(seq 1 30); do ss -tln | grep -q ':{port} ' && echo ready && exit 0; sleep 1; done; echo timeout" + ); + let poll_result = sandbox + .exec_command(&poll_cmd, 60_000, None, None, None) + .await + .unwrap(); + eprintln!("Server readiness: {}", poll_result.stdout.trim()); + + if poll_result.stdout.trim() != "ready" { + let stderr = sandbox + .exec_command( + "cat /tmp/mcp_server_stderr.log 2>/dev/null | tail -20", + 10_000, + None, + None, + None, + ) + .await + .map(|r| r.stdout) + .unwrap_or_default(); + panic!("MCP server did not start on port {port}. stderr:\n{stderr}"); + } + + sandbox + .get_preview_url(*port) + .await + .unwrap() + .expect("sandbox should support preview URLs") + }; + eprintln!("Preview URL: {url}"); + + arc_mcp::config::McpServerConfig { + name: mcp_config.name.clone(), + transport: arc_mcp::config::McpTransport::Http { url, headers }, + startup_timeout_secs: mcp_config.startup_timeout_secs, + tool_timeout_secs: mcp_config.tool_timeout_secs, + } + } + _ => unreachable!(), + }; + + // 3. Connect the MCP client to the resolved HTTP endpoint + let mut manager = arc_mcp::connection_manager::McpConnectionManager::new(); + let results = manager.start_servers(&[resolved]).await; + for (name, result) in &results { + match result { + Ok(count) => eprintln!("MCP server '{name}' ready with {count} tools"), + Err(e) => panic!("MCP server '{name}' failed: {e}"), + } + } + + // 4. List the tools to verify we got Playwright tools + let tools = manager.all_tools(); + eprintln!("Discovered {} MCP tools:", tools.len()); + for (name, info) in tools { + eprintln!( + " - {name}: {}", + info.description.chars().take(80).collect::() + ); + } + assert!(!tools.is_empty(), "Should have discovered Playwright tools"); + + // 5. Install the browser via MCP tool (ensures correct version is available) + let install_tool = tools + .keys() + .find(|k| k.ends_with("browser_install")) + .expect("no browser_install tool found"); + eprintln!("Calling tool: {install_tool}"); + let install_result = manager + .call_tool( + install_tool, + serde_json::json!({}), + std::time::Duration::from_secs(120), + ) + .await; + match &install_result { + Ok(result) => eprintln!( + "Install result: {}", + result + .content + .first() + .map(|c| format!("{c:?}")) + .unwrap_or_default() + ), + Err(e) => eprintln!("Install error (non-fatal): {e}"), + } + + // 6. Call the browser_navigate tool to load a page + let nav_tool = tools + .keys() + .find(|k| k.ends_with("browser_navigate")) + .expect("no browser_navigate tool found"); + eprintln!("Calling tool: {nav_tool}"); + let nav_result = manager + .call_tool( + nav_tool, + serde_json::json!({"url": "https://example.com"}), + std::time::Duration::from_secs(30), + ) + .await; + match &nav_result { + Ok(result) => eprintln!( + "Navigate result: {}", + &result + .content + .first() + .map(|c| format!("{c:?}")) + .unwrap_or_default() + ), + Err(e) => eprintln!("Navigate error: {e}"), + } + assert!(nav_result.is_ok(), "Navigate should succeed"); + + // 7. Take a snapshot to verify the page loaded + let snap_tool = tools + .keys() + .find(|k| k.contains("snapshot")) + .expect("no snapshot tool found"); + eprintln!("Calling tool: {snap_tool}"); + let snap_result = manager + .call_tool( + snap_tool, + serde_json::json!({}), + std::time::Duration::from_secs(30), + ) + .await; + match &snap_result { + Ok(result) => { + let text = result + .content + .first() + .map(|c| format!("{c:?}")) + .unwrap_or_default(); + eprintln!( + "Snapshot result (first 500 chars): {}", + &text[..text.len().min(500)] + ); + assert!( + text.contains("Example Domain"), + "Snapshot should contain 'Example Domain'" + ); + } + Err(e) => panic!("Snapshot failed: {e}"), + } + + // 8. Cleanup + sandbox.cleanup().await.unwrap(); +}