use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use everruns_core::llmsim_driver::{LlmSimConfig, SimToolCall, SimTurn};
use serde_json::json;
use crate::runtime::{BuildOptions, BuiltRuntime, ProviderChoice, build_with_options};
use crate::settings::SettingsStore;
const TURN_TIMEOUT: Duration = Duration::from_secs(20);
fn python3() -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.map(|dir| dir.join("python3"))
.find(|candidate| candidate.is_file())
}
fn require_python3(test: &str) -> Option<PathBuf> {
if let Some(python) = python3() {
return Some(python);
}
assert!(
std::env::var_os("CI").is_none(),
"{test}: python3 is required to run the MCP e2e tests in CI but was not found on PATH",
);
eprintln!("skipping {test}: python3 not found (set CI=1 to make this a hard failure)");
None
}
fn fixture_server() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mcp_echo_server.py")
}
fn mcp_tool(server: &str, tool: &str) -> String {
format!("mcp_{server}__{tool}")
}
fn script(tool: &str, message: &str) -> LlmSimConfig {
LlmSimConfig::scripted(vec![
SimTurn::ToolCalls(vec![SimToolCall {
name: tool.to_string(),
arguments: json!({ "message": message }),
id: None,
}]),
SimTurn::Assistant("done".to_string()),
])
}
fn echo_mcp_json(python: &Path, marker_dir: &Path) -> serde_json::Value {
json!({
"mcpServers": {
"echo": {
"type": "stdio",
"command": python.to_str().unwrap(),
"args": [
fixture_server().to_str().unwrap(),
marker_dir.to_str().unwrap(),
]
}
}
})
}
fn empty_mcp_json() -> serde_json::Value {
json!({ "mcpServers": {} })
}
fn write_mcp_json(workspace_root: &Path, value: &serde_json::Value) {
std::fs::write(
workspace_root.join(".mcp.json"),
serde_json::to_vec_pretty(value).unwrap(),
)
.expect("write .mcp.json");
}
async fn live_mcp_server_names(runtime: &BuiltRuntime) -> Vec<String> {
use everruns_core::SessionStore;
let session = runtime
.handles
.session_store
.get_session(runtime.handles.session_id)
.await
.expect("get session")
.expect("session exists");
let mut names: Vec<String> = session.mcp_servers.keys().cloned().collect();
names.sort();
names
}
async fn build_runtime_with(config: LlmSimConfig, mcp_json: &serde_json::Value) -> BuiltRuntime {
let workspace_root = tempfile::tempdir().expect("workspace").keep();
let sessions_root = tempfile::tempdir().expect("sessions").keep();
write_mcp_json(&workspace_root, mcp_json);
let settings = Arc::new(SettingsStore::open(sessions_root.join("settings.toml")));
build_with_options(
workspace_root,
ProviderChoice::Sim,
None,
sessions_root,
settings,
BuildOptions {
llmsim_override: Some(config.with_model("llmsim-yolop")),
..BuildOptions::default()
},
)
.await
.expect("build runtime")
}
async fn build_runtime(config: LlmSimConfig, marker_dir: &Path, python: &Path) -> BuiltRuntime {
build_runtime_with(config, &echo_mcp_json(python, marker_dir)).await
}
async fn run_turn(runtime: &BuiltRuntime, text: &str) -> everruns_runtime::TurnResult {
let session_id = runtime.handles.session_id;
let input = runtime.model.input_message(text.to_string());
tokio::time::timeout(
TURN_TIMEOUT,
runtime.handles.runtime.run_turn(session_id, input),
)
.await
.expect("run_turn timed out")
.expect("run_turn errored")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_tool_executes_over_real_stdio_server() {
let Some(python) = require_python3("mcp_tool_executes_over_real_stdio_server") else {
return;
};
let marker = tempfile::tempdir().expect("marker").keep();
let tool = mcp_tool("echo", "echo");
let runtime = build_runtime(script(&tool, "hello-mcp"), &marker, &python).await;
let result = run_turn(&runtime, "use the echo tool").await;
assert!(result.success, "turn must succeed: {result:?}");
assert_eq!(result.tool_calls_count, 1, "exactly one MCP call expected");
let called = marker.join("echo.called");
assert!(
called.exists(),
"the real MCP server must have executed tools/call (marker missing)"
);
let body = std::fs::read_to_string(&called).unwrap();
assert!(
body.contains("hello-mcp"),
"server should receive the call arguments: {body}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reload_swaps_live_session_mcp_servers() {
let has = |names: &[String], name: &str| names.iter().any(|n| n == name);
let runtime = build_runtime_with(LlmSimConfig::fixed("hi"), &empty_mcp_json()).await;
assert!(
!has(&live_mcp_server_names(&runtime).await, "docs"),
"docs is absent before it is configured"
);
write_mcp_json(
&runtime.startup.workspace_root,
&json!({ "mcpServers": { "docs": { "type": "http", "url": "https://example.com/mcp" } } }),
);
let names = runtime
.handles
.reload_mcp_servers()
.await
.expect("reload after add");
assert!(has(&names, "docs"), "reload reports the added server");
assert!(
has(&live_mcp_server_names(&runtime).await, "docs"),
"the live session now carries the added server"
);
write_mcp_json(&runtime.startup.workspace_root, &empty_mcp_json());
let names = runtime
.handles
.reload_mcp_servers()
.await
.expect("reload after remove");
assert!(!has(&names, "docs"), "reload drops the removed server");
assert!(
!has(&live_mcp_server_names(&runtime).await, "docs"),
"the live session drops the removed server"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reload_adds_mcp_server_and_executes_live() {
let Some(python) = require_python3("reload_adds_mcp_server_and_executes_live") else {
return;
};
let marker = tempfile::tempdir().expect("marker").keep();
let tool = mcp_tool("echo", "echo");
let runtime = build_runtime_with(script(&tool, "added-live"), &empty_mcp_json()).await;
assert!(
!live_mcp_server_names(&runtime)
.await
.iter()
.any(|n| n == "echo"),
"echo server is absent before reload"
);
write_mcp_json(
&runtime.startup.workspace_root,
&echo_mcp_json(&python, &marker),
);
let names = runtime
.handles
.reload_mcp_servers()
.await
.expect("reload after add");
assert!(names.iter().any(|n| n == "echo"), "reload attaches echo");
let result = run_turn(&runtime, "use the echo tool").await;
assert!(result.success, "turn must succeed: {result:?}");
assert_eq!(result.tool_calls_count, 1, "exactly one MCP call expected");
let called = marker.join("echo.called");
assert!(
called.exists(),
"the reloaded MCP server must have executed tools/call (marker missing)"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reload_removes_mcp_server_live() {
let Some(python) = require_python3("reload_removes_mcp_server_live") else {
return;
};
let marker = tempfile::tempdir().expect("marker").keep();
let tool = mcp_tool("echo", "echo");
let runtime = build_runtime_with(
script(&tool, "should-not-run"),
&echo_mcp_json(&python, &marker),
)
.await;
assert!(
live_mcp_server_names(&runtime)
.await
.iter()
.any(|n| n == "echo"),
"echo server is present at startup"
);
write_mcp_json(&runtime.startup.workspace_root, &empty_mcp_json());
let names = runtime
.handles
.reload_mcp_servers()
.await
.expect("reload after remove");
assert!(
!names.iter().any(|n| n == "echo"),
"echo dropped after remove"
);
let _ = run_turn(&runtime, "use the echo tool").await;
assert!(
!marker.join("echo.called").exists(),
"the removed MCP server must not execute after reload"
);
}