mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
parent
2e1015241e
commit
d426eebf1a
8 changed files with 404 additions and 2 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -432,6 +432,7 @@ dependencies = [
|
|||
"arc-git-storage",
|
||||
"arc-github",
|
||||
"arc-llm",
|
||||
"arc-mcp",
|
||||
"arc-util",
|
||||
"assert_cmd",
|
||||
"async-trait",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue