fix(cli): align MCP run tools with CLI behavior

This commit is contained in:
Bryan Helmkamp 2026-05-11 09:17:12 -04:00
parent 40a057f10c
commit 9fc0ec3061
No known key found for this signature in database
12 changed files with 1864 additions and 1262 deletions

22
Cargo.lock generated
View file

@ -1678,6 +1678,7 @@ dependencies = [
"fabro-interview",
"fabro-llm",
"fabro-macros",
"fabro-manifest",
"fabro-mcp",
"fabro-mcp-server",
"fabro-model",
@ -2004,6 +2005,24 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "fabro-manifest"
version = "0.230.0-nightly.0"
dependencies = [
"anyhow",
"fabro-api",
"fabro-config",
"fabro-github",
"fabro-graphviz",
"fabro-template",
"fabro-types",
"fabro-workflow",
"git2",
"temp-env",
"tempfile",
"toml 0.8.23",
]
[[package]]
name = "fabro-mcp"
version = "0.230.0-nightly.0"
@ -2029,7 +2048,10 @@ dependencies = [
"dirs",
"fabro-api",
"fabro-client",
"fabro-config",
"fabro-http",
"fabro-manifest",
"fabro-server",
"fabro-types",
"fabro-util",
"rmcp",

View file

@ -32,6 +32,7 @@ fabro-install = { path = "../fabro-install" }
fabro-interview = { path = "../fabro-interview" }
fabro-mcp = { path = "../fabro-mcp" }
fabro-mcp-server = { path = "../fabro-mcp-server" }
fabro-manifest = { path = "../fabro-manifest" }
fabro-proc = { path = "../fabro-proc" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] }
fabro-checkpoint = { path = "../fabro-checkpoint" }

View file

@ -102,6 +102,10 @@ impl CommandContext {
&self.cwd
}
pub(crate) fn storage_dir(&self) -> &Path {
&self.storage_dir
}
pub(crate) fn run_settings(&self) -> Result<&RunNamespace> {
self.run_settings
.as_ref()

View file

@ -4,6 +4,7 @@ use anyhow::{Context as _, Result};
use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs};
use crate::command_context::CommandContext;
use crate::user_config;
pub(crate) async fn dispatch(ns: McpNamespace, base_ctx: &CommandContext) -> Result<()> {
match ns.command {
@ -26,10 +27,23 @@ fn server_settings(
base_ctx: &CommandContext,
connection: &ServerConnectionArgs,
) -> Result<fabro_mcp_server::McpServerSettings> {
let connection_ctx = base_ctx.with_connection(connection)?;
let server_target = user_config::resolve_nondefault_server_target(
&connection.target,
connection_ctx.user_settings(),
)?
.map(|target| {
target
.as_unix_socket_path()
.map_or_else(|| target.to_string(), |path| path.display().to_string())
});
Ok(fabro_mcp_server::McpServerSettings {
config: config_settings(connection),
config: config_settings(connection),
server_target,
storage_dir: connection_ctx.storage_dir().to_path_buf(),
config_path: connection_ctx.base_config_path().to_path_buf(),
home_dir: home_dir()?,
cwd: base_ctx.cwd().to_path_buf(),
cwd: base_ctx.cwd().to_path_buf(),
})
}

File diff suppressed because it is too large Load diff

View file

@ -15,8 +15,11 @@ use std::process::Stdio;
use fabro_mcp::client::McpClient;
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use crate::support::{RealAuthHarness, TEST_DEV_TOKEN, seed_dev_token_auth};
use super::support::mock_resolved_run;
use crate::support::{RealAuthHarness, TEST_DEV_TOKEN, seed_dev_token_auth, unique_run_id};
#[test]
fn help() {
@ -481,8 +484,8 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
"source": "mcp-test"
},
"source_directory": "[SOURCE_DIRECTORY]",
"repo_origin_url": null,
"goal": "Run the Fabro workflow."
"repo_origin_url": "[REPO_ORIGIN_URL]",
"goal": "Run tests and report results"
}
],
"next_cursor": null
@ -496,6 +499,100 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
harness.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_run_tools_use_default_local_server_without_server_flag() {
let context = test_context!();
let workflow = context.install_fixture("simple.fabro");
let client = spawn_mcp_client(&context, &[]).await;
let create = call_tool_json(
&client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-default-server-test" },
"start": false
}]
}),
)
.await;
let run_id = create["runs"][0]["run_id"].as_str().unwrap();
let search = call_tool_json(
&client,
"fabro_run_search",
serde_json::json!({ "run_ids": [run_id], "first": 1 }),
)
.await;
assert_eq!(search["runs"][0]["run_id"], run_id);
assert_eq!(
search["runs"][0]["labels"]["source"],
"mcp-default-server-test"
);
client
.shutdown()
.await
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_search_filters_status_dates_and_paginates() {
let context = test_context!();
let harness =
RealAuthHarness::start_with_dev_token(fabro_test::GitHubAppState::default()).await;
let target_url = harness.api_target();
let target: fabro_client::ServerTarget = target_url.parse().unwrap();
seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN);
let workflow = context.install_fixture("simple.fabro");
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let first = create_mcp_run(&client, workflow.clone(), false).await;
let second = create_mcp_run(&client, workflow, false).await;
let page_one = call_tool_json(
&client,
"fabro_run_search",
serde_json::json!({
"labels": { "source": "mcp-test" },
"status": ["submitted"],
"archived": false,
"created_after": "2000-01-01",
"created_before": "2100-01-01T00:00:00Z",
"first": 1
}),
)
.await;
let cursor = page_one["next_cursor"]
.as_str()
.expect("first page should have cursor");
let page_two = call_tool_json(
&client,
"fabro_run_search",
serde_json::json!({
"labels": { "source": "mcp-test" },
"status": ["submitted"],
"archived": false,
"after": cursor,
"first": 1
}),
)
.await;
let page_one_id = page_one["runs"][0]["run_id"].as_str().unwrap();
let page_two_id = page_two["runs"][0]["run_id"].as_str().unwrap();
assert_ne!(page_one_id, page_two_id);
assert!([first.as_str(), second.as_str()].contains(&page_one_id));
assert!([first.as_str(), second.as_str()].contains(&page_two_id));
client
.shutdown()
.await
.expect("MCP client should shut down");
harness.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_lifecycle_tools_manage_real_run() {
let context = test_context!();
@ -584,8 +681,8 @@ async fn mcp_lifecycle_tools_manage_real_run() {
"source": "mcp-test"
},
"source_directory": "[SOURCE_DIRECTORY]",
"repo_origin_url": null,
"goal": "Run the Fabro workflow."
"repo_origin_url": "[REPO_ORIGIN_URL]",
"goal": "Run tests and report results"
}
],
"timed_out": false,
@ -686,6 +783,253 @@ async fn mcp_interact_error_does_not_stop_server() {
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_create_validation_errors_happen_before_auth_or_network() {
let context = test_context!();
let client = spawn_mcp_client(&context, &["--server", "http://127.0.0.1:9"]).await;
let too_many = (0..51)
.map(|index| serde_json::json!({ "workflow": format!("wf-{index}.fabro") }))
.collect::<Vec<_>>();
let empty = call_tool_error_text(
&client,
"fabro_run_create",
serde_json::json!({ "runs": [] }),
)
.await;
let many = call_tool_error_text(
&client,
"fabro_run_create",
serde_json::json!({ "runs": too_many }),
)
.await;
let null = call_tool_error_text(
&client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": "simple.fabro",
"inputs": { "decision": null }
}]
}),
)
.await;
assert!(empty.contains("runs"), "{empty}");
assert!(many.contains("runs"), "{many}");
assert!(null.contains("decision"), "{null}");
assert_eq!(client.list_tools().await.unwrap().len(), 5);
client
.shutdown()
.await
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_interact_questions_and_answers_use_api_wire_contract() {
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();
seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN);
let run_id = unique_run_id();
let selector = "nightly";
let resolve = mock_resolved_run(&server, selector, &run_id);
let questions = server.mock(|when, then| {
when.method(GET)
.path(format!("/api/v1/runs/{run_id}/questions"))
.query_param("page[limit]", "100")
.query_param("page[offset]", "0");
then.status(200)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"data": [{
"id": "q-1",
"text": "Proceed?",
"stage": "gate",
"question_type": "yes_no",
"options": [],
"allow_freeform": false,
"timeout_seconds": null,
"context_display": null
}],
"meta": { "has_more": false }
}));
});
let expected_answers = [
(
serde_json::json!(true),
serde_json::json!({ "kind": "yes" }),
),
(
serde_json::json!(false),
serde_json::json!({ "kind": "no" }),
),
(
serde_json::json!("Looks good"),
serde_json::json!({ "kind": "text", "text": "Looks good" }),
),
(
serde_json::json!({ "option": "approve" }),
serde_json::json!({ "kind": "selected", "option_key": "approve" }),
),
(
serde_json::json!({ "options": ["approve", "notify"] }),
serde_json::json!({ "kind": "multi_selected", "option_keys": ["approve", "notify"] }),
),
(
serde_json::json!({ "text": "Freeform" }),
serde_json::json!({ "kind": "text", "text": "Freeform" }),
),
];
let answer_mocks = expected_answers
.iter()
.map(|(_, expected_body)| {
server.mock(|when, then| {
when.method(POST)
.path(format!("/api/v1/runs/{run_id}/questions/q-1/answer"))
.json_body(expected_body.clone());
then.status(204);
})
})
.collect::<Vec<_>>();
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let question_result = call_tool_json(
&client,
"fabro_run_interact",
serde_json::json!({ "run_id": selector, "action": "get_questions" }),
)
.await;
assert_eq!(question_result["result"]["questions"][0]["id"], "q-1");
for (answer, _) in expected_answers {
let result = call_tool_json(
&client,
"fabro_run_interact",
serde_json::json!({
"run_id": selector,
"action": "answer",
"question_id": "q-1",
"answer": answer
}),
)
.await;
assert_eq!(result["result"]["submitted"], true);
}
resolve.assert_calls(7);
questions.assert();
for answer in answer_mocks {
answer.assert();
}
client
.shutdown()
.await
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_events_filters_find_matches_beyond_first_page() {
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();
seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN);
let run_id = unique_run_id();
let resolve = mock_resolved_run(&server, "nightly", &run_id);
let events = (1..=60)
.map(|sequence| {
let event_name = if sequence == 60 {
"stage.started"
} else {
"run.started"
};
let properties = if sequence == 60 {
serde_json::json!({
"index": 1,
"handler_type": "prompt",
"attempt": 1,
"max_attempts": 1
})
} else {
serde_json::json!({
"name": "Simple",
"goal": format!("ordinary event {sequence}")
})
};
serde_json::json!({
"seq": sequence,
"id": format!("evt-{sequence}"),
"ts": "2026-04-05T12:00:00Z",
"run_id": run_id,
"event": event_name,
"properties": properties,
"actor": null
})
})
.collect::<Vec<_>>();
let first_event = events[0].clone();
let _limited_events = server.mock(|when, then| {
when.method(GET)
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param("limit", "1");
then.status(200)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"data": [first_event],
"meta": { "has_more": true }
}));
});
let list_events = server.mock(|when, then| {
when.method(GET)
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param_missing("limit");
then.status(200)
.header("Content-Type", "application/json")
.json_body(serde_json::json!({
"data": events,
"meta": { "has_more": false }
}));
});
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let details = call_tool_json(
&client,
"fabro_run_events",
serde_json::json!({
"run_id": "nightly",
"action": "details",
"event_ids": ["evt-60"],
"first": 1
}),
)
.await;
let filtered = call_tool_json(
&client,
"fabro_run_events",
serde_json::json!({
"run_id": "nightly",
"action": "search",
"categories": ["stage"],
"query": "prompt",
"first": 1,
"max_content_length": 32
}),
)
.await;
assert_eq!(details["events"][0]["event_id"], "evt-60");
assert_eq!(filtered["events"][0]["event_id"], "evt-60");
assert_eq!(filtered["events"][0]["truncated"], true);
resolve.assert_calls(2);
list_events.assert_calls(2);
client
.shutdown()
.await
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_tool_auth_error_mentions_login() {
let context = test_context!();
@ -865,6 +1209,9 @@ fn normalize_run_search(mut value: serde_json::Value) -> serde_json::Value {
if run["source_directory"].is_string() {
run["source_directory"] = serde_json::json!("[SOURCE_DIRECTORY]");
}
if run["repo_origin_url"].is_string() {
run["repo_origin_url"] = serde_json::json!("[REPO_ORIGIN_URL]");
}
}
}
value
@ -885,6 +1232,9 @@ fn normalize_gather(mut value: serde_json::Value) -> serde_json::Value {
if run["source_directory"].is_string() {
run["source_directory"] = serde_json::json!("[SOURCE_DIRECTORY]");
}
if run["repo_origin_url"].is_string() {
run["repo_origin_url"] = serde_json::json!("[REPO_ORIGIN_URL]");
}
}
}
value

View file

@ -0,0 +1,29 @@
[package]
name = "fabro-manifest"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Fabro run manifest construction"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
fabro-api = { path = "../fabro-api" }
fabro-config = { path = "../fabro-config" }
fabro-github = { path = "../fabro-github" }
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-template = { path = "../fabro-template" }
fabro-types = { path = "../fabro-types" }
fabro-workflow = { path = "../fabro-workflow" }
git2.workspace = true
toml.workspace = true
[dev-dependencies]
tempfile = "3"
temp-env = "0.3"

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,9 @@ dirs.workspace = true
fabro-api = { path = "../fabro-api" }
fabro-client = { path = "../fabro-client" }
fabro-http.workspace = true
fabro-manifest = { path = "../fabro-manifest" }
fabro-config = { path = "../fabro-config" }
fabro-server = { path = "../fabro-server" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
rmcp = { workspace = true, features = ["server", "macros", "schemars", "transport-io"] }

View file

@ -9,9 +9,12 @@ pub use server::start;
#[derive(Debug, Clone)]
pub struct McpServerSettings {
pub config: McpConfigSettings,
pub home_dir: PathBuf,
pub cwd: PathBuf,
pub config: McpConfigSettings,
pub server_target: Option<String>,
pub storage_dir: PathBuf,
pub config_path: PathBuf,
pub home_dir: PathBuf,
pub cwd: PathBuf,
}
#[derive(Debug, Clone, Default)]

View file

@ -11,13 +11,20 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, NaiveDate, Utc};
use fabro_api::types;
use fabro_client::Client;
use fabro_config::{
CliLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, RunSandboxLayer,
};
use fabro_manifest::{ManifestBuildInput, build_run_manifest as build_canonical_run_manifest};
use fabro_server::manifest_validation;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{ApprovalMode, RunMode};
use fabro_types::{EventEnvelope, Run, RunId, RunStatus};
use fabro_util::exit::{self, ExitClass};
use rmcp::model::{CallToolResult, Content};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::{fs, time};
use tokio::time;
#[derive(Debug)]
pub(crate) struct ToolError {
@ -329,12 +336,13 @@ pub(crate) struct RunEventResult {
pub(crate) async fn create_runs(
client: Arc<Client>,
base_cwd: &Path,
user_settings_path: &Path,
params: ValidatedCreateRuns,
) -> ToolResult<CreateRunsResult> {
let mut created = Vec::with_capacity(params.runs.len());
for spec in params.runs {
let cwd = spec.cwd.clone().unwrap_or_else(|| base_cwd.to_path_buf());
let manifest = build_run_manifest(&spec, &cwd).await?;
let manifest = build_mcp_run_manifest(&spec, &cwd, user_settings_path)?;
let run_id = client
.create_run_from_manifest(manifest)
.await
@ -565,7 +573,7 @@ pub(crate) async fn run_events(
.map_err(|err| ToolError::from_anyhow(&err))?
.id;
let mut events = client
.list_run_events(&run_id, raw.after, Some(event_fetch_limit(&raw)))
.list_run_events(&run_id, raw.after, event_fetch_limit(&raw))
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
filter_events(&mut events, &raw)?;
@ -715,13 +723,24 @@ fn answer_to_submit_request(answer: Value) -> ToolResult<types::SubmitAnswerRequ
.map_err(|err| ToolError::message(format!("failed to build submit-answer request: {err}")))
}
fn event_fetch_limit(params: &FabroRunEventsParams) -> usize {
params
.first
.or(params.limit)
.unwrap_or(50)
.saturating_add(params.offset.unwrap_or(0))
.clamp(1, 200)
fn event_fetch_limit(params: &FabroRunEventsParams) -> Option<usize> {
let needs_full_scan = params.event_ids.is_some()
|| params.event_types.is_some()
|| params.categories.is_some()
|| params.created_after.is_some()
|| params.created_before.is_some()
|| matches!(
params.action,
RunEventsAction::Details | RunEventsAction::Search
);
(!needs_full_scan).then(|| {
params
.first
.or(params.limit)
.unwrap_or(50)
.saturating_add(params.offset.unwrap_or(0))
.clamp(1, 200)
})
}
fn filter_events(events: &mut Vec<EventEnvelope>, params: &FabroRunEventsParams) -> ToolResult<()> {
@ -786,71 +805,45 @@ fn run_event_result(
})
}
async fn build_run_manifest(spec: &CreateRunSpec, cwd: &Path) -> ToolResult<types::RunManifest> {
fn build_mcp_run_manifest(
spec: &CreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> ToolResult<types::RunManifest> {
if let Some(run_id) = spec.run_id.as_deref() {
run_id.parse::<RunId>().map_err(|err| {
ToolError::message(format!("run_id must be a valid Fabro run id: {err}"))
})?;
}
let workflow_path = resolve_workflow_path(&spec.workflow, cwd);
let manifest_cwd = manifest_cwd_for_workflow(cwd, &workflow_path);
let workflow_key = workflow_path
.strip_prefix(&manifest_cwd)
.unwrap_or(&workflow_path)
.display()
.to_string();
let source = fs::read_to_string(&workflow_path).await.map_err(|err| {
ToolError::message(format!(
"failed to read workflow {}: {err}",
workflow_path.display()
))
})?;
let workflows = HashMap::from([(workflow_key.clone(), types::ManifestWorkflow {
config: None,
files: HashMap::new(),
source,
})]);
Ok(types::RunManifest {
args: mcp_manifest_args(spec),
configs: Vec::new(),
cwd: manifest_cwd.display().to_string(),
git: None,
goal: Some(types::ManifestGoal {
path: None,
text: spec
.goal
.clone()
.unwrap_or_else(|| "Run the Fabro workflow.".to_string()),
type_: types::ManifestGoalType::Value,
}),
run_id: spec.run_id.clone(),
target: types::ManifestTarget {
identifier: spec.workflow.clone(),
path: workflow_key,
},
title: None,
version: 1,
workflows,
let built = build_canonical_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(&spec.workflow),
cwd: cwd.to_path_buf(),
run_overrides: mcp_run_overrides(spec),
cli_overrides: Some(CliLayer::default()),
input_overrides: spec
.inputs
.iter()
.map(|(key, value)| json_to_toml_value(key, value).map(|value| (key.clone(), value)))
.collect::<ToolResult<HashMap<_, _>>>()?,
args: mcp_manifest_args(spec),
run_id: spec
.run_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.map_err(|err| {
ToolError::message(format!("run_id must be a valid Fabro run id: {err}"))
})?,
user_settings_path: Some(user_settings_path.to_path_buf()),
})
}
fn resolve_workflow_path(workflow: &str, cwd: &Path) -> PathBuf {
let path = PathBuf::from(workflow);
if path.is_absolute() {
path
} else {
cwd.join(path)
}
}
fn manifest_cwd_for_workflow(cwd: &Path, workflow_path: &Path) -> PathBuf {
if workflow_path.strip_prefix(cwd).is_ok() {
cwd.to_path_buf()
} else {
workflow_path
.parent()
.map_or_else(|| cwd.to_path_buf(), Path::to_path_buf)
.map_err(|err| ToolError::from_anyhow(&err))?;
let validation = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)
.map_err(|err| ToolError::from_anyhow(&err))?;
if !validation.ok {
return Err(ToolError::message("workflow manifest validation failed"));
}
Ok(built.manifest)
}
fn mcp_manifest_args(spec: &CreateRunSpec) -> Option<types::ManifestArgs> {
@ -859,16 +852,11 @@ fn mcp_manifest_args(spec: &CreateRunSpec) -> Option<types::ManifestArgs> {
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
let input = spec
.inputs
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
let payload = types::ManifestArgs {
auto_approve: spec.auto_approve.filter(|value| *value),
docker_image: None,
dry_run: spec.dry_run.filter(|value| *value),
input,
input: Vec::new(),
label,
model: spec.model.clone(),
preserve_sandbox: spec.preserve_sandbox.filter(|value| *value),
@ -879,6 +867,55 @@ fn mcp_manifest_args(spec: &CreateRunSpec) -> Option<types::ManifestArgs> {
(!mcp_manifest_args_is_empty(&payload)).then_some(payload)
}
fn mcp_run_overrides(spec: &CreateRunSpec) -> Option<RunLayer> {
let goal = spec
.goal
.as_ref()
.map(|goal| RunGoalLayer::Inline(InterpString::parse(goal)));
let model = (spec.model.is_some() || spec.provider.is_some()).then(|| RunModelLayer {
provider: spec.provider.as_deref().map(InterpString::parse),
name: spec.model.as_deref().map(InterpString::parse),
fallbacks: Vec::new(),
});
let sandbox =
(spec.sandbox.is_some() || spec.preserve_sandbox.is_some()).then(|| RunSandboxLayer {
provider: spec.sandbox.clone(),
preserve: spec.preserve_sandbox,
..RunSandboxLayer::default()
});
let execution =
(spec.dry_run.is_some() || spec.auto_approve.is_some()).then(|| RunExecutionLayer {
mode: spec.dry_run.map(|dry_run| {
if dry_run {
RunMode::DryRun
} else {
RunMode::Normal
}
}),
approval: spec.auto_approve.map(|auto_approve| {
if auto_approve {
ApprovalMode::Auto
} else {
ApprovalMode::Prompt
}
}),
});
let run = RunLayer {
goal,
metadata: ReplaceMap::from(spec.labels.clone()),
model,
sandbox,
execution,
..RunLayer::default()
};
(run.goal.is_some()
|| !run.metadata.is_empty()
|| run.model.is_some()
|| run.sandbox.is_some()
|| run.execution.is_some())
.then_some(run)
}
fn mcp_manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
args.auto_approve.is_none()
&& args.docker_image.is_none()

View file

@ -1,21 +1,31 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
use fabro_client::{
AuthEntry, AuthStore, Client, Credential, ServerTarget, TransportConnector,
apply_bearer_token_auth,
};
use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
use fabro_config::{RuntimeDirectory, Storage};
use fabro_util::dev_token;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ServerCapabilities, ServerInfo};
use rmcp::transport::stdio;
use rmcp::{ErrorData, ServerHandler, serve_server, tool, tool_handler, tool_router};
use tokio::process::Command as TokioCommand;
use tokio::sync::OnceCell;
use tokio::task::yield_now;
use tokio::time::sleep;
use crate::{McpServerSettings, run_tools};
const CLIENT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const SERVER_START_TIMEOUT: Duration = Duration::from_secs(8);
#[derive(Clone)]
pub(crate) struct FabroMcpServer {
settings: Arc<McpServerSettings>,
@ -67,7 +77,7 @@ impl FabroMcpServer {
Ok(client) => client,
Err(err) => return Ok(run_tools::error_result(err)),
};
match run_tools::create_runs(client, &self.cwd, params).await {
match run_tools::create_runs(client, &self.cwd, &self.settings.config_path, params).await {
Ok(result) => run_tools::success_result(&result, run_tools::create_runs_text(&result)),
Err(err) => Ok(run_tools::error_result(err)),
}
@ -176,19 +186,27 @@ impl FabroMcpServer {
async fn client_from_settings(settings: &McpServerSettings) -> Result<Client> {
yield_now().await;
let Some(server) = settings.config.server.as_ref() else {
return Err(anyhow!(
"fabro mcp start requires --server for run tools in this release"
));
};
if let Some(server) = settings.server_target.as_ref() {
return connect_target(server, settings).await;
}
connect_local_server(settings).await
}
async fn connect_target(server: &str, settings: &McpServerSettings) -> Result<Client> {
let target: ServerTarget = server.parse()?;
let credential = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json"))
let mut credential = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json"))
.get(&target)?
.map(credential_from_auth_entry);
if credential.is_none() && target.is_unix_socket() {
let runtime_token_path = Storage::new(&settings.storage_dir)
.runtime_directory()
.dev_token_path();
credential = dev_token::read_dev_token_file(&runtime_token_path).map(Credential::DevToken);
}
let mut builder = Client::builder()
.target(target.clone())
.transport_connector(target_transport_connector(target))
.request_timeout(std::time::Duration::from_secs(30));
.request_timeout(CLIENT_REQUEST_TIMEOUT);
if let Some(credential) = credential {
builder = builder.credential(credential);
}
@ -198,6 +216,89 @@ async fn client_from_settings(settings: &McpServerSettings) -> Result<Client> {
.context("failed to connect Fabro API")
}
async fn connect_local_server(settings: &McpServerSettings) -> Result<Client> {
let bind = ensure_local_server_running(&settings.storage_dir, &settings.config_path).await?;
match bind {
Bind::Unix(path) => {
let token = wait_for_runtime_dev_token(
&Storage::new(&settings.storage_dir)
.runtime_directory()
.dev_token_path(),
)
.await?;
let http_client = connect_bind_http_client(&Bind::Unix(path), Some(&token)).await?;
Client::builder()
.transport("http://fabro", http_client)
.request_timeout(CLIENT_REQUEST_TIMEOUT)
.connect()
.await
}
Bind::Tcp(addr) => {
let target = ServerTarget::http_url(format!("http://{addr}"))?;
let credential = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json"))
.get(&target)?
.map(credential_from_auth_entry);
let mut builder = Client::builder()
.target(target.clone())
.transport_connector(target_transport_connector(target))
.request_timeout(CLIENT_REQUEST_TIMEOUT);
if let Some(credential) = credential {
builder = builder.credential(credential);
}
builder.connect().await
}
}
}
async fn ensure_local_server_running(storage_dir: &Path, config_path: &Path) -> Result<Bind> {
let runtime_directory = RuntimeDirectory::new(storage_dir);
if let Some(existing) = ServerDaemon::load_running(&runtime_directory)? {
return Ok(existing.bind);
}
let exe = std::env::current_exe().context("resolving current fabro executable path")?;
let status = TokioCommand::new(exe)
.args(["server", "start", "--no-web", "--storage-dir"])
.arg(storage_dir)
.arg("--config")
.arg(config_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.status()
.await
.context("starting local Fabro server")?;
if !status.success() {
return Err(anyhow!("fabro server start exited with status {status}"));
}
let deadline = std::time::Instant::now() + SERVER_START_TIMEOUT;
while std::time::Instant::now() < deadline {
if let Some(running) = ServerDaemon::load_running(&runtime_directory)? {
return Ok(running.bind);
}
sleep(Duration::from_millis(50)).await;
}
Err(anyhow!(
"Fabro server started but no active record was found for {}",
storage_dir.display()
))
}
async fn wait_for_runtime_dev_token(path: &Path) -> Result<String> {
let deadline = std::time::Instant::now() + SERVER_START_TIMEOUT;
while std::time::Instant::now() < deadline {
if let Some(token) = dev_token::read_dev_token_file(path) {
return Ok(token);
}
sleep(Duration::from_millis(50)).await;
}
Err(anyhow!(
"runtime dev token did not become available at {}",
path.display()
))
}
fn credential_from_auth_entry(entry: AuthEntry) -> Credential {
match entry {
AuthEntry::OAuth(entry) => Credential::OAuth(entry),
@ -237,3 +338,38 @@ fn connect_target_transport(
}
Ok((builder.build()?, "http://fabro".to_string()))
}
async fn connect_bind_http_client(
bind: &Bind,
bearer_token: Option<&str>,
) -> Result<fabro_http::HttpClient> {
let (client, health_url) = match bind {
Bind::Unix(path) => {
let mut builder = fabro_http::HttpClientBuilder::new()
.unix_socket(path)
.no_proxy();
if let Some(token) = bearer_token {
builder = apply_bearer_token_auth(builder, token)?;
}
(builder.build()?, "http://fabro/health".to_string())
}
Bind::Tcp(addr) => {
let mut builder = fabro_http::HttpClientBuilder::new().no_proxy();
if let Some(token) = bearer_token {
builder = apply_bearer_token_auth(builder, token)?;
}
(builder.build()?, format!("http://{addr}/health"))
}
};
let deadline = std::time::Instant::now() + SERVER_START_TIMEOUT;
let mut last_error = None;
while std::time::Instant::now() < deadline {
match client.get(&health_url).send().await {
Ok(response) if response.status().is_success() => return Ok(client),
Ok(response) => last_error = Some(anyhow!("health returned {}", response.status())),
Err(err) => last_error = Some(anyhow!(err)),
}
sleep(Duration::from_millis(50)).await;
}
Err(last_error.unwrap_or_else(|| anyhow!("Fabro server did not become ready in time")))
}