diff --git a/Cargo.lock b/Cargo.lock index 655be86cb..eac5d2242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1679,6 +1679,7 @@ dependencies = [ "fabro-llm", "fabro-macros", "fabro-mcp", + "fabro-mcp-server", "fabro-model", "fabro-oauth", "fabro-proc", @@ -2019,6 +2020,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "fabro-mcp-server" +version = "0.230.0-nightly.0" +dependencies = [ + "anyhow", + "dirs", + "rmcp", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "fabro-model" version = "0.230.0-nightly.0" diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index d3f4c94d2..e05a5691d 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -31,6 +31,7 @@ fabro-hooks = { path = "../fabro-hooks" } fabro-install = { path = "../fabro-install" } fabro-interview = { path = "../fabro-interview" } fabro-mcp = { path = "../fabro-mcp" } +fabro-mcp-server = { path = "../fabro-mcp-server" } fabro-proc = { path = "../fabro-proc" } fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } fabro-checkpoint = { path = "../fabro-checkpoint" } diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 8525fa0df..0ed502b1b 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -168,6 +168,49 @@ pub(crate) struct ServerConnectionArgs { pub(crate) target: ServerTargetArgs, } +#[derive(Args)] +pub(crate) struct McpNamespace { + #[command(subcommand)] + pub(crate) command: McpCommand, +} + +#[derive(Subcommand)] +pub(crate) enum McpCommand { + /// Start the Fabro MCP server over stdio + Start(McpStartArgs), + /// Print MCP client configuration JSON + Config(McpConfigArgs), + /// Configure an MCP client to launch Fabro + Init(McpInitArgs), +} + +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct McpStartArgs { + #[command(flatten)] + pub(crate) connection: ServerConnectionArgs, +} + +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct McpConfigArgs { + #[command(flatten)] + pub(crate) connection: ServerConnectionArgs, +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct McpInitArgs { + pub(crate) agent: McpAgent, + + #[command(flatten)] + pub(crate) connection: ServerConnectionArgs, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum McpAgent { + Claude, + Cursor, + Windsurf, +} + #[derive(Args, Debug, Clone, Default)] pub(crate) struct InputOverrideArgs { /// Override a workflow input value (repeatable, format: KEY=VALUE) @@ -1118,6 +1161,8 @@ pub(crate) enum Commands { #[command(subcommand)] command: Option, }, + /// Model Context Protocol server + Mcp(McpNamespace), /// Server operations Server(ServerNamespace), /// Check environment and integration health @@ -1209,6 +1254,11 @@ impl Commands { Some(ModelsCommand::Test(_)) => "model test", None => "model", }, + Self::Mcp(ns) => match &ns.command { + McpCommand::Start(_) => "mcp start", + McpCommand::Config(_) => "mcp config", + McpCommand::Init(_) => "mcp init", + }, Self::Server(ns) => match &ns.command { ServerCommand::Start(_) => "server start", ServerCommand::Stop(_) => "server stop", diff --git a/lib/crates/fabro-cli/src/commands/mcp/mod.rs b/lib/crates/fabro-cli/src/commands/mcp/mod.rs new file mode 100644 index 000000000..84fde4583 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/mcp/mod.rs @@ -0,0 +1,66 @@ +use anyhow::{Context as _, Result}; + +use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs}; +use crate::command_context::CommandContext; + +pub(crate) async fn dispatch(ns: McpNamespace, base_ctx: &CommandContext) -> Result<()> { + match ns.command { + McpCommand::Start(args) => { + fabro_mcp_server::start(server_settings(base_ctx, &args.connection)?).await + } + McpCommand::Config(args) => { + let json = fabro_mcp_server::config_json(config_settings(&args.connection)); + print!("{json}"); + Ok(()) + } + McpCommand::Init(args) => { + fabro_mcp_server::init_agent(init_settings(args.agent, &args.connection)?)?; + Ok(()) + } + } +} + +fn server_settings( + base_ctx: &CommandContext, + connection: &ServerConnectionArgs, +) -> Result { + Ok(fabro_mcp_server::McpServerSettings { + config: config_settings(connection), + home_dir: home_dir()?, + cwd: base_ctx.cwd().to_path_buf(), + }) +} + +fn init_settings( + agent: McpAgent, + connection: &ServerConnectionArgs, +) -> Result { + Ok(fabro_mcp_server::McpInitSettings { + agent: McpAgentForServer(agent).into(), + config: config_settings(connection), + home_dir: home_dir()?, + }) +} + +fn config_settings(connection: &ServerConnectionArgs) -> fabro_mcp_server::McpConfigSettings { + fabro_mcp_server::McpConfigSettings { + server: connection.target.server.clone(), + storage_dir: connection.storage_dir.clone_path(), + } +} + +fn home_dir() -> Result { + dirs::home_dir().context("failed to resolve home directory for MCP config") +} + +struct McpAgentForServer(McpAgent); + +impl From for fabro_mcp_server::McpAgent { + fn from(value: McpAgentForServer) -> Self { + match value.0 { + McpAgent::Claude => Self::Claude, + McpAgent::Cursor => Self::Cursor, + McpAgent::Windsurf => Self::Windsurf, + } + } +} diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 5f2b24b16..7e5c3ee69 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod dump; pub(crate) mod exec; pub(crate) mod graph; pub(crate) mod install; +pub(crate) mod mcp; pub(crate) mod model; pub(crate) mod parse; pub(crate) mod pr; diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index adf4562d6..f0e00b997 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -281,6 +281,9 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) { Commands::Model { command } => { commands::model::execute(command, &base_ctx).await?; } + Commands::Mcp(ns) => { + commands::mcp::dispatch(ns, &base_ctx).await?; + } Commands::Server(ns) => { Box::pin(commands::server::dispatch( ns.command, diff --git a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs new file mode 100644 index 000000000..73070d929 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs @@ -0,0 +1,112 @@ +use fabro_test::{fabro_snapshot, test_context}; + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["mcp", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Model Context Protocol server + + Usage: fabro mcp [OPTIONS] + + Commands: + start Start the Fabro MCP server over stdio + config Print MCP client configuration JSON + init Configure an MCP client to launch Fabro + help Print this message or the help of the given subcommand(s) + + Options: + --json Output as JSON [env: FABRO_JSON=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +fn start_help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["mcp", "start", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Start the Fabro MCP server over stdio + + Usage: fabro mcp start [OPTIONS] + + Options: + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +fn config_help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["mcp", "config", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Print MCP client configuration JSON + + Usage: fabro mcp config [OPTIONS] + + Options: + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +fn init_help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["mcp", "init", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Configure an MCP client to launch Fabro + + Usage: fabro mcp init [OPTIONS] + + Arguments: + [possible values: claude, cursor, windsurf] + + Options: + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 58a98b5e3..0dd5da69b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -20,6 +20,7 @@ mod inspect; mod install; mod json_global; mod logs; +mod mcp; mod model; mod model_list; mod model_test; diff --git a/lib/crates/fabro-mcp-server/Cargo.toml b/lib/crates/fabro-mcp-server/Cargo.toml new file mode 100644 index 000000000..94f1119fb --- /dev/null +++ b/lib/crates/fabro-mcp-server/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "fabro-mcp-server" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "Fabro MCP stdio server" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +dirs.workspace = true +rmcp = { workspace = true, features = ["server", "macros", "schemars", "transport-io"] } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true diff --git a/lib/crates/fabro-mcp-server/src/config.rs b/lib/crates/fabro-mcp-server/src/config.rs new file mode 100644 index 000000000..42a042b74 --- /dev/null +++ b/lib/crates/fabro-mcp-server/src/config.rs @@ -0,0 +1,21 @@ +use anyhow::Result; +use serde_json::json; + +use crate::{McpConfigSettings, McpInitSettings}; + +pub fn config_json(_settings: McpConfigSettings) -> String { + serde_json::to_string_pretty(&json!({ + "mcpServers": { + "fabro": { + "command": "fabro", + "args": ["mcp", "start"] + } + } + })) + .expect("static MCP config should serialize") + + "\n" +} + +pub fn init_agent(_settings: McpInitSettings) -> Result<()> { + Ok(()) +} diff --git a/lib/crates/fabro-mcp-server/src/lib.rs b/lib/crates/fabro-mcp-server/src/lib.rs new file mode 100644 index 000000000..be53c34a0 --- /dev/null +++ b/lib/crates/fabro-mcp-server/src/lib.rs @@ -0,0 +1,35 @@ +mod config; +mod run_tools; +mod server; + +use std::path::PathBuf; + +pub use config::{config_json, init_agent}; +pub use server::start; + +#[derive(Debug, Clone)] +pub struct McpServerSettings { + pub config: McpConfigSettings, + pub home_dir: PathBuf, + pub cwd: PathBuf, +} + +#[derive(Debug, Clone, Default)] +pub struct McpConfigSettings { + pub server: Option, + pub storage_dir: Option, +} + +#[derive(Debug, Clone)] +pub struct McpInitSettings { + pub agent: McpAgent, + pub config: McpConfigSettings, + pub home_dir: PathBuf, +} + +#[derive(Debug, Clone, Copy)] +pub enum McpAgent { + Claude, + Cursor, + Windsurf, +} diff --git a/lib/crates/fabro-mcp-server/src/run_tools.rs b/lib/crates/fabro-mcp-server/src/run_tools.rs new file mode 100644 index 000000000..2bcd6d29e --- /dev/null +++ b/lib/crates/fabro-mcp-server/src/run_tools.rs @@ -0,0 +1 @@ +// Run-management MCP tools are implemented after the stdio server skeleton. diff --git a/lib/crates/fabro-mcp-server/src/server.rs b/lib/crates/fabro-mcp-server/src/server.rs new file mode 100644 index 000000000..1cf3cd462 --- /dev/null +++ b/lib/crates/fabro-mcp-server/src/server.rs @@ -0,0 +1,7 @@ +use anyhow::{Result, bail}; + +use crate::McpServerSettings; + +pub async fn start(_settings: McpServerSettings) -> Result<()> { + bail!("fabro mcp start is not implemented yet") +}