mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
fix(cli): align MCP run tools with CLI behavior
This commit is contained in:
parent
40a057f10c
commit
9fc0ec3061
12 changed files with 1864 additions and 1262 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
29
lib/crates/fabro-manifest/Cargo.toml
Normal file
29
lib/crates/fabro-manifest/Cargo.toml
Normal 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"
|
||||
1168
lib/crates/fabro-manifest/src/lib.rs
Normal file
1168
lib/crates/fabro-manifest/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue