Add MCP server support to workflow engine and Playwright demo workflow

Wire MCP server configs from workflow TOML into agent sessions so
MCP tools are available to agent stages during workflow runs.

Changes:
- run_config.rs: add mcp_servers HashMap to WorkflowRunConfig
- backend.rs: AgentApiBackend.with_mcp_servers() passes configs to SessionConfig
- run.rs: extract mcp_servers from RunConfig and pass to backend
- demo/mod.rs: add default mcp_servers field to struct literals

New workflow: arc/workflows/playwright-demo/
- Runs on Daytona with daytona-medium snapshot
- Configures Playwright MCP as a sandbox transport (port 3100)
- Agent browses Hacker News, takes screenshots at each step
- asset_globs captures screenshots/** into logs directory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 08:05:12 -04:00
parent d426eebf1a
commit 08461fe603
8 changed files with 118 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -109,6 +109,7 @@ pub struct AgentApiBackend {
fallback_chain: Vec<FallbackTarget>,
sessions: Mutex<HashMap<String, Session>>,
env: HashMap<String, String>,
mcp_servers: Vec<arc_mcp::config::McpServerConfig>,
}
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<arc_mcp::config::McpServerConfig>) -> 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<dyn Sandbox>,
env: &HashMap<String, String>,
tool_hooks: Option<Arc<dyn arc_agent::ToolHookCallback>>,
mcp_servers: Vec<arc_mcp::config::McpServerConfig>,
) -> Result<Session, ArcError> {
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
{

View file

@ -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<arc_mcp::config::McpServerConfig> = 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 {

View file

@ -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<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
}
#[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)]