diff --git a/arc/workflows/playwright-demo/browse-prompt.md b/arc/workflows/playwright-demo/browse-prompt.md new file mode 100644 index 000000000..4f63f1311 --- /dev/null +++ b/arc/workflows/playwright-demo/browse-prompt.md @@ -0,0 +1,14 @@ +You have Playwright MCP tools available. Do the following: + +1. First, call the `browser_install` tool to ensure the browser is installed. +2. Create the screenshots directory: `mkdir -p /home/daytona/workspace/screenshots` +3. Use `browser_navigate` to go to https://news.ycombinator.com +4. Use `browser_snapshot` to capture the page content +5. Use `browser_take_screenshot` to save a screenshot to `/home/daytona/workspace/screenshots/01-hn-front-page.png` +6. Click on the first story link +7. Use `browser_take_screenshot` to save a screenshot to `/home/daytona/workspace/screenshots/02-first-story.png` +8. Use `browser_navigate_back` to go back to the front page +9. Click on the "new" link in the nav bar +10. Use `browser_take_screenshot` to save a screenshot to `/home/daytona/workspace/screenshots/03-newest.png` + +After capturing screenshots, write a brief summary of what you found on Hacker News today. diff --git a/arc/workflows/playwright-demo/workflow.dot b/arc/workflows/playwright-demo/workflow.dot new file mode 100644 index 000000000..0908162fa --- /dev/null +++ b/arc/workflows/playwright-demo/workflow.dot @@ -0,0 +1,13 @@ +digraph PlaywrightDemo { + graph [goal="Use Playwright MCP to browse Hacker News and take screenshots", model_stylesheet="* { llm_model: claude-sonnet-4-6; llm_provider: anthropic }"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + install_browser [label="Install Browser", shape=parallelogram, script="npx playwright install --with-deps chromium 2>&1 || true"] + + browse [label="Browse Hacker News", prompt="@browse-prompt.md"] + + start -> install_browser -> browse -> exit +} diff --git a/arc/workflows/playwright-demo/workflow.toml b/arc/workflows/playwright-demo/workflow.toml new file mode 100644 index 000000000..e78ff8309 --- /dev/null +++ b/arc/workflows/playwright-demo/workflow.toml @@ -0,0 +1,28 @@ +version = 1 +goal = "Browse Hacker News with Playwright MCP and capture screenshots" +graph = "workflow.dot" + +[llm] +model = "claude-sonnet-4-6" + +[sandbox] +provider = "daytona" + +[sandbox.daytona] +auto_stop_interval = 30 + +[sandbox.daytona.labels] +project = "playwright-demo" + +[sandbox.daytona.snapshot] +name = "daytona-medium" + +[assets] +include = ["screenshots/**"] + +[mcp_servers.playwright] +type = "sandbox" +command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] +port = 3100 +startup_timeout_secs = 60 +tool_timeout_secs = 120 diff --git a/lib/crates/arc-api/src/demo/mod.rs b/lib/crates/arc-api/src/demo/mod.rs index a682e1c7e..b96aebc92 100644 --- a/lib/crates/arc-api/src/demo/mod.rs +++ b/lib/crates/arc-api/src/demo/mod.rs @@ -1295,6 +1295,7 @@ mod runs { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }) .unwrap() } @@ -1447,6 +1448,7 @@ mod workflows { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }), graph: r#"digraph fix_build { graph [ @@ -1516,6 +1518,7 @@ mod workflows { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }), graph: r#"digraph implement { graph [ @@ -1597,6 +1600,7 @@ mod workflows { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }), graph: r#"digraph sync { graph [ @@ -1667,6 +1671,7 @@ mod workflows { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }), graph: r#"digraph expand { graph [ diff --git a/lib/crates/arc-workflows/Cargo.toml b/lib/crates/arc-workflows/Cargo.toml index d3daae560..614f6a571 100644 --- a/lib/crates/arc-workflows/Cargo.toml +++ b/lib/crates/arc-workflows/Cargo.toml @@ -22,6 +22,7 @@ anyhow.workspace = true dotenvy.workspace = true arc-agent = { path = "../arc-agent" } arc-exe = { path = "../arc-exe", optional = true } +arc-mcp = { path = "../arc-mcp" } arc-github = { path = "../arc-github" } arc-util = { path = "../arc-util" } arc-git-storage = { path = "../arc-git-storage" } diff --git a/lib/crates/arc-workflows/src/cli/backend.rs b/lib/crates/arc-workflows/src/cli/backend.rs index 8ae83871f..e6bf8b6ff 100644 --- a/lib/crates/arc-workflows/src/cli/backend.rs +++ b/lib/crates/arc-workflows/src/cli/backend.rs @@ -109,6 +109,7 @@ pub struct AgentApiBackend { fallback_chain: Vec, sessions: Mutex>, env: HashMap, + mcp_servers: Vec, } impl AgentApiBackend { @@ -120,6 +121,7 @@ impl AgentApiBackend { fallback_chain, sessions: Mutex::new(HashMap::new()), env: HashMap::new(), + mcp_servers: Vec::new(), } } @@ -129,6 +131,12 @@ impl AgentApiBackend { self } + #[must_use] + pub fn with_mcp_servers(mut self, servers: Vec) -> Self { + self.mcp_servers = servers; + self + } + async fn create_session( &self, node: &Node, @@ -142,6 +150,7 @@ impl AgentApiBackend { sandbox, &self.env, tool_hooks, + self.mcp_servers.clone(), ) .await } @@ -153,6 +162,7 @@ impl AgentApiBackend { sandbox: &Arc, env: &HashMap, tool_hooks: Option>, + mcp_servers: Vec, ) -> Result { let client = Client::from_env() .await @@ -164,6 +174,7 @@ impl AgentApiBackend { max_tokens: node.max_tokens(), reasoning_effort: Some(node.reasoning_effort().to_string()), tool_hooks, + mcp_servers, ..SessionConfig::default() }; @@ -481,6 +492,7 @@ impl CodergenBackend for AgentApiBackend { sandbox, &self.env, tool_hooks.clone(), + self.mcp_servers.clone(), ) .await { diff --git a/lib/crates/arc-workflows/src/cli/run.rs b/lib/crates/arc-workflows/src/cli/run.rs index fc4d4c25a..6b731ff92 100644 --- a/lib/crates/arc-workflows/src/cli/run.rs +++ b/lib/crates/arc-workflows/src/cli/run.rs @@ -893,16 +893,28 @@ pub async fn run_command( .and_then(|c| c.sandbox.as_ref()) .and_then(|s| s.env.clone()) .unwrap_or_default(); + let mcp_servers: Vec = run_cfg + .as_ref() + .map(|c| { + c.mcp_servers + .clone() + .into_iter() + .map(|(name, entry)| entry.into_config(name)) + .collect() + }) + .unwrap_or_default(); let registry = default_registry(interviewer.clone(), { let sandbox_env = sandbox_env.clone(); let model = model.clone(); + let mcp_servers = mcp_servers.clone(); move || { if dry_run_mode { None } else { let api = AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone()) - .with_env(sandbox_env.clone()); + .with_env(sandbox_env.clone()) + .with_mcp_servers(mcp_servers.clone()); let cli = AgentCliBackend::new(model.clone(), provider_enum) .with_env(sandbox_env.clone()); Some(Box::new(BackendRouter::new(Box::new(api), cli))) @@ -2119,6 +2131,7 @@ mod tests { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }; let (model, provider) = resolve_model_provider( Some("gpt-5.2"), @@ -2162,6 +2175,7 @@ mod tests { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -2240,6 +2254,7 @@ mod tests { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -2268,6 +2283,7 @@ mod tests { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }; let defaults = RunDefaults::default(); assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults)); @@ -2295,6 +2311,7 @@ mod tests { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }; let defaults = RunDefaults { sandbox: Some(run_config::SandboxConfig { @@ -2365,6 +2382,7 @@ mod tests { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }; let defaults = RunDefaults::default(); assert_eq!( @@ -2418,6 +2436,7 @@ mod tests { checkpoint: Default::default(), pull_request: None, assets: None, + mcp_servers: Default::default(), }; let defaults = RunDefaults { sandbox: Some(run_config::SandboxConfig { diff --git a/lib/crates/arc-workflows/src/cli/run_config.rs b/lib/crates/arc-workflows/src/cli/run_config.rs index 756b9e5a7..808762e7e 100644 --- a/lib/crates/arc-workflows/src/cli/run_config.rs +++ b/lib/crates/arc-workflows/src/cli/run_config.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use tracing::debug; +use arc_mcp::config::{McpServerConfig, McpTransport}; + use crate::daytona_sandbox::{DaytonaConfig, DockerfileSource}; const SUPPORTED_VERSION: u32 = 1; @@ -51,6 +53,29 @@ pub struct WorkflowRunConfig { pub checkpoint: CheckpointConfig, pub pull_request: Option, pub assets: Option, + #[serde(default)] + pub mcp_servers: HashMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct McpServerEntry { + #[serde(flatten)] + pub transport: McpTransport, + #[serde(default = "arc_mcp::config::default_startup_timeout_secs")] + pub startup_timeout_secs: u64, + #[serde(default = "arc_mcp::config::default_tool_timeout_secs")] + pub tool_timeout_secs: u64, +} + +impl McpServerEntry { + pub fn into_config(self, name: String) -> McpServerConfig { + McpServerConfig { + name, + transport: self.transport, + startup_timeout_secs: self.startup_timeout_secs, + tool_timeout_secs: self.tool_timeout_secs, + } + } } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]