diff --git a/lib/crates/fabro-agent/src/mcp_integration.rs b/lib/crates/fabro-agent/src/mcp_integration.rs index ed4ef4574..4c1514d5c 100644 --- a/lib/crates/fabro-agent/src/mcp_integration.rs +++ b/lib/crates/fabro-agent/src/mcp_integration.rs @@ -62,6 +62,8 @@ mod tests { command: vec!["python3".into(), test_server], env: HashMap::new(), }, + current_dir: None, + clear_env: false, startup_timeout_secs: 10, tool_timeout_secs: 30, } diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 045038a63..143898983 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -493,6 +493,8 @@ impl Session { resolved.push(McpServerSettings { name: config.name.clone(), transport: McpTransport::Http { url, headers }, + current_dir: config.current_dir.clone(), + clear_env: config.clear_env, startup_timeout_secs: config.startup_timeout_secs, tool_timeout_secs: config.tool_timeout_secs, }); @@ -3367,6 +3369,8 @@ mod tests { command: vec!["python3".into(), test_server], env: HashMap::new(), }, + current_dir: None, + clear_env: false, startup_timeout_secs: 10, tool_timeout_secs: 30, }], diff --git a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs index 1408d40fc..b2f162703 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use chrono::{Duration as ChronoDuration, Utc}; -use fabro_client::{AuthEntry, AuthStore, OAuthEntry, StoredSubject}; +use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject}; use fabro_mcp::client::McpClient; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; @@ -488,7 +488,7 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() { "source": "mcp-test" }, "source_directory": "[SOURCE_DIRECTORY]", - "repo_origin_url": "[REPO_ORIGIN_URL]", + "repo_origin_url": null, "goal": "Run tests and report results" } ], @@ -755,6 +755,66 @@ async fn mcp_search_refreshes_expired_oauth_token() { .expect("MCP client should shut down"); } +#[tokio::test(flavor = "multi_thread")] +async fn mcp_search_uses_fabro_auth_file_override() { + let context = test_context!(); + let server = MockServer::start(); + let target_url = format!("{}/api/v1", server.base_url()); + let target: fabro_client::ServerTarget = target_url.parse().unwrap(); + let auth_file = context.temp_dir.join("custom-auth.json"); + AuthStore::new(auth_file.clone()) + .put( + &target, + AuthEntry::DevToken(DevTokenEntry { + token: TEST_DEV_TOKEN.to_string(), + logged_in_at: Utc::now(), + }), + ) + .expect("custom auth store should be seeded"); + let run_id = unique_run_id(); + let list_runs = server.mock(|when, then| { + when.method(GET) + .path("/api/v1/runs") + .header("authorization", format!("Bearer {TEST_DEV_TOKEN}")) + .query_param("include_archived", "true") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "data": [remote_run_summary_json( + &run_id, + "Simple", + "simple", + "Custom auth file", + &serde_json::json!({ "kind": "submitted" }), + "2026-04-05T12:00:00Z", + )], + "meta": { "has_more": false } + })); + }); + let mut fixture = mcp_stdio_fixture(&context, &["--server", &target_url]); + fixture.env.insert( + "FABRO_AUTH_FILE".to_string(), + auth_file.display().to_string(), + ); + let client = spawn_mcp_client_from_fixture(fixture).await; + + let result = call_tool_json( + &client, + "fabro_run_search", + serde_json::json!({ "run_ids": [run_id], "first": 1 }), + ) + .await; + + assert_eq!(result["runs"][0]["run_id"], run_id); + list_runs.assert(); + client + .shutdown() + .await + .expect("MCP client should shut down"); +} + #[tokio::test(flavor = "multi_thread")] async fn mcp_search_orders_by_started_timestamp_before_created_timestamp() { let context = test_context!(); @@ -908,7 +968,7 @@ async fn mcp_lifecycle_tools_manage_real_run() { "source": "mcp-test" }, "source_directory": "[SOURCE_DIRECTORY]", - "repo_origin_url": "[REPO_ORIGIN_URL]", + "repo_origin_url": null, "goal": "Run tests and report results" } ], @@ -1834,12 +1894,18 @@ fn mcp_stdio_fixture(context: &fabro_test::TestContext, extra_args: &[&str]) -> async fn spawn_mcp_client(context: &fabro_test::TestContext, extra_args: &[&str]) -> McpClient { let fixture = mcp_stdio_fixture(context, extra_args); + spawn_mcp_client_from_fixture(fixture).await +} + +async fn spawn_mcp_client_from_fixture(fixture: McpStdioFixture) -> McpClient { let config = McpServerSettings { name: "fabro-under-test".to_string(), transport: McpTransport::Stdio { command: fixture.command, env: fixture.env, }, + current_dir: Some(fixture.current_dir), + clear_env: true, startup_timeout_secs: 10, tool_timeout_secs: 30, }; diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index 1a85289f2..fe4c94240 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -346,6 +346,8 @@ pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerS McpServerSettings { name: name.to_string(), transport, + current_dir: None, + clear_env: false, startup_timeout_secs, tool_timeout_secs, } diff --git a/lib/crates/fabro-mcp-server/src/server.rs b/lib/crates/fabro-mcp-server/src/server.rs index 5d34b0917..aa968274a 100644 --- a/lib/crates/fabro-mcp-server/src/server.rs +++ b/lib/crates/fabro-mcp-server/src/server.rs @@ -194,7 +194,7 @@ async fn client_from_settings(settings: &McpServerSettings) -> Result { async fn connect_target(server: &str, settings: &McpServerSettings) -> Result { let target: ServerTarget = server.parse()?; - let auth_store = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json")); + let auth_store = AuthStore::default(); let mut credential = resolve_target_credential_with_store(&target, &auth_store)?; if credential.is_none() && target.is_unix_socket() { let runtime_token_path = Storage::new(&settings.storage_dir) @@ -238,7 +238,7 @@ async fn connect_local_server(settings: &McpServerSettings) -> Result { } Bind::Tcp(addr) => { let target = ServerTarget::http_url(format!("http://{addr}"))?; - let auth_store = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json")); + let auth_store = AuthStore::default(); let credential = resolve_target_credential_with_store(&target, &auth_store)?; let oauth_session = refreshable_oauth(&target, &auth_store, credential.as_ref()); let mut builder = Client::builder() diff --git a/lib/crates/fabro-mcp/src/client.rs b/lib/crates/fabro-mcp/src/client.rs index 7a3843a9c..1775affff 100644 --- a/lib/crates/fabro-mcp/src/client.rs +++ b/lib/crates/fabro-mcp/src/client.rs @@ -53,9 +53,15 @@ impl McpClient { .stderr(Stdio::piped()) .kill_on_drop(true); + if config.clear_env { + cmd.env_clear(); + } if !env.is_empty() { cmd.envs(env); } + if let Some(current_dir) = config.current_dir.as_ref() { + cmd.current_dir(current_dir); + } #[cfg(unix)] cmd.process_group(0); diff --git a/lib/crates/fabro-mcp/tests/stdio_integration.rs b/lib/crates/fabro-mcp/tests/stdio_integration.rs index 8da5c24d6..bd0b2ed73 100644 --- a/lib/crates/fabro-mcp/tests/stdio_integration.rs +++ b/lib/crates/fabro-mcp/tests/stdio_integration.rs @@ -13,6 +13,8 @@ fn test_server_config() -> McpServerSettings { command: vec!["python3".into(), test_server], env: HashMap::new(), }, + current_dir: None, + clear_env: false, startup_timeout_secs: 10, tool_timeout_secs: 30, } @@ -30,6 +32,78 @@ async fn stdio_client_initialize_and_list_tools() { assert_eq!(tools[0].1, "Echo back the message"); } +#[tokio::test] +#[expect( + clippy::disallowed_methods, + reason = "stdio integration test stages a local process cwd and inherits PATH for python3 lookup" +)] +async fn stdio_client_uses_configured_cwd_and_exact_env() { + let test_server = format!("{}/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR")); + let temp_dir = std::env::temp_dir().join(format!( + "fabro-mcp-stdio-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&temp_dir).unwrap(); + let canonical_temp_dir = std::fs::canonicalize(&temp_dir).unwrap(); + let mut env = HashMap::new(); + env.insert( + "PATH".to_string(), + std::env::var("PATH").expect("PATH should be set for python3 lookup"), + ); + env.insert("FABRO_MCP_TEST_SENTINEL".to_string(), "fixture".to_string()); + let config = McpServerSettings { + name: "test-echo".into(), + transport: McpTransport::Stdio { + command: vec!["python3".into(), test_server], + env, + }, + current_dir: Some(canonical_temp_dir.clone()), + clear_env: true, + startup_timeout_secs: 10, + tool_timeout_secs: 30, + }; + let client = McpClient::new(&config).unwrap(); + client.initialize(config.startup_timeout()).await.unwrap(); + + let cwd = client + .call_tool( + "echo", + serde_json::json!({"message": "__cwd__"}), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!( + call_result_to_string(&cwd).unwrap(), + canonical_temp_dir.display().to_string() + ); + let sentinel = client + .call_tool( + "echo", + serde_json::json!({"message": "__env:FABRO_MCP_TEST_SENTINEL__"}), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!(call_result_to_string(&sentinel).unwrap(), "fixture"); + let home = client + .call_tool( + "echo", + serde_json::json!({"message": "__env:HOME__"}), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!(call_result_to_string(&home).unwrap(), ""); + + client.shutdown().await.unwrap(); + std::fs::remove_dir(&temp_dir).unwrap(); +} + #[tokio::test] async fn stdio_client_call_tool_echo() { let config = test_server_config(); diff --git a/lib/crates/fabro-mcp/tests/test_mcp_server.py b/lib/crates/fabro-mcp/tests/test_mcp_server.py index 619d0f365..63f95b689 100644 --- a/lib/crates/fabro-mcp/tests/test_mcp_server.py +++ b/lib/crates/fabro-mcp/tests/test_mcp_server.py @@ -5,6 +5,7 @@ Speaks JSON-RPC 2.0 over stdin/stdout per the MCP specification. Exposes a single tool: echo(message) -> message. """ import json +import os import sys SERVER_INFO = { @@ -53,6 +54,11 @@ def handle_request(req): arguments = params.get("arguments", {}) if tool_name == "echo": msg = arguments.get("message", "") + if msg == "__cwd__": + msg = os.getcwd() + elif msg.startswith("__env:") and msg.endswith("__"): + key = msg[len("__env:") : -len("__")] + msg = os.environ.get(key, "") return { "jsonrpc": "2.0", "id": req_id, diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 9148d986c..18ae07b4c 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -7,6 +7,7 @@ //! behavior, and artifact collection. use std::collections::HashMap; +use std::path::PathBuf; use std::time::Duration as StdDuration; use serde::ser::SerializeStruct; @@ -330,6 +331,10 @@ pub struct RunAgentSettings { pub struct McpServerSettings { pub name: String, pub transport: McpTransport, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_dir: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub clear_env: bool, pub startup_timeout_secs: u64, pub tool_timeout_secs: u64, } @@ -342,6 +347,8 @@ impl Default for McpServerSettings { command: Vec::new(), env: HashMap::new(), }, + current_dir: None, + clear_env: false, startup_timeout_secs: 10, tool_timeout_secs: 60, } @@ -378,6 +385,14 @@ pub enum McpTransport { }, } +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "serde skip_serializing_if helpers receive borrowed field values" +)] +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)] #[serde(rename_all = "snake_case")] pub enum TlsMode { diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index a0d65ecf3..0cee89dbc 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -547,6 +547,8 @@ fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings env: env.clone(), }, }, + current_dir: None, + clear_env: false, startup_timeout_secs: settings.startup_timeout_secs, tool_timeout_secs: settings.tool_timeout_secs, } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 50e5bd39c..a2f01ccd2 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -2149,6 +2149,8 @@ async fn daytona_playwright_mcp_sandbox_transport() { port: mcp_port, env: std::collections::HashMap::new(), }, + current_dir: None, + clear_env: false, startup_timeout_secs: 30, tool_timeout_secs: 120, }; @@ -2205,6 +2207,8 @@ async fn daytona_playwright_mcp_sandbox_transport() { fabro_mcp::config::McpServerSettings { name: mcp_config.name.clone(), transport: fabro_mcp::config::McpTransport::Http { url, headers }, + current_dir: mcp_config.current_dir.clone(), + clear_env: mcp_config.clear_env, startup_timeout_secs: mcp_config.startup_timeout_secs, tool_timeout_secs: mcp_config.tool_timeout_secs, }