fix(mcp): honor auth env and stdio fixture

This commit is contained in:
Bryan Helmkamp 2026-05-11 10:41:19 -04:00
parent 947001d719
commit feec197dec
No known key found for this signature in database
11 changed files with 186 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -194,7 +194,7 @@ async fn client_from_settings(settings: &McpServerSettings) -> Result<Client> {
async fn connect_target(server: &str, settings: &McpServerSettings) -> Result<Client> {
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<Client> {
}
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()

View file

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

View file

@ -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();

View file

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

View file

@ -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<PathBuf>,
#[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 {

View file

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

View file

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