## Summary Adds a stdio-based Fabro MCP server so MCP clients can manage Fabro workflow runs through the authenticated `fabro` CLI, without a separate MCP auth flow. ## What Changed - Adds `fabro mcp start`, `fabro mcp config`, and `fabro mcp init <agent>` for launching and configuring the MCP server. - Introduces a new `fabro-mcp-server` crate with run-management tools: - `fabro_run_create` - `fabro_run_search` - `fabro_run_interact` - `fabro_run_gather` - `fabro_run_events` - Reuses the CLI's authenticated server connection behavior, including OAuth refresh, dev-token/local-server handling, explicit server targets, proxy behavior, and stdio env/cwd isolation. - Moves shared run-manifest construction into `fabro-manifest` so CLI runs and MCP-created runs use the same override semantics. - Extends MCP client stdio support with configured cwd and exact environment handling for reliable spawned-server tests. --------- Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Co-authored-by: Fabro <noreply@fabro.sh>
60 KiB
Fabro MCP Server Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a stdio MCP server to the fabro CLI, exposed as fabro mcp start, with fabro mcp config and fabro mcp init <agent> support and first-class tools for managing Fabro runs.
Architecture: Implement the Fabro MCP server in a new fabro-mcp-server crate, with fabro-cli owning only clap parsing and dispatch for fabro mcp .... Use rmcp server macros and stdio transport for protocol correctness, connect lazily to the Fabro API through settings built from the same CLI auth/config inputs as existing Fabro commands, and return structured MCP tool results plus text fallbacks. Keep the existing fabro-mcp crate as the external MCP client/shared protocol support used by Fabro agents and tests, not as the server crate.
Tech Stack: Rust, clap, tokio, new fabro-mcp-server crate, rmcp 1.3 stdio server transport, serde/schemars JSON schemas, fabro-client, fabro-api generated types, existing Fabro CLI integration test harness with insta snapshots.
File Structure
-
Create
lib/crates/fabro-mcp-server/Cargo.toml- New crate for the stdio MCP server implementation. Add direct
rmcpdependency with server, macros, schemars, and stdio transport features, plus the Fabro crates needed for API access, run manifest construction, settings/auth-store resolution, and tests. The workspace already includeslib/crates/*, so no root workspace member edit is required.
- New crate for the stdio MCP server implementation. Add direct
-
Create
lib/crates/fabro-mcp-server/src/lib.rs- Export the server entry points and settings types consumed by
fabro-cli:McpServerSettings,McpConfigSettings,McpAgent,start(settings),config_json(settings), andinit_agent(settings).
- Export the server entry points and settings types consumed by
-
Create
lib/crates/fabro-mcp-server/src/server.rs- Own the stdio MCP service, tool registration, API client acquisition from explicit settings, and tool error shaping.
-
Create
lib/crates/fabro-mcp-server/src/run_tools.rs- Own run-management behavior behind the MCP tools: create/start, search, interact, gather, and events.
- This split keeps protocol boilerplate out of run semantics.
-
Create
lib/crates/fabro-mcp-server/src/config.rs- Own generic MCP config rendering and agent-specific config path/merge/write logic.
-
Modify
lib/crates/fabro-cli/Cargo.toml- Add a path dependency on the new
fabro-mcp-servercrate.fabro-clishould not depend directly onrmcpfor the server implementation.
- Add a path dependency on the new
-
Modify
lib/crates/fabro-cli/src/args.rs- Add
McpNamespace,McpCommand,McpStartArgs,McpConfigArgs,McpInitArgs, andMcpAgent. - Add
Commands::Mcp(McpNamespace)andCommands::name()branch returningmcp start,mcp config, ormcp init.
- Add
-
Modify
lib/crates/fabro-cli/src/main.rs- Add
mod commands::mcpdispatch. - Keep
fabro mcp starton the normal CLI logging path, which writes logs to stderr, and never write human output to stdout during stdio serving.
- Add
-
Modify
lib/crates/fabro-cli/src/commands/mod.rs- Export the new
mcpcommand module.
- Export the new
-
Create
lib/crates/fabro-cli/src/commands/mcp/mod.rs- Own CLI dispatch for
start,config, andinit.
- Own CLI dispatch for
-
Modify
lib/crates/fabro-cli/src/commands/run/overrides.rs- If needed, move shared manifest override construction into a non-CLI crate or expose a small reusable helper without creating a dependency from
fabro-mcp-serverback tofabro-cli:- label parsing
- goal layer construction
- execution/model/sandbox override construction
- Do not duplicate manifest override semantics in the MCP server crate.
- If needed, move shared manifest override construction into a non-CLI crate or expose a small reusable helper without creating a dependency from
-
Modify
lib/crates/fabro-cli/tests/it/cmd/mod.rs- Add
mod mcp;.
- Add
-
Create
lib/crates/fabro-cli/tests/it/cmd/mcp.rs- Add CLI help/config/init snapshots and stdio MCP integration tests.
-
Optionally modify
lib/crates/fabro-cli/tests/it/support/mod.rs- Add only narrow helpers for spawning
fabro mcp startor extracting MCP text/structured output if duplication appears incmd/mcp.rs.
- Add only narrow helpers for spawning
Before editing Rust code, read:
docs/internal/testing-strategy.mdbecause this plan adds CLI integration tests and unit tests.docs/internal/error-handling-strategy.mdbecause MCP tool failures convert CLI/API/auth errors into user-visible tool errors.
User-Visible Contract
The CLI contract is:
fabro mcp start [--server <SERVER>] [--storage-dir <DIR>]
fabro mcp config [--server <SERVER>] [--storage-dir <DIR>]
fabro mcp init <agent> [--server <SERVER>] [--storage-dir <DIR>]
Supported agents for the first implementation:
claude
cursor
windsurf
fabro mcp config emits generic MCP client JSON to stdout:
{
"mcpServers": {
"fabro": {
"command": "fabro",
"args": ["mcp", "start"]
}
}
}
When --server or --storage-dir is passed to config or init, preserve those choices in the emitted or written args, for example:
{
"mcpServers": {
"fabro": {
"command": "fabro",
"args": ["mcp", "start", "--server", "https://example.test/api/v1"]
}
}
}
fabro mcp init <agent> writes the same entry into the agent config file under mcpServers.fabro, preserving every unrelated existing key. Re-running it is idempotent. If the existing file is invalid JSON or its root is not an object, fail clearly and do not overwrite it.
Agent config paths:
claude- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
- macOS:
cursor- all platforms:
~/.cursor/mcp.json
- all platforms:
windsurf- all platforms:
~/.codeium/windsurf/mcp_config.json
- all platforms:
The MCP server exposes exactly these tools in this first slice:
fabro_run_create
fabro_run_search
fabro_run_interact
fabro_run_gather
fabro_run_events
Tool Semantics
fabro_run_create
- Input:
#[derive(Debug, Deserialize, JsonSchema)]
struct FabroRunCreateParams {
runs: Vec<CreateRunSpec>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CreateRunSpec {
workflow: String,
cwd: Option<PathBuf>,
run_id: Option<String>,
goal: Option<String>,
#[serde(default)]
inputs: HashMap<String, serde_json::Value>,
#[serde(default)]
labels: HashMap<String, String>,
dry_run: Option<bool>,
auto_approve: Option<bool>,
model: Option<String>,
provider: Option<String>,
sandbox: Option<String>,
preserve_sandbox: Option<bool>,
start: Option<bool>,
}
runsis required and must contain 1 to 50 entries.workflowis a workflow path or project workflow selector resolved fromcwdwhen provided, otherwise from the MCP process cwd.startdefaults totruebecause this is analogous to Devin session creation: creating a run for an agent should normally launch it. Passingstart: falsecreates a submitted run without starting it.inputsobject values are converted totoml::Valuewith JSON-compatible semantics: string, bool, integer, float, arrays, and objects are accepted; null is rejected with a tool error naming the key.- Output is structured:
#[derive(Debug, Serialize, JsonSchema)]
struct CreateRunsResult {
runs: Vec<CreatedRunResult>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct CreatedRunResult {
run_id: String,
workflow: String,
started: bool,
status: String,
}
fabro_run_search
- Input:
struct FabroRunSearchParams {
run_ids: Option<Vec<String>>,
workflow: Option<String>,
labels: Option<HashMap<String, String>>,
status: Option<Vec<String>>,
archived: Option<bool>,
created_after: Option<String>,
created_before: Option<String>,
first: Option<usize>,
after: Option<String>,
}
- Search starts from
Client::list_store_runs(), which already includes archived runs. statususes existingrun_status_kind(...)strings.created_afterandcreated_beforeparse RFC3339 timestamps orYYYY-MM-DDdates.firstdefaults to 20 and has max 100.afteris an opaque cursor containing the last run id from the previous page. For the first implementation, encode it as the run id string and document it as opaque in the tool description.- Output contains normalized run summaries:
struct RunSummaryResult {
run_id: String,
workflow_name: String,
workflow_slug: Option<String>,
status: String,
archived: bool,
created_at: String,
started_at: Option<String>,
completed_at: Option<String>,
labels: HashMap<String, String>,
source_directory: Option<String>,
repo_origin_url: Option<String>,
goal: String,
}
fabro_run_interact
- Input:
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum RunInteractAction {
Get,
Start,
Message,
Cancel,
Archive,
Unarchive,
GetQuestions,
Answer,
}
struct FabroRunInteractParams {
action: RunInteractAction,
run_id: String,
message: Option<String>,
interrupt: Option<bool>,
question_id: Option<String>,
answer: Option<serde_json::Value>,
}
run_idaccepts the same selector semantics as CLI commands by callingClient::resolve_run(...).getreturns summary plus projection fromretrieve_runandget_run_state.startcallsstart_run(resume = false).messagecallssteer_run;messageis required and trimmed;interruptdefaults false.cancelcallscancel_run.archiveandunarchivecall existing API methods.get_questionscallslist_run_questions.answerrequiresquestion_idand maps answer JSON intoSubmitAnswerRequest:- boolean true -> yes
- boolean false -> no
- string -> freeform
{ "option": "key" }-> single choice{ "options": ["a", "b"] }-> multi choice{ "text": "..." }-> freeform
- Return a structured object with
run_id,action, and action-specificresult.
fabro_run_gather
- Input:
struct FabroRunGatherParams {
run_ids: Vec<String>,
timeout_seconds: Option<u64>,
poll_interval_seconds: Option<u64>,
}
run_idsis required, max 50.timeout_secondsdefaults to 300 and maxes at 600.poll_interval_secondsdefaults to 15 and mins at 5.- Resolve selectors once at the start.
- Poll
retrieve_rununtil every run is terminal or timeout expires. - Output contains each final or current run summary plus
timed_out: bool.
fabro_run_events
- Input:
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum RunEventsAction {
List,
Details,
Search,
}
struct FabroRunEventsParams {
action: RunEventsAction,
run_id: String,
event_types: Option<Vec<String>>,
categories: Option<Vec<String>>,
direction: Option<String>,
created_after: Option<String>,
created_before: Option<String>,
first: Option<usize>,
after: Option<u32>,
event_ids: Option<Vec<String>>,
offset: Option<usize>,
limit: Option<usize>,
max_content_length: Option<usize>,
query: Option<String>,
}
- Use
Client::list_run_events(...)rather than SSE for deterministic request/response behavior. listreturns paginated envelopes sorted ascending by default;direction: "desc"reverses after fetching.detailsfilters byevent_ids.searchfilters events whose serialized event JSON containsquery.event_typesmatchevent.event_name().categoriesare best-effort derived from the prefix before the first.inevent_name, for examplerun.completedhas categoryrun.firstdefaults to 50 and maxes at 200.limitis accepted as an alias for compatibility with the Devin-shaped input.aftermaps tosince_seq.max_content_lengthdefaults to 20_000 and truncates only large serialized event payload strings, with atruncated: truemarker in the returned event item.
Contracts And Invariants
fabro mcp startstdout is reserved for MCP JSON-RPC only. All logs, warnings, errors, tracing, and diagnostics must go to stderr.- MCP initialize and tools/list must not require a live Fabro server. API connection is lazy and happens when a tool needs it.
- There is no separate MCP authentication. Tool calls use the same CLI auth store and
fabro-clientbehavior as existing CLI commands. Auth failures returned from tools must include the existing user guidance:Run \fabro auth login` to authenticate.` - Tool failures are MCP tool errors, not process exits. The stdio server should stay alive after invalid arguments, not-found selectors, conflicts, auth failures, and API errors.
- Tool-level argument validation that does not need server state must run before acquiring the lazy Fabro API client. Every handler must convert raw MCP parameter structs into its tool-specific
Validated...type before auth lookup, client creation, selector resolution, or API calls. Invalid local input such as empty run lists, too many run ids, malformed timestamps, missing required action fields, unsupported answer JSON, or timeout values must report that validation error even when the CLI is not authenticated or the server is unavailable. - Every successful tool returns structured content and a concise text fallback. The text fallback is for clients that do not yet show MCP structured output. Do not return
rmcp::Json<T>directly from successful tools, because its text content is the full JSON payload. Instead, build aCallToolResultwithstructured_content: Some(...)and a shortContent::text(...)summary. rmcp 1.3only accepts manually constructedCallToolResultvalues from tool handlers throughResult<CallToolResult, rmcp::ErrorData>. Do not useResult<CallToolResult, String>in#[tool]methods; it does not satisfyIntoCallToolResult. Expected Fabro failures must be returned asOk(CallToolResult::error(...))so they are MCP tool errors and the server stays alive. ReserveErr(ErrorData)for unexpected serialization/framework failures.- Run selectors must go through
Client::resolve_run(...)to preserve existing Fabro prefix/workflow-name behavior. - Run creation must reuse
build_run_manifest(...)and server manifest validation. Do not fabricate run specs or bypass the same source-of-truth path asfabro create. - Agent config writes must be idempotent and preserve unrelated user config.
- Do not add live LLM/provider tests for this first slice. Use dry-run workflows and local/test servers.
Strategy Decisions
- Implement the server in
fabro-mcp-server: The existingfabro-mcpcrate remains the client/shared protocol support for agents consuming third-party MCP servers. The newfabro-mcp-servercrate owns the Fabro server implementation and exposes explicit settings APIs sofabro-clican wirefabro mcp ...commands without making the existing client crate a server crate. - Use
rmcpinstead of hand-rolled JSON-RPC: The project already depends onrmcpand uses it for MCP client behavior. The server should use the same SDK to get initialize/tools/list/tools/call semantics, JSON schema generation, and stdio framing right. - Default create to start: Devin's session creation starts usable sessions. For Fabro, a run that stays submitted unless the caller remembers a second tool call is a surprising first-use experience.
start: falsekeeps the lower-level control available without making it the default. - Use five Devin-shaped tools instead of many tiny tools: The user explicitly asked to adapt Devin sessions to Fabro runs. The five-tool shape is easier for MCP clients to discover and keeps later additions compatible. Internally, the Rust implementation should still split actions into small functions.
- Lazy API connection: MCP clients often list tools during startup. Requiring auth/server connectivity during initialize would make even configuration validation brittle. Lazy connection gives users useful tool discovery and clear per-tool auth errors.
- Validate before connecting: MCP clients often probe tools with incomplete or malformed payloads. Local validation must happen before API client acquisition so callers get actionable schema/argument errors instead of misleading auth or server availability failures.
Task 1: Add CLI Surface And Help Snapshots
Files:
-
Create:
lib/crates/fabro-mcp-server/Cargo.toml -
Create:
lib/crates/fabro-mcp-server/src/lib.rs -
Create:
lib/crates/fabro-mcp-server/src/config.rs -
Create:
lib/crates/fabro-mcp-server/src/run_tools.rs -
Create:
lib/crates/fabro-mcp-server/src/server.rs -
Modify:
lib/crates/fabro-cli/Cargo.toml -
Modify:
lib/crates/fabro-cli/src/args.rs -
Modify:
lib/crates/fabro-cli/src/main.rs -
Modify:
lib/crates/fabro-cli/src/commands/mod.rs -
Create:
lib/crates/fabro-cli/src/commands/mcp/mod.rs -
Create:
lib/crates/fabro-cli/tests/it/cmd/mcp.rs -
Modify:
lib/crates/fabro-cli/tests/it/cmd/mod.rs -
Step 1: Write failing CLI help tests
Add mod mcp; to lib/crates/fabro-cli/tests/it/cmd/mod.rs.
Create lib/crates/fabro-cli/tests/it/cmd/mcp.rs with snapshots for:
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, @"");
}
#[test]
fn start_help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["mcp", "start", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"");
}
#[test]
fn config_help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["mcp", "config", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"");
}
#[test]
fn init_help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["mcp", "init", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"");
}
- Step 2: Run the help tests and verify they fail
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::help cmd::mcp::start_help cmd::mcp::config_help cmd::mcp::init_help
Expected: FAIL because fabro mcp does not exist.
- Step 3: Add new crate, clap arguments, and no-op dispatch
Create lib/crates/fabro-mcp-server/Cargo.toml with the package name
fabro-mcp-server. Add direct rmcp dependency there:
rmcp = { workspace = true, features = ["server", "macros", "schemars", "transport-io"] }
Also add the Fabro crate dependencies needed for settings/auth, API calls,
manifest construction, and tests. In lib/crates/fabro-cli/Cargo.toml, add only
the path dependency:
fabro-mcp-server = { path = "../fabro-mcp-server" }
In lib/crates/fabro-cli/src/args.rs, add:
#[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,
}
Add Commands::Mcp(McpNamespace) with help text Model Context Protocol server.
In Commands::name():
Self::Mcp(ns) => match &ns.command {
McpCommand::Start(_) => "mcp start",
McpCommand::Config(_) => "mcp config",
McpCommand::Init(_) => "mcp init",
},
In commands/mod.rs, add pub(crate) mod mcp;.
Create commands/mcp/mod.rs:
use anyhow::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(())
}
}
}
Add small conversion helpers in commands/mcp/mod.rs that turn CLI arguments
and base_ctx.cwd() into fabro_mcp_server settings. These helpers must pass
plain owned values such as server URL override, storage-dir override, home dir,
and cwd; the new crate must not depend on fabro-cli::CommandContext.
Create lib/crates/fabro-mcp-server/src/lib.rs, config.rs, run_tools.rs,
and server.rs in this task. Use stub implementations that return Ok(()) or
placeholder JSON for config/init for now, except start(settings) can
anyhow::bail!("fabro mcp start is not implemented yet") until Task 3.
run_tools.rs can contain only a placeholder module comment until Task 3 adds
the first types/helpers.
In main.rs, dispatch:
Commands::Mcp(ns) => {
commands::mcp::dispatch(ns, &base_ctx).await?;
}
- Step 4: Run help tests and accept expected snapshots
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::help cmd::mcp::start_help cmd::mcp::config_help cmd::mcp::init_help
cargo insta pending-snapshots
cargo insta accept
cargo nextest run -p fabro-cli --test it cmd::mcp::help cmd::mcp::start_help cmd::mcp::config_help cmd::mcp::init_help
Expected: first run produces snapshots to inspect, final run PASS.
- Step 5: Refactor and verify
Run:
cargo +nightly-2026-04-14 fmt --all
cargo +nightly-2026-04-14 clippy -p fabro-cli --test it -- -D warnings
cargo +nightly-2026-04-14 clippy -p fabro-mcp-server --all-targets -- -D warnings
Expected: PASS.
- Step 6: Commit
git add lib/crates/fabro-mcp-server/Cargo.toml lib/crates/fabro-mcp-server/src/lib.rs lib/crates/fabro-mcp-server/src/config.rs lib/crates/fabro-mcp-server/src/run_tools.rs lib/crates/fabro-mcp-server/src/server.rs lib/crates/fabro-cli/Cargo.toml lib/crates/fabro-cli/src/args.rs lib/crates/fabro-cli/src/main.rs lib/crates/fabro-cli/src/commands/mod.rs lib/crates/fabro-cli/src/commands/mcp/mod.rs lib/crates/fabro-cli/tests/it/cmd/mod.rs lib/crates/fabro-cli/tests/it/cmd/mcp.rs
git commit -m "feat(cli): add mcp command surface"
Task 2: Implement fabro mcp config And fabro mcp init
Files:
-
Modify:
lib/crates/fabro-mcp-server/src/config.rs -
Modify:
lib/crates/fabro-mcp-server/src/lib.rs -
Modify:
lib/crates/fabro-cli/src/commands/mcp/mod.rs -
Modify:
lib/crates/fabro-cli/tests/it/cmd/mcp.rs -
Step 1: Write failing config/init tests
Add tests:
#[test]
fn config_prints_generic_mcp_json() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["mcp", "config"]);
fabro_snapshot!(context.filters(), cmd, @"");
}
#[test]
fn config_preserves_connection_flags() {
let context = test_context!();
let mut cmd = context.command();
cmd.args([
"mcp",
"config",
"--server",
"https://example.test/api/v1",
"--storage-dir",
"/tmp/fabro-mcp-storage",
]);
fabro_snapshot!(context.filters(), cmd, @"");
}
#[test]
fn init_cursor_writes_idempotent_config() {
let context = test_context!();
context
.command()
.args(["mcp", "init", "cursor"])
.assert()
.success();
context
.command()
.args(["mcp", "init", "cursor"])
.assert()
.success();
let config_path = context.home_dir.join(".cursor").join("mcp.json");
let config: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap();
fabro_json_snapshot!(context, config, @"");
}
#[test]
fn init_claude_writes_platform_config() {
let context = test_context!();
context
.command()
.args(["mcp", "init", "claude"])
.assert()
.success();
let config_path = expected_claude_config_path(&context.home_dir);
let config: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap();
fabro_json_snapshot!(context, config, @"");
}
#[test]
fn init_windsurf_writes_config() {
let context = test_context!();
context
.command()
.args(["mcp", "init", "windsurf"])
.assert()
.success();
let config_path = context
.home_dir
.join(".codeium")
.join("windsurf")
.join("mcp_config.json");
let config: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap();
fabro_json_snapshot!(context, config, @"");
}
#[test]
fn init_preserves_existing_servers() {
let context = test_context!();
let config_path = context.home_dir.join(".cursor").join("mcp.json");
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(
&config_path,
r#"{"mcpServers":{"other":{"command":"other","args":["serve"]}},"theme":"dark"}"#,
)
.unwrap();
context
.command()
.args(["mcp", "init", "cursor", "--server", "https://example.test/api/v1"])
.assert()
.success();
let config: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap();
fabro_json_snapshot!(context, config, @"");
}
#[test]
fn init_invalid_json_fails_without_overwrite() {
let context = test_context!();
let config_path = context.home_dir.join(".cursor").join("mcp.json");
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(&config_path, "{not json").unwrap();
let mut cmd = context.command();
cmd.args(["mcp", "init", "cursor"]);
fabro_snapshot!(context.filters(), cmd, @"");
assert_eq!(std::fs::read_to_string(config_path).unwrap(), "{not json");
}
Use fabro_json_snapshot where the parsed config is the contract. Add a small
expected_claude_config_path(home_dir: &Path) -> PathBuf helper in the test
module with the same platform branches as production so macOS, Linux, and
Windows path behavior is covered. Add the required import:
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
- Step 2: Run tests and verify they fail
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::config_prints_generic_mcp_json cmd::mcp::config_preserves_connection_flags cmd::mcp::init_cursor_writes_idempotent_config cmd::mcp::init_claude_writes_platform_config cmd::mcp::init_windsurf_writes_config cmd::mcp::init_preserves_existing_servers cmd::mcp::init_invalid_json_fails_without_overwrite
Expected: FAIL because config/init are stubs.
- Step 3: Implement config rendering
In lib/crates/fabro-mcp-server/src/config.rs, implement:
#![expect(
clippy::disallowed_methods,
reason = "MCP client config setup intentionally performs small synchronous JSON file reads/writes from a CLI command."
)]
use std::path::PathBuf;
use anyhow::{Context as _, Result, anyhow, bail};
use serde_json::{Map, Value, json};
const SERVER_NAME: &str = "fabro";
pub fn config_json(settings: McpConfigSettings) -> Result<String> {
serde_json::to_string_pretty(&generic_config(&settings))
.map(|json| format!("{json}\n"))
.context("failed to render Fabro MCP client config")
}
pub fn init_agent(settings: McpInitSettings) -> Result<()> {
let path = agent_config_path(settings.agent, &settings.home_dir)?;
let entry = server_entry(&settings.config);
merge_server_entry(&path, entry)?;
Ok(())
}
server_entry(...) must emit command fabro and args built by:
fn start_args(settings: &McpConfigSettings) -> Vec<String> {
let mut args = vec!["mcp".to_string(), "start".to_string()];
if let Some(server) = settings.server.as_ref() {
args.push("--server".to_string());
args.push(server.clone());
}
if let Some(storage_dir) = settings.storage_dir.as_deref() {
args.push("--storage-dir".to_string());
args.push(storage_dir.display().to_string());
}
args
}
Implement merge_server_entry(path, entry) so it:
- creates the parent directory
- reads existing JSON if the file exists
- rejects invalid JSON with context including the path
- rejects non-object roots and non-object
mcpServers - inserts/replaces only
mcpServers.fabro - writes pretty JSON plus trailing newline
Implement all three supported path mappings (claude, cursor, windsurf).
For claude, use dirs::home_dir() plus platform cfgs:
-
macOS:
Library/Application Support/Claude/claude_desktop_config.json -
Linux:
.config/Claude/claude_desktop_config.json -
Windows:
%APPDATA%\Claude\claude_desktop_config.json, falling back to~/AppData/Roaming/Claude/claude_desktop_config.jsonwhenAPPDATAis absent. The fallback is needed because integration tests run the compiled binary underfabro_test::apply_test_isolation, which clears ambientAPPDATA. -
Step 4: Run config/init tests and accept snapshots
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::config_prints_generic_mcp_json cmd::mcp::config_preserves_connection_flags cmd::mcp::init_cursor_writes_idempotent_config cmd::mcp::init_claude_writes_platform_config cmd::mcp::init_windsurf_writes_config cmd::mcp::init_preserves_existing_servers cmd::mcp::init_invalid_json_fails_without_overwrite
cargo insta pending-snapshots
cargo insta accept
cargo nextest run -p fabro-cli --test it cmd::mcp::config_prints_generic_mcp_json cmd::mcp::config_preserves_connection_flags cmd::mcp::init_cursor_writes_idempotent_config cmd::mcp::init_claude_writes_platform_config cmd::mcp::init_windsurf_writes_config cmd::mcp::init_preserves_existing_servers cmd::mcp::init_invalid_json_fails_without_overwrite
Expected: PASS.
- Step 5: Refactor and verify
Run:
cargo +nightly-2026-04-14 fmt --all
cargo +nightly-2026-04-14 clippy -p fabro-cli --test it -- -D warnings
cargo +nightly-2026-04-14 clippy -p fabro-mcp-server --all-targets -- -D warnings
Expected: PASS.
- Step 6: Commit
git add lib/crates/fabro-mcp-server/src/config.rs lib/crates/fabro-mcp-server/src/lib.rs lib/crates/fabro-cli/src/commands/mcp/mod.rs lib/crates/fabro-cli/tests/it/cmd/mcp.rs
git commit -m "feat(cli): configure fabro mcp clients"
Task 3: Add MCP Server Skeleton With Protocol Tests
Files:
-
Modify:
lib/crates/fabro-mcp-server/src/server.rs -
Modify:
lib/crates/fabro-mcp-server/src/run_tools.rs -
Modify:
lib/crates/fabro-mcp-server/src/lib.rs -
Modify:
lib/crates/fabro-cli/tests/it/cmd/mcp.rs -
Step 1: Write failing stdio protocol test
Add a test that uses the existing fabro_mcp::client::McpClient to spawn the compiled CLI:
#[tokio::test(flavor = "multi_thread")]
async fn stdio_server_initializes_and_lists_run_tools() {
let context = test_context!();
let config = fabro_mcp::config::McpServerSettings {
name: "fabro-under-test".to_string(),
transport: fabro_mcp::config::McpTransport::Stdio {
command: vec![
env!("CARGO_BIN_EXE_fabro").to_string(),
"mcp".to_string(),
"start".to_string(),
],
env: mcp_stdio_env(&context),
},
startup_timeout_secs: 10,
tool_timeout_secs: 30,
};
let client = fabro_mcp::client::McpClient::new(&config).unwrap();
client.initialize(config.startup_timeout()).await.unwrap();
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_search",
"fabro_run_interact",
"fabro_run_gather",
"fabro_run_events",
]
);
}
TestContext does not currently expose a reusable command env map. Add a narrow
test helper that constructs a deterministic child-process environment instead
of reading from ambient user HOME or trying to reverse a built
std::process::Command:
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("HOME".to_string(), context.home_dir.display().to_string());
env.insert("FABRO_HOME".to_string(), context.home_dir.join(".fabro").display().to_string());
env.insert("NO_COLOR".to_string(), "1".to_string());
McpStdioFixture {
command,
env,
current_dir: context.temp_dir.clone(),
}
}
If fabro_test::isolated_env does not exist, add a similarly narrow helper to
fabro_test that returns the same env map used by
fabro_test::apply_test_isolation. Use fixture.env.clone() for
fabro_mcp::config::McpServerSettings, and use the same fixture.env plus
fixture.current_dir when spawning raw subprocess tests; raw subprocess helpers
must call cmd.env_clear() before applying this map. The production crate
should expose equivalent explicit settings (McpServerSettings { server, storage_dir, home_dir, cwd }) so tests and CLI dispatch build settings from
owned values directly; do not depend on ambient process env in tests.
- Step 2: Run test and verify it fails
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::stdio_server_initializes_and_lists_run_tools
Expected: FAIL because fabro mcp start is not implemented.
- Step 3: Implement rmcp server skeleton
In lib/crates/fabro-mcp-server/src/server.rs, implement:
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use rmcp::{
ErrorData, ServerHandler, serve_server,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
};
use tokio::sync::OnceCell;
use fabro_client::Client;
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(())
}
Implement ServerHandler through the #[tool_handler] impl, not a separate
plain impl. rmcp::serve_server(...) returns after initialization with a
running service handle; fabro mcp start must await service.waiting() so the
stdio process stays alive for later tools/list and tools/call requests.
#[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.")
}
}
Add tool functions with temporary placeholder results:
#[tool_router]
impl FabroMcpServer {
pub(crate) fn new(settings: Arc<McpServerSettings>) -> Self { ... }
#[tool(name = "fabro_run_create", description = "...")]
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)),
}
}
}
Use this same handler shape for all five tools: first normalize and validate
the parameter object into a tool-specific Validated... type, then acquire the
lazy client only after validation succeeds, call the corresponding run_tools
function, return successful values with success_result(...), and convert
expected Fabro/API/validation failures with error_result(...).
new(...) should copy settings.cwd.clone() into the cwd field before
storing the settings, so tool calls resolve relative workflows against the MCP
process cwd captured at startup.
Each placeholder in run_tools.rs should still define the input structs,
validated parameter structs, and TryFrom<Params> validation hooks for all five
tools in this task. The run functions can return
Err(ToolError::message("not implemented")) until later tasks, except the
module must compile and tools must be listed. Add a small crate-local tool error
type:
#[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>;
Then add result helpers:
pub(crate) fn success_result<T: serde::Serialize>(
value: &T,
text: impl Into<String>,
) -> Result<rmcp::model::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 = rmcp::model::CallToolResult::structured(structured_content);
result.content = vec![rmcp::model::Content::text(text.into())];
Ok(result)
}
pub(crate) fn error_result(err: ToolError) -> rmcp::model::CallToolResult {
rmcp::model::CallToolResult::error(vec![rmcp::model::Content::text(
err.as_str().to_string(),
)])
}
Add one text helper per result type, for example create_runs_text(...), so
fallback content is concise: "created 1 Fabro run and started 1", not a full
JSON dump.
Implement client(&self) with lazy connection:
async fn client(&self) -> Result<Arc<Client>, run_tools::ToolError> {
self.client
.get_or_try_init(|| async { client_from_settings(&self.settings).await.map_err(run_tools::ToolError::from_anyhow) })
.await
.map(Arc::clone)
}
Make format_tool_error append auth guidance when fabro_util::exit::exit_class_for(&err) == Some(ExitClass::AuthRequired).
- Step 4: Run protocol test
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::stdio_server_initializes_and_lists_run_tools
Expected: PASS listing all five tools.
- Step 5: Add stdout-purity regression
Add a raw subprocess test that:
- spawns
fabro mcp start - sends a JSON-RPC initialize request on stdin
- reads the first stdout line
- asserts it parses as JSON and has
jsonrpc: "2.0" - asserts stderr may contain logs but stdout contains no leading human text
Use mcp_stdio_fixture(&context, &[]) for the raw subprocess helper so this
test and the McpServerSettings test use identical command, env, and cwd
values.
Use a child timeout and kill-on-drop cleanup. The raw JSON should be:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"fabro-test","version":"0.0.0"}}}
- Step 6: Run protocol checks
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::stdio_server_initializes_and_lists_run_tools cmd::mcp::stdio_start_writes_only_json_rpc_to_stdout
Expected: PASS.
- Step 7: Add startup/list-tools performance smoke
Add a lightweight smoke test that uses mcp_stdio_fixture to initialize the
server and call tools/list without a live Fabro server or auth. Assert the
combined initialize plus list-tools path completes within 2 seconds on the test
machine:
#[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));
}
This is a smoke check, not a benchmark. If CI variance makes 2 seconds too tight, keep the assertion but adjust the threshold in the implementation with a comment explaining the observed bound.
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::stdio_startup_and_list_tools_is_fast
Expected: PASS.
- Step 8: Refactor and verify
Run:
cargo +nightly-2026-04-14 fmt --all
cargo +nightly-2026-04-14 clippy -p fabro-cli --test it -- -D warnings
cargo +nightly-2026-04-14 clippy -p fabro-mcp-server --all-targets -- -D warnings
Expected: PASS.
- Step 9: Commit
git add lib/crates/fabro-mcp-server/src/server.rs lib/crates/fabro-mcp-server/src/run_tools.rs lib/crates/fabro-mcp-server/src/lib.rs lib/crates/fabro-cli/tests/it/cmd/mcp.rs
git commit -m "feat(cli): start fabro mcp stdio server"
Task 4: Implement Run Create/Search Tools
Files:
-
Modify:
lib/crates/fabro-mcp-server/src/run_tools.rs -
Modify:
lib/crates/fabro-cli/src/commands/run/overrides.rs -
Modify:
lib/crates/fabro-cli/tests/it/cmd/mcp.rs -
Step 1: Write failing create/search integration test
Add a test backed by an authenticated real Fabro server:
#[tokio::test(flavor = "multi_thread")]
async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
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 create = call_tool_json(&client, "fabro_run_create", serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-test" }
}]
})).await;
let run_id = create["runs"][0]["run_id"].as_str().unwrap().to_string();
assert_eq!(create["runs"][0]["started"], true);
let search = call_tool_json(&client, "fabro_run_search", serde_json::json!({
"run_ids": [run_id],
"labels": { "source": "mcp-test" },
"first": 10
})).await;
fabro_json_snapshot!(context, normalize_run_search(search), @"");
harness.shutdown().await;
}
Implement call_tool_json(...) so it asserts is_error != Some(true), extracts
structured_content, and verifies the first text content is present and does
not start with { or [; this makes the concise fallback contract automated
instead of a manual-only check.
If RealAuthHarness::start_with_dev_token cannot create runs due missing server settings for local execution, use TestContext managed server plus explicit seed_dev_token_auth against its server target, but keep the test proving persisted CLI auth is used.
- Step 2: Run test and verify it fails
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::mcp_create_and_search_manage_real_runs_with_cli_auth
Expected: FAIL because tools return not implemented.
- Step 3: Implement shared parameter and result types
In run_tools.rs, define public crate-visible structs for every tool input/output with:
#[derive(Debug, serde::Deserialize, rmcp::schemars::JsonSchema)]
pub(crate) struct ...
#[derive(Debug, serde::Serialize, rmcp::schemars::JsonSchema)]
pub(crate) struct ...
Use #[serde(default)] on optional map fields so omitted maps become empty maps where helpful.
- Step 4: Expose manifest override helpers
In commands/run/overrides.rs, make these helpers pub(crate) if needed:
parse_labelsmodel_from_argssandbox_layerexecution_layergoal_layer_from_args
If changing visibility creates awkward API, instead add one new crate-visible function:
pub(crate) fn manifest_overrides_from_parts(input: ManifestOverrideParts<'_>) -> Result<ManifestSettingsOverrides>
Prefer the single helper if more than three helpers would need visibility changes.
- Step 5: Implement
fabro_run_create
Implementation outline:
pub(crate) async fn create_runs(
client: Arc<Client>,
base_cwd: &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 run_id = spec.run_id.as_deref().map(str::parse).transpose().map_err(tool_err)?;
let overrides = build_mcp_manifest_overrides(&spec, &cwd)?;
let manifest_args = mcp_manifest_args(&spec);
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(&spec.workflow),
cwd,
run_overrides: overrides.run,
cli_overrides: overrides.cli,
input_overrides: overrides.input_overrides,
args: manifest_args,
run_id,
user_settings_path: Some(active_settings_path(None)),
})?;
let validation = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?;
reject_validation_errors(validation)?;
let run_id = client.create_run_from_manifest(built.manifest).await?;
let started = spec.start.unwrap_or(true);
if started {
client.start_run(&run_id, false).await?;
}
let summary = client.retrieve_run(&run_id).await?;
created.push(CreatedRunResult::from_summary(summary, started));
}
Ok(CreateRunsResult { runs: created })
}
Important: the function signature in server.rs should pass both the lazy API client and the MCP process cwd from the captured McpServerSettings, not call std::env::current_dir() deep in the tool. The raw FabroRunCreateParams must only appear at the MCP handler boundary; ValidatedCreateRuns::try_from(raw) must run before acquiring the client, and create_runs(...) must accept ValidatedCreateRuns.
The outline above omits some map_err(...) calls for readability; the real
implementation must convert every anyhow::Error and API error into
ToolError with the shared formatting helper so ? never tries to convert
anyhow::Error directly into ToolError.
Implement mcp_manifest_args(&CreateRunSpec) -> Option<fabro_api::types::ManifestArgs>
in run_tools.rs. It should mirror manifest_builder::run_manifest_args for
provenance:
fn mcp_manifest_args(spec: &CreateRunSpec) -> Option<types::ManifestArgs> {
let label = spec
.labels
.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),
dry_run: spec.dry_run.filter(|value| *value),
label,
model: spec.model.clone(),
preserve_sandbox: spec.preserve_sandbox.filter(|value| *value),
provider: spec.provider.clone(),
sandbox: spec.sandbox.clone(),
docker_image: None,
input,
verbose: None,
};
(!mcp_manifest_args_is_empty(&payload)).then_some(payload)
}
Keep the emptiness check local if manifest_args_is_empty is not accessible.
The input strings are only for provenance; authoritative input values come
from input_overrides after JSON-to-TOML conversion.
- Step 6: Implement JSON-to-TOML input conversion
Add unit tests in run_tools.rs for:
- strings
- bools
- integers
- floats
- arrays
- objects
- null rejected with key name
Run:
cargo nextest run -p fabro-mcp-server run_tools
Expected: PASS after implementation.
- Step 7: Implement
fabro_run_search
Use existing server_runs::ServerRunSummaryInfo where useful, but avoid adding public API only for tests. Search should:
- fetch
client.list_store_runs().await - sort newest first using created/start timestamp and run id as tie-breaker
- apply filters
- page with
firstandafter - return
SearchRunsResult { runs, next_cursor }
Do not drop archived runs by default. archived: Some(false) should exclude them.
- Step 8: Run create/search tests
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::mcp_create_and_search_manage_real_runs_with_cli_auth
Expected: PASS.
- Step 9: Refactor and verify
Run:
cargo +nightly-2026-04-14 fmt --all
cargo +nightly-2026-04-14 clippy -p fabro-cli --test it -- -D warnings
cargo +nightly-2026-04-14 clippy -p fabro-mcp-server --all-targets -- -D warnings
Expected: PASS.
- Step 10: Commit
git add lib/crates/fabro-mcp-server/src/run_tools.rs lib/crates/fabro-cli/src/commands/run/overrides.rs lib/crates/fabro-cli/tests/it/cmd/mcp.rs
git commit -m "feat(cli): add mcp run create and search tools"
Task 5: Implement Interact/Gather/Events Tools
Files:
-
Modify:
lib/crates/fabro-mcp-server/src/run_tools.rs -
Modify:
lib/crates/fabro-cli/tests/it/cmd/mcp.rs -
Step 1: Write failing lifecycle interaction test
Add a test that:
- creates a dry-run auto-approved run with
fabro_run_create - calls
fabro_run_gatherwith the run id - calls
fabro_run_interactactionget - calls
fabro_run_eventsactionlist - calls
fabro_run_interactactionarchive - calls
fabro_run_interactactionunarchive - verifies server-visible state through API or a follow-up
fabro_run_search
Snapshot a normalized object:
fabro_json_snapshot!(
context,
serde_json::json!({
"gather": normalize_gather(gather),
"get_status": get["result"]["summary"]["status"],
"events_nonempty": events["events"].as_array().unwrap().is_empty() == false,
"archive_action": archive["action"],
"unarchive_action": unarchive["action"],
}),
@""
);
- Step 2: Write failing validation/error tests
Add tests for:
-
fabro_run_gatherrejects more than 50 run ids without requiring auth or a reachable server. Startfabro mcp start --server http://127.0.0.1:9with no auth entry, call the tool, assert the error mentionsrun_ids, then calltools/listagain to prove the server stayed alive. -
fabro_run_gatherreturnstimed_out: truewhen the timeout expires before all requested runs are terminal. Use a real authenticated test server, create or select a non-terminal run, call gather withtimeout_seconds: 1andpoll_interval_seconds: 5, assert elapsed wall time is bounded, and verify the returned run summary is the current state rather than a process/tool failure. -
fabro_run_interactactionmessagewithoutmessagereturns an MCP tool error and the server remains alive for a subsequentfabro_run_search. -
Missing auth against a protected remote target returns a tool error containing
Run \fabro auth login` to authenticate.` -
Step 3: Run tests and verify they fail
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::mcp_lifecycle_tools_manage_real_run cmd::mcp::mcp_gather_rejects_too_many_runs cmd::mcp::mcp_gather_returns_timeout_result cmd::mcp::mcp_interact_error_does_not_stop_server cmd::mcp::mcp_tool_auth_error_mentions_login
Expected: FAIL because tools are incomplete.
- Step 4: Implement
fabro_run_interact
Implement one small function per action:
async fn interact_get(client: &Client, run_id: &RunId) -> Result<Value>
async fn interact_start(client: &Client, run_id: &RunId) -> Result<Value>
async fn interact_message(client: &Client, run_id: &RunId, message: Option<String>, interrupt: bool) -> Result<Value>
async fn interact_cancel(client: &Client, run_id: &RunId) -> Result<Value>
async fn interact_archive(client: &Client, run_id: &RunId) -> Result<Value>
async fn interact_unarchive(client: &Client, run_id: &RunId) -> Result<Value>
async fn interact_get_questions(client: &Client, run_id: &RunId) -> Result<Value>
async fn interact_answer(client: &Client, run_id: &RunId, question_id: Option<String>, answer: Option<Value>) -> Result<Value>
Resolve selectors once:
let run_id = client.resolve_run(¶ms.run_id).await?.id;
Use serde_json::to_value(...) for API objects rather than manually copying complex projection/question structures.
- Step 5: Implement answer mapping tests and helper
Add unit tests for:
assert_answer_json(json!(true), json!({"kind": "yes"}));
assert_answer_json(json!(false), json!({"kind": "no"}));
assert_answer_json(json!("hello"), json!({"kind": "text", "text": "hello"}));
assert_answer_json(json!({"option":"a"}), json!({"kind": "selected", "option_key": "a"}));
assert_answer_json(
json!({"options":["a","b"]}),
json!({"kind": "multi_selected", "option_keys": ["a", "b"]}),
);
assert_answer_json(json!({"text":"hello"}), json!({"kind": "text", "text": "hello"}));
Build the generated fabro_api::types::SubmitAnswerRequest through the
documented wire JSON shape and then serialize it back in tests:
fn answer_to_submit_request(answer: serde_json::Value) -> ToolResult<types::SubmitAnswerRequest> {
let payload = match answer {
serde_json::Value::Bool(true) => serde_json::json!({ "kind": "yes" }),
serde_json::Value::Bool(false) => serde_json::json!({ "kind": "no" }),
serde_json::Value::String(text) => serde_json::json!({ "kind": "text", "text": text }),
serde_json::Value::Object(mut object) => {
if let Some(option) = object.remove("option") {
serde_json::json!({ "kind": "selected", "option_key": option })
} else if let Some(options) = object.remove("options") {
serde_json::json!({ "kind": "multi_selected", "option_keys": options })
} else if let Some(text) = object.remove("text") {
serde_json::json!({ "kind": "text", "text": text })
} else {
return Err(ToolError::message(
"answer object must contain one of: option, options, text",
));
}
}
other => {
return Err(ToolError::message(format!(
"unsupported answer value: {other}; expected boolean, string, or object",
)));
}
};
serde_json::from_value(payload).map_err(|err| {
ToolError::message(format!("failed to build submit-answer request: {err}"))
})
}
This matches the current API contract proven by
lib/crates/fabro-api/tests/submit_answer_request_round_trip.rs, which uses
kind: yes, kind: no, kind: selected, kind: multi_selected, and
kind: text. Do not introduce references to non-existent generated names such
as SubmitAnswerRequestKind.
- Step 6: Implement
fabro_run_gather
Validation converts raw FabroRunGatherParams into ValidatedGatherRuns
before client acquisition:
validate_len("run_ids", params.run_ids.len(), 1, 50)?;
let timeout = params.timeout_seconds.unwrap_or(300).min(600);
let poll = params.poll_interval_seconds.unwrap_or(15).max(5);
Implementation:
-
resolve all selectors at the start
-
poll summaries until every
summary.lifecycle.status.is_terminal()or deadline -
if the deadline expires, return
timed_out: true, current run summaries, andelapsed_secondsas a successful structured tool result -
only return a tool error for selector/API failures, not for ordinary timeout expiry
-
Step 7: Implement
fabro_run_events
Fetch events using:
let events = client
.list_run_events(&run_id, params.after, effective_limit_for_fetch(params))
.await?;
Then apply filters in memory:
- event ids
- event types using
event.event.event_name() - categories
- created_after/before
- query substring on serialized event JSON
- offset
- limit/first
- direction
- max_content_length truncation
Output:
struct RunEventsResult {
run_id: String,
action: RunEventsAction,
events: Vec<RunEventResult>,
next_cursor: Option<u32>,
}
next_cursor is last returned sequence plus one when at least one event was returned.
- Step 8: Run lifecycle and error tests
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp::mcp_lifecycle_tools_manage_real_run cmd::mcp::mcp_gather_rejects_too_many_runs cmd::mcp::mcp_gather_returns_timeout_result cmd::mcp::mcp_interact_error_does_not_stop_server cmd::mcp::mcp_tool_auth_error_mentions_login
Expected: PASS.
- Step 9: Refactor and verify
Run:
cargo +nightly-2026-04-14 fmt --all
cargo +nightly-2026-04-14 clippy -p fabro-cli --test it -- -D warnings
cargo +nightly-2026-04-14 clippy -p fabro-mcp-server --all-targets -- -D warnings
Expected: PASS.
- Step 10: Commit
git add lib/crates/fabro-mcp-server/src/run_tools.rs lib/crates/fabro-cli/tests/it/cmd/mcp.rs
git commit -m "feat(cli): add mcp run control tools"
Task 6: Final Contract Coverage And Workspace Verification
Files:
-
Modify as needed from prior tasks only.
-
Step 1: Run all MCP command tests
Run:
cargo nextest run -p fabro-cli --test it cmd::mcp
Expected: PASS.
- Step 2: Run existing relevant MCP client tests
Run:
cargo nextest run -p fabro-mcp
Expected: PASS. This confirms the existing external MCP client crate was not regressed by dependency feature unification.
- Step 3: Run relevant existing CLI run/auth tests
Run:
cargo nextest run -p fabro-cli --test it scenario::auth::auth_login_refresh_logout_flow scenario::lifecycle::dry_run_create_start_attach_works_with_default_run_lookup cmd::ps::ps_explicit_local_tcp_target_uses_auth_store
Expected: PASS. If exact test names drift, use cargo nextest list -p fabro-cli --test it | rg 'auth_login_refresh_logout_flow|dry_run_create_start_attach|explicit_local_tcp' and run the matching tests.
- Step 4: Run formatting and linting
Run:
cargo +nightly-2026-04-14 fmt --check --all
cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
Expected: PASS.
- Step 5: Run broader regression suite
Run:
ulimit -n 4096 && cargo nextest run --workspace
Expected: PASS.
- Step 6: Inspect snapshots before accepting any remaining changes
Run:
cargo insta pending-snapshots
Expected: no pending snapshots. If pending snapshots exist, inspect them before accepting. Only accept snapshots caused by this feature.
- Step 7: Final code review pass
Check manually:
-
fabro mcp starthas noprintout!,println!,eprintln!is only for stderr and not in server steady-state startup. -
all MCP tool argument validation returns tool errors, not process exits.
-
successful MCP tools include both
structuredContentand short text content; text content is not just serialized JSON. -
no tests write run internals directly.
-
no live provider credentials are required.
-
agent config merge preserves unrelated keys.
-
auth failures include login guidance.
-
Step 8: Commit final fixes if any
git status --short
git add <changed-files>
git commit -m "test(cli): cover fabro mcp server contract"
Only make this commit if Task 6 produced additional fixes or tests not already committed.