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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 02:09:04 -04:00
parent 2e1015241e
commit d426eebf1a
8 changed files with 404 additions and 2 deletions

1
Cargo.lock generated
View file

@ -432,6 +432,7 @@ dependencies = [
"arc-git-storage",
"arc-github",
"arc-llm",
"arc-mcp",
"arc-util",
"assert_cmd",
"async-trait",

View file

@ -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<Option<(String, std::collections::HashMap<String, String>)>, 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<Option<(String, HashMap<String, String>)>, 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.

View file

@ -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<McpServerConfig> {
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<String, String>,
) -> Result<(String, std::collections::HashMap<String, String>), 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 >/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();

View file

@ -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 {

View file

@ -46,6 +46,15 @@ pub enum McpTransport {
#[serde(default)]
headers: HashMap<String, String>,
},
/// 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<String>,
port: u16,
#[serde(default)]
env: HashMap<String, String>,
},
}
#[cfg(test)]

View file

@ -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"

View file

@ -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<Option<(String, HashMap<String, String>)>, 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,

View file

@ -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::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.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 >/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::<String>()
);
}
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();
}