mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
feat(cli): start fabro mcp stdio server
This commit is contained in:
parent
c19acaccd3
commit
f425e51acb
6 changed files with 761 additions and 5 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2026,7 +2026,9 @@ version = "0.230.0-nightly.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
"fabro-client",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,18 @@
|
|||
clippy::disallowed_methods,
|
||||
reason = "integration tests stage MCP config files with sync std::fs"
|
||||
)]
|
||||
#![expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "raw stdio regression test intentionally uses blocking std pipes outside Tokio"
|
||||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead as _, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
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};
|
||||
|
||||
#[test]
|
||||
|
|
@ -336,6 +345,78 @@ fn init_invalid_json_fails_without_overwrite() {
|
|||
assert_eq!(std::fs::read_to_string(config_path).unwrap(), "{not json");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn stdio_server_initializes_and_lists_run_tools() {
|
||||
let context = test_context!();
|
||||
let client = spawn_mcp_client(&context, &[]).await;
|
||||
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
let names: Vec<_> = tools.iter().map(|(name, _, _)| name.as_str()).collect();
|
||||
assert_eq!(names, vec![
|
||||
"fabro_run_create",
|
||||
"fabro_run_events",
|
||||
"fabro_run_gather",
|
||||
"fabro_run_interact",
|
||||
"fabro_run_search",
|
||||
]);
|
||||
for (_, _, schema) in tools {
|
||||
assert!(
|
||||
schema.is_object(),
|
||||
"tool should have input schema: {schema}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_start_writes_only_json_rpc_to_stdout() {
|
||||
let context = test_context!();
|
||||
let fixture = mcp_stdio_fixture(&context, &[]);
|
||||
let mut cmd = std::process::Command::new(&fixture.command[0]);
|
||||
cmd.args(&fixture.command[1..])
|
||||
.env_clear()
|
||||
.envs(&fixture.env)
|
||||
.current_dir(&fixture.current_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
writeln!(
|
||||
stdin,
|
||||
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18","capabilities":{{}},"clientInfo":{{"name":"fabro-test","version":"0.0.0"}}}}}}"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut line = String::new();
|
||||
let result = std::io::BufReader::new(stdout).read_line(&mut line);
|
||||
let _ = tx.send(result.map(|_| line));
|
||||
});
|
||||
|
||||
let line = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.expect("initialize response should arrive")
|
||||
.expect("stdout should be readable");
|
||||
let value: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||
assert_eq!(value["jsonrpc"], "2.0");
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn stdio_startup_and_list_tools_is_fast() {
|
||||
let context = test_context!();
|
||||
let start = std::time::Instant::now();
|
||||
let client = spawn_mcp_client(&context, &[]).await;
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
assert_eq!(tools.len(), 5);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2));
|
||||
}
|
||||
|
||||
fn expected_claude_config_path(home_dir: &Path) -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
|
@ -361,3 +442,49 @@ fn expected_claude_config_path(home_dir: &Path) -> PathBuf {
|
|||
.join("claude_desktop_config.json")
|
||||
}
|
||||
}
|
||||
|
||||
struct McpStdioFixture {
|
||||
command: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
current_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn mcp_stdio_fixture(context: &fabro_test::TestContext, extra_args: &[&str]) -> McpStdioFixture {
|
||||
let mut command = vec![
|
||||
env!("CARGO_BIN_EXE_fabro").to_string(),
|
||||
"mcp".to_string(),
|
||||
"start".to_string(),
|
||||
];
|
||||
command.extend(extra_args.iter().map(|arg| (*arg).to_string()));
|
||||
|
||||
let mut env = fabro_test::isolated_env(&context.home_dir);
|
||||
env.insert(
|
||||
"FABRO_HOME".to_string(),
|
||||
context.home_dir.join(".fabro").display().to_string(),
|
||||
);
|
||||
|
||||
McpStdioFixture {
|
||||
command,
|
||||
env,
|
||||
current_dir: context.temp_dir.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_mcp_client(context: &fabro_test::TestContext, extra_args: &[&str]) -> McpClient {
|
||||
let fixture = mcp_stdio_fixture(context, extra_args);
|
||||
let config = McpServerSettings {
|
||||
name: "fabro-under-test".to_string(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: fixture.command,
|
||||
env: fixture.env,
|
||||
},
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
};
|
||||
let client = McpClient::new(&config).expect("MCP client should build");
|
||||
client
|
||||
.initialize(config.startup_timeout())
|
||||
.await
|
||||
.expect("MCP server should initialize");
|
||||
client
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ workspace = true
|
|||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
dirs.workspace = true
|
||||
fabro-client = { path = "../fabro-client" }
|
||||
rmcp = { workspace = true, features = ["server", "macros", "schemars", "transport-io"] }
|
||||
schemars = "1.2.1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -1 +1,419 @@
|
|||
// Run-management MCP tools are implemented after the stdio server skeleton.
|
||||
#![allow(
|
||||
dead_code,
|
||||
reason = "The MCP server skeleton defines the full first-slice contract before each tool body is implemented."
|
||||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_client::Client;
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::task::yield_now;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ToolError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ToolError {
|
||||
pub(crate) fn message(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_anyhow(err: &anyhow::Error) -> Self {
|
||||
Self::message(format_tool_error(err))
|
||||
}
|
||||
|
||||
pub(crate) fn as_str(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type ToolResult<T> = Result<T, ToolError>;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunCreateParams {
|
||||
pub(crate) runs: Vec<CreateRunSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct CreateRunSpec {
|
||||
pub(crate) workflow: String,
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) run_id: Option<String>,
|
||||
pub(crate) goal: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) inputs: HashMap<String, Value>,
|
||||
#[serde(default)]
|
||||
pub(crate) labels: HashMap<String, String>,
|
||||
pub(crate) dry_run: Option<bool>,
|
||||
pub(crate) auto_approve: Option<bool>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) provider: Option<String>,
|
||||
pub(crate) sandbox: Option<String>,
|
||||
pub(crate) preserve_sandbox: Option<bool>,
|
||||
pub(crate) start: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedCreateRuns {
|
||||
pub(crate) runs: Vec<CreateRunSpec>,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunCreateParams> for ValidatedCreateRuns {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunCreateParams) -> Result<Self, Self::Error> {
|
||||
validate_len("runs", params.runs.len(), 1, 50)?;
|
||||
Ok(Self { runs: params.runs })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct CreateRunsResult {
|
||||
pub(crate) runs: Vec<CreatedRunResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct CreatedRunResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) workflow: String,
|
||||
pub(crate) started: bool,
|
||||
pub(crate) status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunSearchParams {
|
||||
pub(crate) run_ids: Option<Vec<String>>,
|
||||
pub(crate) workflow: Option<String>,
|
||||
pub(crate) labels: Option<HashMap<String, String>>,
|
||||
pub(crate) status: Option<Vec<String>>,
|
||||
pub(crate) archived: Option<bool>,
|
||||
pub(crate) created_after: Option<String>,
|
||||
pub(crate) created_before: Option<String>,
|
||||
pub(crate) first: Option<usize>,
|
||||
pub(crate) after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedSearchRuns {
|
||||
pub(crate) raw: FabroRunSearchParams,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunSearchParams> for ValidatedSearchRuns {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunSearchParams) -> Result<Self, Self::Error> {
|
||||
if params.first.is_some_and(|first| first > 100) {
|
||||
return Err(ToolError::message("first must be <= 100"));
|
||||
}
|
||||
Ok(Self { raw: params })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct SearchRunsResult {
|
||||
pub(crate) runs: Vec<RunSummaryResult>,
|
||||
pub(crate) next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct RunSummaryResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) workflow_name: String,
|
||||
pub(crate) workflow_slug: Option<String>,
|
||||
pub(crate) status: String,
|
||||
pub(crate) archived: bool,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) started_at: Option<String>,
|
||||
pub(crate) completed_at: Option<String>,
|
||||
pub(crate) labels: HashMap<String, String>,
|
||||
pub(crate) source_directory: Option<String>,
|
||||
pub(crate) repo_origin_url: Option<String>,
|
||||
pub(crate) goal: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum RunInteractAction {
|
||||
Get,
|
||||
Start,
|
||||
Message,
|
||||
Cancel,
|
||||
Archive,
|
||||
Unarchive,
|
||||
GetQuestions,
|
||||
Answer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunInteractParams {
|
||||
pub(crate) action: RunInteractAction,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) message: Option<String>,
|
||||
pub(crate) interrupt: Option<bool>,
|
||||
pub(crate) question_id: Option<String>,
|
||||
pub(crate) answer: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedInteractRun {
|
||||
pub(crate) raw: FabroRunInteractParams,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunInteractParams> for ValidatedInteractRun {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunInteractParams) -> Result<Self, Self::Error> {
|
||||
if params.run_id.trim().is_empty() {
|
||||
return Err(ToolError::message("run_id is required"));
|
||||
}
|
||||
if matches!(params.action, RunInteractAction::Message)
|
||||
&& params
|
||||
.message
|
||||
.as_deref()
|
||||
.is_none_or(|message| message.trim().is_empty())
|
||||
{
|
||||
return Err(ToolError::message("message is required for action message"));
|
||||
}
|
||||
if matches!(params.action, RunInteractAction::Answer) {
|
||||
if params.question_id.as_deref().is_none_or(str::is_empty) {
|
||||
return Err(ToolError::message(
|
||||
"question_id is required for action answer",
|
||||
));
|
||||
}
|
||||
if params.answer.is_none() {
|
||||
return Err(ToolError::message("answer is required for action answer"));
|
||||
}
|
||||
}
|
||||
Ok(Self { raw: params })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct InteractRunResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) action: RunInteractAction,
|
||||
pub(crate) result: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunGatherParams {
|
||||
pub(crate) run_ids: Vec<String>,
|
||||
pub(crate) timeout_seconds: Option<u64>,
|
||||
pub(crate) poll_interval_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedGatherRuns {
|
||||
pub(crate) run_ids: Vec<String>,
|
||||
pub(crate) timeout_seconds: u64,
|
||||
pub(crate) poll_interval_seconds: u64,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunGatherParams> for ValidatedGatherRuns {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunGatherParams) -> Result<Self, Self::Error> {
|
||||
validate_len("run_ids", params.run_ids.len(), 1, 50)?;
|
||||
Ok(Self {
|
||||
run_ids: params.run_ids,
|
||||
timeout_seconds: params.timeout_seconds.unwrap_or(300).min(600),
|
||||
poll_interval_seconds: params.poll_interval_seconds.unwrap_or(15).max(5),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct GatherRunsResult {
|
||||
pub(crate) runs: Vec<RunSummaryResult>,
|
||||
pub(crate) timed_out: bool,
|
||||
pub(crate) elapsed_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum RunEventsAction {
|
||||
List,
|
||||
Details,
|
||||
Search,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunEventsParams {
|
||||
pub(crate) action: RunEventsAction,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) event_types: Option<Vec<String>>,
|
||||
pub(crate) categories: Option<Vec<String>>,
|
||||
pub(crate) direction: Option<String>,
|
||||
pub(crate) created_after: Option<String>,
|
||||
pub(crate) created_before: Option<String>,
|
||||
pub(crate) first: Option<usize>,
|
||||
pub(crate) after: Option<u32>,
|
||||
pub(crate) event_ids: Option<Vec<String>>,
|
||||
pub(crate) offset: Option<usize>,
|
||||
pub(crate) limit: Option<usize>,
|
||||
pub(crate) max_content_length: Option<usize>,
|
||||
pub(crate) query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedRunEvents {
|
||||
pub(crate) raw: FabroRunEventsParams,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunEventsParams> for ValidatedRunEvents {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunEventsParams) -> Result<Self, Self::Error> {
|
||||
if params.run_id.trim().is_empty() {
|
||||
return Err(ToolError::message("run_id is required"));
|
||||
}
|
||||
let first = params.first.or(params.limit).unwrap_or(50);
|
||||
if first > 200 {
|
||||
return Err(ToolError::message("first must be <= 200"));
|
||||
}
|
||||
Ok(Self { raw: params })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct RunEventsResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) action: RunEventsAction,
|
||||
pub(crate) events: Vec<RunEventResult>,
|
||||
pub(crate) next_cursor: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct RunEventResult {
|
||||
pub(crate) event_id: String,
|
||||
pub(crate) sequence: u32,
|
||||
pub(crate) event: Value,
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn create_runs(
|
||||
_client: Arc<Client>,
|
||||
_base_cwd: &Path,
|
||||
_params: ValidatedCreateRuns,
|
||||
) -> ToolResult<CreateRunsResult> {
|
||||
yield_now().await;
|
||||
Err(ToolError::message(
|
||||
"fabro_run_create is not implemented yet",
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn search_runs(
|
||||
_client: Arc<Client>,
|
||||
_params: ValidatedSearchRuns,
|
||||
) -> ToolResult<SearchRunsResult> {
|
||||
yield_now().await;
|
||||
Err(ToolError::message(
|
||||
"fabro_run_search is not implemented yet",
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn interact_run(
|
||||
_client: Arc<Client>,
|
||||
_params: ValidatedInteractRun,
|
||||
) -> ToolResult<InteractRunResult> {
|
||||
yield_now().await;
|
||||
Err(ToolError::message(
|
||||
"fabro_run_interact is not implemented yet",
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn gather_runs(
|
||||
_client: Arc<Client>,
|
||||
_params: ValidatedGatherRuns,
|
||||
) -> ToolResult<GatherRunsResult> {
|
||||
yield_now().await;
|
||||
Err(ToolError::message(
|
||||
"fabro_run_gather is not implemented yet",
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn run_events(
|
||||
_client: Arc<Client>,
|
||||
_params: ValidatedRunEvents,
|
||||
) -> ToolResult<RunEventsResult> {
|
||||
yield_now().await;
|
||||
Err(ToolError::message(
|
||||
"fabro_run_events is not implemented yet",
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn success_result<T: Serialize>(
|
||||
value: &T,
|
||||
text: impl Into<String>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
let structured_content = serde_json::to_value(value).map_err(|err| {
|
||||
rmcp::ErrorData::internal_error(
|
||||
format!("failed to serialize Fabro MCP tool result: {err}"),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let mut result = CallToolResult::structured(structured_content);
|
||||
result.content = vec![Content::text(text.into())];
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn error_result(err: ToolError) -> CallToolResult {
|
||||
CallToolResult::error(vec![Content::text(err.message)])
|
||||
}
|
||||
|
||||
pub(crate) fn create_runs_text(result: &CreateRunsResult) -> String {
|
||||
let started = result.runs.iter().filter(|run| run.started).count();
|
||||
format!(
|
||||
"created {} Fabro run(s), started {started}",
|
||||
result.runs.len()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn search_runs_text(result: &SearchRunsResult) -> String {
|
||||
format!("found {} Fabro run(s)", result.runs.len())
|
||||
}
|
||||
|
||||
pub(crate) fn interact_run_text(result: &InteractRunResult) -> String {
|
||||
format!(
|
||||
"completed {:?} for Fabro run {}",
|
||||
result.action, result.run_id
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn gather_runs_text(result: &GatherRunsResult) -> String {
|
||||
format!(
|
||||
"gathered {} Fabro run(s), timed_out={}",
|
||||
result.runs.len(),
|
||||
result.timed_out
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn run_events_text(result: &RunEventsResult) -> String {
|
||||
format!("returned {} Fabro event(s)", result.events.len())
|
||||
}
|
||||
|
||||
fn validate_len(name: &str, len: usize, min: usize, max: usize) -> ToolResult<()> {
|
||||
if len < min {
|
||||
return Err(ToolError::message(format!(
|
||||
"{name} must contain at least {min} item(s)"
|
||||
)));
|
||||
}
|
||||
if len > max {
|
||||
return Err(ToolError::message(format!(
|
||||
"{name} must contain no more than {max} item(s)"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_tool_error(err: &anyhow::Error) -> String {
|
||||
format!("{err:#}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,177 @@
|
|||
use anyhow::{Result, bail};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::McpServerSettings;
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_client::Client;
|
||||
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::sync::OnceCell;
|
||||
use tokio::task::yield_now;
|
||||
|
||||
pub async fn start(_settings: McpServerSettings) -> Result<()> {
|
||||
bail!("fabro mcp start is not implemented yet")
|
||||
use crate::{McpServerSettings, run_tools};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FabroMcpServer {
|
||||
settings: Arc<McpServerSettings>,
|
||||
client: Arc<OnceCell<Arc<Client>>>,
|
||||
cwd: PathBuf,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
pub async fn start(settings: McpServerSettings) -> Result<()> {
|
||||
let server = FabroMcpServer::new(Arc::new(settings));
|
||||
let service = serve_server(server, stdio()).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for FabroMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_instructions("Use these tools to create, inspect, control, wait for, and read events from Fabro workflow runs.")
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = tool_router)]
|
||||
impl FabroMcpServer {
|
||||
pub(crate) fn new(settings: Arc<McpServerSettings>) -> Self {
|
||||
let cwd = settings.cwd.clone();
|
||||
Self {
|
||||
settings,
|
||||
client: Arc::new(OnceCell::new()),
|
||||
cwd,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_create",
|
||||
description = "Create one or more Fabro workflow runs, starting them by default."
|
||||
)]
|
||||
async fn fabro_run_create(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunCreateParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedCreateRuns::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::create_runs(client, &self.cwd, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::create_runs_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_search",
|
||||
description = "Search Fabro workflow runs by id, workflow, labels, status, archival state, and creation time."
|
||||
)]
|
||||
async fn fabro_run_search(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunSearchParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedSearchRuns::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::search_runs(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::search_runs_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_interact",
|
||||
description = "Get, start, message, cancel, archive, unarchive, inspect questions, or answer a Fabro run."
|
||||
)]
|
||||
async fn fabro_run_interact(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunInteractParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedInteractRun::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::interact_run(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::interact_run_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_gather",
|
||||
description = "Wait for Fabro runs to reach terminal states, returning current state on timeout."
|
||||
)]
|
||||
async fn fabro_run_gather(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunGatherParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedGatherRuns::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::gather_runs(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::gather_runs_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_events",
|
||||
description = "List, inspect, or search stored events for a Fabro workflow run."
|
||||
)]
|
||||
async fn fabro_run_events(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunEventsParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedRunEvents::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::run_events(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::run_events_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn client(&self) -> Result<Arc<Client>, run_tools::ToolError> {
|
||||
self.client
|
||||
.get_or_try_init(|| async {
|
||||
client_from_settings(&self.settings)
|
||||
.await
|
||||
.map(Arc::new)
|
||||
.map_err(|err| run_tools::ToolError::from_anyhow(&err))
|
||||
})
|
||||
.await
|
||||
.map(Arc::clone)
|
||||
}
|
||||
}
|
||||
|
||||
async fn client_from_settings(_settings: &McpServerSettings) -> Result<Client> {
|
||||
yield_now().await;
|
||||
Err(anyhow!("fabro MCP API client is not implemented yet"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,43 @@ pub fn apply_test_isolation(cmd: &mut std::process::Command, home_dir: &Path) {
|
|||
apply_test_isolation_with_lookup(cmd, home_dir, |name| std::env::var_os(name));
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn isolated_env(home_dir: &Path) -> HashMap<String, String> {
|
||||
let mut env = HashMap::new();
|
||||
if let Some(coverage) =
|
||||
std::env::var_os(EnvVars::LLVM_PROFILE_FILE).and_then(|value| value.into_string().ok())
|
||||
{
|
||||
env.insert(EnvVars::LLVM_PROFILE_FILE.to_string(), coverage);
|
||||
}
|
||||
if let Some(path) = std::env::var_os(EnvVars::PATH).and_then(|value| value.into_string().ok()) {
|
||||
env.insert(EnvVars::PATH.to_string(), path);
|
||||
}
|
||||
env.insert(EnvVars::NO_COLOR.to_string(), "1".to_string());
|
||||
env.insert(EnvVars::HOME.to_string(), home_dir.display().to_string());
|
||||
env.insert(
|
||||
EnvVars::FABRO_NO_UPGRADE_CHECK.to_string(),
|
||||
"true".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
EnvVars::FABRO_HTTP_PROXY_POLICY.to_string(),
|
||||
"disabled".to_string(),
|
||||
);
|
||||
env.insert(EnvVars::FABRO_TELEMETRY.to_string(), "off".to_string());
|
||||
env.insert(
|
||||
EnvVars::FABRO_SUPPRESS_OPEN_BROWSER.to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
EnvVars::FABRO_SERVER_MAX_CONCURRENT_RUNS.to_string(),
|
||||
"64".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
EnvVars::FABRO_TEST_IN_MEMORY_STORE.to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
env
|
||||
}
|
||||
|
||||
fn apply_test_isolation_with_lookup(
|
||||
cmd: &mut std::process::Command,
|
||||
home_dir: &Path,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue