mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
fix(mcp): honor auth env and stdio fixture
This commit is contained in:
parent
947001d719
commit
feec197dec
11 changed files with 186 additions and 5 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue