From 73ebb7d28bc66b3b9dfb894274fe46ba97ea9948 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sun, 17 May 2026 09:26:18 -0700 Subject: [PATCH] feat(mcp): support run parent relationships (#295) ## Summary - Add parent metadata (`parent_id`, `children_count`) to Fabro MCP run summaries, search summaries, and created-run results. - Allow MCP clients to create child runs, search direct children, and link or unlink an existing run's parent through the existing run tools. - Update MCP docs and tool descriptions for the parent-aware create/search/interact behavior. ## Test Plan - [x] `cargo +nightly-2026-04-14 fmt --check --all` - [x] `cargo nextest run -p fabro-mcp-server` - [x] `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` --------- Co-authored-by: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + docs/public/agents/mcp.mdx | 8 +- lib/crates/fabro-cli/tests/it/cmd/mcp.rs | 4 + lib/crates/fabro-mcp-server/Cargo.toml | 4 + .../fabro-mcp-server/src/run_tools/common.rs | 67 ++++++ .../fabro-mcp-server/src/run_tools/create.rs | 190 +++++++++++++++++- .../src/run_tools/interact.rs | 89 ++++++++ .../src/run_tools/manifest.rs | 1 + .../fabro-mcp-server/src/run_tools/search.rs | 91 ++++++++- lib/crates/fabro-mcp-server/src/server.rs | 6 +- 10 files changed, 448 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99100c271..8f3273b3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2120,10 +2120,12 @@ dependencies = [ "fabro-types", "fabro-util", "futures", + "httpmock", "rmcp", "schemars 1.2.1", "serde", "serde_json", + "tempfile", "tokio", "toml 0.8.23", ] diff --git a/docs/public/agents/mcp.mdx b/docs/public/agents/mcp.mdx index 06a5ce4da..a3bf91216 100644 --- a/docs/public/agents/mcp.mdx +++ b/docs/public/agents/mcp.mdx @@ -28,12 +28,14 @@ Pass `--server` when the MCP client should connect to a specific Fabro server, o | Tool | Purpose | |---|---| -| `fabro_run_create` | Create one or more workflow runs, starting them by default. | -| `fabro_run_search` | Search runs by ID, workflow, labels, status, archive state, and creation time. | -| `fabro_run_interact` | Get, start, message, cancel, archive, unarchive, inspect questions, or answer a run. | +| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. | +| `fabro_run_search` | Search runs by ID, parent, workflow, labels, status, archive state, and creation time. | +| `fabro_run_interact` | Get, start, message, cancel, archive, unarchive, link or unlink a parent, inspect questions, or answer a run. | | `fabro_run_gather` | Wait for runs to reach terminal states, returning current state on timeout. | | `fabro_run_events` | List, inspect, or search stored events for a run. | +Run summaries returned by the MCP server include parent metadata. Use `parent_id` on `fabro_run_create` to create a child run, `parent_id` on `fabro_run_search` to list direct children, and the `link_parent` or `unlink_parent` actions on `fabro_run_interact` to change an existing run's parent. + ## Fabro agents as MCP clients When an agent session starts with MCP servers configured, Fabro: diff --git a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs index 332af03e8..4681b9bcc 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs @@ -556,6 +556,8 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() { "runs": [ { "run_id": "[RUN_ID]", + "parent_id": null, + "children_count": 0, "workflow_name": "Simple", "workflow_slug": "simple", "status": "queued", @@ -1160,6 +1162,8 @@ async fn mcp_lifecycle_tools_manage_real_run() { "runs": [ { "run_id": "[RUN_ID]", + "parent_id": null, + "children_count": 0, "workflow_name": "Simple", "workflow_slug": "simple", "status": "failed", diff --git a/lib/crates/fabro-mcp-server/Cargo.toml b/lib/crates/fabro-mcp-server/Cargo.toml index 2aad3c067..8a7f57f22 100644 --- a/lib/crates/fabro-mcp-server/Cargo.toml +++ b/lib/crates/fabro-mcp-server/Cargo.toml @@ -30,3 +30,7 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true toml.workspace = true + +[dev-dependencies] +httpmock = "0.8" +tempfile = "3" diff --git a/lib/crates/fabro-mcp-server/src/run_tools/common.rs b/lib/crates/fabro-mcp-server/src/run_tools/common.rs index d708ed3ad..118b3efa6 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/common.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/common.rs @@ -34,6 +34,8 @@ pub(super) type ToolResult = Result; #[derive(Debug, Serialize, JsonSchema)] pub(crate) struct RunSummaryResult { pub(crate) run_id: String, + pub(crate) parent_id: Option, + pub(crate) children_count: u64, pub(crate) workflow_name: String, pub(crate) workflow_slug: Option, pub(crate) status: String, @@ -90,6 +92,8 @@ pub(super) async fn retrieve_run(client: &Client, run_id: &RunId) -> ToolResult< pub(super) fn run_summary_result(run: &Run) -> RunSummaryResult { RunSummaryResult { run_id: run.id.to_string(), + parent_id: run.parent_id.map(|parent_id| parent_id.to_string()), + children_count: run.children_count, workflow_name: run.workflow.name.clone(), workflow_slug: run.workflow.slug.clone(), status: run_status_kind(run.lifecycle.status).to_string(), @@ -139,3 +143,66 @@ fn format_tool_error(err: &anyhow::Error) -> String { } rendered } + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunTimestamps, WorkflowRef}; + + use super::*; + + #[test] + fn run_summary_result_includes_parent_metadata() { + let parent_id = run_id("01KRBZW4DW0000000000000002"); + let run = Run { + id: run_id("01KRBZW5C00000000000000001"), + parent_id: Some(parent_id), + children_count: 3, + title: "test".to_string(), + goal: "test".to_string(), + workflow: WorkflowRef { + slug: Some("simple".to_string()), + name: "Simple".to_string(), + }, + automation: None, + repository: None, + created_by: None, + origin: RunOrigin::default(), + labels: HashMap::new(), + lifecycle: RunLifecycle { + status: RunStatus::Submitted, + pending_control: None, + queue_position: None, + error: None, + archived: false, + archived_at: None, + }, + sandbox: None, + models: Vec::new(), + source_directory: None, + timestamps: RunTimestamps { + created_at: Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, 0).unwrap(), + started_at: None, + last_event_at: None, + completed_at: None, + duration_ms: None, + elapsed_secs: None, + }, + billing: None, + diff: None, + pull_request: None, + current_question: None, + superseded_by: None, + links: RunLinks { web: None }, + }; + + let summary = run_summary_result(&run); + + assert_eq!(summary.parent_id, Some(parent_id.to_string())); + assert_eq!(summary.children_count, 3); + } + + fn run_id(raw: &str) -> RunId { + raw.parse().expect("test run id should parse") + } +} diff --git a/lib/crates/fabro-mcp-server/src/run_tools/create.rs b/lib/crates/fabro-mcp-server/src/run_tools/create.rs index 56745ff43..77d67615e 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/create.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/create.rs @@ -22,6 +22,7 @@ pub(crate) struct CreateRunSpec { pub(crate) workflow: String, pub(crate) cwd: Option, pub(crate) run_id: Option, + pub(crate) parent_id: Option, pub(crate) goal: Option, #[serde(default)] pub(crate) inputs: HashMap, @@ -84,6 +85,7 @@ pub(crate) struct ValidatedCreateRunSpec { pub(crate) workflow: String, pub(crate) cwd: Option, pub(crate) run_id: Option, + pub(crate) parent_id: Option, pub(crate) goal: Option, pub(crate) inputs: HashMap, pub(crate) labels: HashMap, @@ -122,6 +124,15 @@ impl TryFrom for ValidatedCreateRunSpec { .map_err(|err| { ToolError::message(format!("run_id must be a valid Fabro run id: {err}")) })?; + let parent_id = spec + .parent_id + .as_deref() + .map(str::trim) + .filter(|parent_id| !parent_id.is_empty()) + .map(ToOwned::to_owned); + if spec.parent_id.is_some() && parent_id.is_none() { + return Err(ToolError::message("parent_id must not be blank")); + } let inputs = spec .inputs .into_iter() @@ -134,6 +145,7 @@ impl TryFrom for ValidatedCreateRunSpec { workflow: spec.workflow, cwd: spec.cwd, run_id, + parent_id, goal: spec.goal, inputs, labels: spec.labels, @@ -155,10 +167,12 @@ pub(crate) struct CreateRunsResult { #[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, + pub(crate) run_id: String, + pub(crate) parent_id: Option, + pub(crate) children_count: u64, + pub(crate) workflow: String, + pub(crate) started: bool, + pub(crate) status: String, } pub(crate) async fn create_runs( @@ -170,7 +184,15 @@ pub(crate) async fn create_runs( let mut created = Vec::with_capacity(params.runs.len()); for spec in params.runs { let cwd = spec.cwd.clone().unwrap_or_else(|| base_cwd.to_path_buf()); - let manifest = manifest::build_mcp_run_manifest(&spec, &cwd, user_settings_path)?; + let mut manifest = manifest::build_mcp_run_manifest(&spec, &cwd, user_settings_path)?; + if let Some(parent_selector) = spec.parent_id.as_deref() { + let parent_id = client + .resolve_run(parent_selector) + .await + .map_err(|err| ToolError::from_anyhow(&err))? + .id; + manifest.parent_id = Some(parent_id.to_string()); + } let run_id = client .create_run_from_manifest(manifest) .await @@ -189,6 +211,8 @@ pub(crate) async fn create_runs( }; created.push(CreatedRunResult { run_id: summary.id.to_string(), + parent_id: summary.parent_id.map(|parent_id| parent_id.to_string()), + children_count: summary.children_count, workflow: spec.workflow, started, status: common::run_status_kind(summary.lifecycle.status).to_string(), @@ -209,6 +233,7 @@ pub(crate) fn create_runs_text(result: &CreateRunsResult) -> String { mod tests { use schemars::SchemaGenerator; use serde_json::json; + use tokio::fs; use super::*; @@ -228,4 +253,159 @@ mod tests { ]) ); } + + #[test] + fn create_spec_accepts_parent_selector() { + let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec { + workflow: "simple.fabro".to_string(), + cwd: None, + run_id: None, + parent_id: Some(" nightly-parent ".to_string()), + goal: None, + inputs: HashMap::new(), + labels: HashMap::new(), + dry_run: None, + auto_approve: None, + model: None, + provider: None, + sandbox: None, + preserve_sandbox: None, + start: None, + }) + .expect("parent selectors should validate without requiring exact run ids"); + + assert_eq!(spec.parent_id.as_deref(), Some("nightly-parent")); + } + + #[tokio::test] + async fn create_runs_resolves_parent_selector_and_sends_parent_id_in_manifest() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let workflow = temp.path().join("simple.fabro"); + fs::write( + &workflow, + r#"digraph Simple { + graph [goal="Run tests and report results"] + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + start -> exit +} +"#, + ) + .await + .expect("workflow should be written"); + let settings = temp.path().join("settings.toml"); + fs::write(&settings, "") + .await + .expect("settings should be written"); + + let server = httpmock::MockServer::start(); + let child_id = run_id("01KRBZW5C00000000000000001"); + let parent_id = run_id("01KRBZW4DW0000000000000002"); + let resolve_parent = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/runs/resolve") + .query_param("selector", "nightly-parent"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(run_summary_json(parent_id, None, 1)); + }); + let create = server.mock(|when, then| { + when.method("POST") + .path("/api/v1/runs") + .json_body_includes(format!(r#"{{"parent_id":"{parent_id}"}}"#)); + then.status(201) + .header("Content-Type", "application/json") + .json_body(run_summary_json(child_id, Some(parent_id), 0)); + }); + let retrieve = server.mock(|when, then| { + when.method("GET").path(format!("/api/v1/runs/{child_id}")); + then.status(200) + .header("Content-Type", "application/json") + .json_body(run_summary_json(child_id, Some(parent_id), 0)); + }); + let client = + Arc::new(Client::new_no_proxy(&server.base_url()).expect("client should build")); + let params = ValidatedCreateRuns::try_from(FabroRunCreateParams { + runs: vec![CreateRunSpec { + workflow: workflow.display().to_string(), + cwd: None, + run_id: None, + parent_id: Some("nightly-parent".to_string()), + goal: None, + inputs: HashMap::new(), + labels: HashMap::new(), + dry_run: Some(true), + auto_approve: Some(true), + model: None, + provider: None, + sandbox: None, + preserve_sandbox: None, + start: Some(false), + }], + }) + .expect("create params should validate"); + + let result = create_runs(client, temp.path(), &settings, params) + .await + .expect("run should be created"); + + assert_eq!(result.runs[0].parent_id, Some(parent_id.to_string())); + assert_eq!(result.runs[0].children_count, 0); + resolve_parent.assert(); + create.assert(); + retrieve.assert(); + } + + fn run_id(raw: &str) -> RunId { + raw.parse().expect("test run id should parse") + } + + fn run_summary_json( + run_id: RunId, + parent_id: Option, + children_count: u64, + ) -> serde_json::Value { + json!({ + "id": run_id, + "parent_id": parent_id, + "children_count": children_count, + "title": "Test run", + "goal": "Test run", + "workflow": { + "slug": "simple", + "name": "Simple" + }, + "repository": null, + "origin": { + "kind": "api" + }, + "labels": {}, + "lifecycle": { + "status": { "kind": "submitted" }, + "pending_control": null, + "queue_position": null, + "error": null, + "archived": false, + "archived_at": null + }, + "models": [], + "source_directory": "/srv/repo", + "timestamps": { + "created_at": "2026-04-05T12:00:00Z", + "started_at": null, + "last_event_at": null, + "completed_at": null, + "duration_ms": null, + "elapsed_secs": null + }, + "billing": null, + "diff": null, + "pull_request": null, + "current_question": null, + "superseded_by": null, + "links": { + "web": null + } + }) + } } diff --git a/lib/crates/fabro-mcp-server/src/run_tools/interact.rs b/lib/crates/fabro-mcp-server/src/run_tools/interact.rs index 28542abd2..1f51cb5b1 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/interact.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/interact.rs @@ -25,6 +25,8 @@ pub(crate) enum RunInteractAction { Cancel, Archive, Unarchive, + LinkParent, + UnlinkParent, GetQuestions, Answer, } @@ -33,6 +35,7 @@ pub(crate) enum RunInteractAction { pub(crate) struct FabroRunInteractParams { pub(crate) action: RunInteractAction, pub(crate) run_id: String, + pub(crate) parent_id: Option, pub(crate) message: Option, pub(crate) interrupt: Option, pub(crate) question_id: Option, @@ -120,6 +123,10 @@ pub(crate) enum ValidatedInteractAction { Cancel, Archive, Unarchive, + LinkParent { + parent_id: String, + }, + UnlinkParent, GetQuestions, Answer { question_id: String, @@ -137,6 +144,8 @@ impl ValidatedInteractAction { Self::Cancel => RunInteractAction::Cancel, Self::Archive => RunInteractAction::Archive, Self::Unarchive => RunInteractAction::Unarchive, + Self::LinkParent { .. } => RunInteractAction::LinkParent, + Self::UnlinkParent => RunInteractAction::UnlinkParent, Self::GetQuestions => RunInteractAction::GetQuestions, Self::Answer { .. } => RunInteractAction::Answer, } @@ -171,6 +180,22 @@ impl TryFrom for ValidatedInteractRun { RunInteractAction::Cancel => ValidatedInteractAction::Cancel, RunInteractAction::Archive => ValidatedInteractAction::Archive, RunInteractAction::Unarchive => ValidatedInteractAction::Unarchive, + RunInteractAction::LinkParent => { + let Some(parent_id) = params + .parent_id + .as_deref() + .map(str::trim) + .filter(|parent_id| !parent_id.is_empty()) + else { + return Err(ToolError::message( + "parent_id is required for action link_parent", + )); + }; + ValidatedInteractAction::LinkParent { + parent_id: parent_id.to_string(), + } + } + RunInteractAction::UnlinkParent => ValidatedInteractAction::UnlinkParent, RunInteractAction::GetQuestions => ValidatedInteractAction::GetQuestions, RunInteractAction::Answer => { let Some(question_id) = params @@ -260,6 +285,25 @@ pub(crate) async fn interact_run( .map_err(|err| ToolError::from_anyhow(&err))?; json!({ "summary": common::run_summary_result(&summary) }) } + ValidatedInteractAction::LinkParent { parent_id } => { + let parent_id = client + .resolve_run(&parent_id) + .await + .map_err(|err| ToolError::from_anyhow(&err))? + .id; + let summary = client + .link_run_parent(&run_id, &parent_id) + .await + .map_err(|err| ToolError::from_anyhow(&err))?; + json!({ "summary": common::run_summary_result(&summary) }) + } + ValidatedInteractAction::UnlinkParent => { + let summary = client + .unlink_run_parent(&run_id) + .await + .map_err(|err| ToolError::from_anyhow(&err))?; + json!({ "summary": common::run_summary_result(&summary) }) + } ValidatedInteractAction::GetQuestions => { let questions = client .list_run_questions(&run_id) @@ -402,6 +446,7 @@ mod tests { let err = ValidatedInteractRun::try_from(FabroRunInteractParams { action: RunInteractAction::Answer, run_id: "run_123".to_string(), + parent_id: None, message: None, interrupt: None, question_id: Some("question-1".to_string()), @@ -412,11 +457,55 @@ mod tests { assert!(err.as_str().contains("option, options, text")); } + #[test] + fn interact_link_parent_validation_rejects_missing_or_blank_parent_id() { + for parent_id in [None, Some(" ".to_string())] { + let err = ValidatedInteractRun::try_from(FabroRunInteractParams { + action: RunInteractAction::LinkParent, + run_id: "child-run".to_string(), + parent_id, + message: None, + interrupt: None, + question_id: None, + answer: None, + }) + .unwrap_err(); + + assert!( + err.as_str() + .contains("parent_id is required for action link_parent"), + "{}", + err.as_str() + ); + } + } + + #[test] + fn interact_unlink_parent_validation_does_not_require_parent_id() { + let validated = ValidatedInteractRun::try_from(FabroRunInteractParams { + action: RunInteractAction::UnlinkParent, + run_id: " child-run ".to_string(), + parent_id: None, + message: None, + interrupt: None, + question_id: None, + answer: None, + }) + .expect("unlink_parent should not require parent_id"); + + assert_eq!(validated.run_id, "child-run"); + assert!(matches!( + validated.action, + ValidatedInteractAction::UnlinkParent + )); + } + #[test] fn interrupt_action_requires_only_run_id() { let validated = ValidatedInteractRun::try_from(FabroRunInteractParams { action: RunInteractAction::Interrupt, run_id: "run_123".to_string(), + parent_id: None, message: None, interrupt: None, question_id: None, diff --git a/lib/crates/fabro-mcp-server/src/run_tools/manifest.rs b/lib/crates/fabro-mcp-server/src/run_tools/manifest.rs index 711e39ce4..576320694 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/manifest.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/manifest.rs @@ -164,6 +164,7 @@ mod tests { let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec { workflow: "simple".to_string(), run_id: None, + parent_id: None, cwd: None, goal: None, inputs: HashMap::from([ diff --git a/lib/crates/fabro-mcp-server/src/run_tools/search.rs b/lib/crates/fabro-mcp-server/src/run_tools/search.rs index 7b9ef9407..dade2f943 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/search.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/search.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_client::Client; -use fabro_types::{Run, RunStatusKind}; +use fabro_types::{Run, RunId, RunStatusKind}; use futures::future::try_join_all; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -23,6 +23,7 @@ pub(crate) struct FabroRunSearchParams { pub(crate) created_before: Option, pub(crate) first: Option, pub(crate) after: Option, + pub(crate) parent_id: Option, } #[derive(Debug)] @@ -34,7 +35,7 @@ pub(crate) struct ValidatedSearchRuns { impl TryFrom for ValidatedSearchRuns { type Error = ToolError; - fn try_from(params: FabroRunSearchParams) -> Result { + fn try_from(mut params: FabroRunSearchParams) -> Result { if params.first.is_some_and(|first| first > 100) { return Err(ToolError::message("first must be <= 100")); } @@ -61,6 +62,13 @@ impl TryFrom for ValidatedSearchRuns { if let Some(created_before) = params.created_before.as_deref() { common::parse_datetime_filter("created_before", created_before)?; } + if let Some(parent_id) = params.parent_id.take() { + let parent_id = parent_id.trim().to_string(); + if parent_id.is_empty() { + return Err(ToolError::message("parent_id must not be blank")); + } + params.parent_id = Some(parent_id); + } Ok(Self { raw: params, status, @@ -77,6 +85,8 @@ pub(crate) struct SearchRunsResult { #[derive(Debug, Serialize, JsonSchema)] pub(crate) struct SearchRunSummaryResult { pub(crate) run_id: String, + pub(crate) parent_id: Option, + pub(crate) children_count: u64, pub(crate) workflow_name: String, pub(crate) workflow_slug: Option, pub(crate) status: String, @@ -97,15 +107,31 @@ pub(crate) async fn search_runs( ) -> ToolResult { let status = params.status; let raw = params.raw; + let parent_id = if let Some(parent_selector) = raw.parent_id.as_deref() { + Some( + client + .resolve_run(parent_selector) + .await + .map_err(|err| ToolError::from_anyhow(&err))? + .id, + ) + } else { + None + }; let runs = if let Some(run_ids) = raw.run_ids.as_ref() { resolve_requested_runs(&client, run_ids).await? + } else if let Some(parent_id) = parent_id { + client + .list_store_runs_by_parent(parent_id) + .await + .map_err(|err| ToolError::from_anyhow(&err))? } else { client .list_store_runs() .await .map_err(|err| ToolError::from_anyhow(&err))? }; - let page = filter_sort_and_page_runs(runs, &raw, status.as_deref())?; + let page = filter_sort_and_page_runs(runs, &raw, status.as_deref(), parent_id)?; Ok(SearchRunsResult { runs: page.runs.iter().map(search_run_summary_result).collect(), @@ -116,6 +142,8 @@ pub(crate) async fn search_runs( fn search_run_summary_result(run: &Run) -> SearchRunSummaryResult { let RunSummaryResult { run_id, + parent_id, + children_count, workflow_name, workflow_slug, status, @@ -132,6 +160,8 @@ fn search_run_summary_result(run: &Run) -> SearchRunSummaryResult { SearchRunSummaryResult { run_id, + parent_id, + children_count, workflow_name, workflow_slug, status, @@ -169,7 +199,11 @@ fn filter_sort_and_page_runs( mut runs: Vec, raw: &FabroRunSearchParams, status: Option<&[RunStatusKind]>, + parent_id: Option, ) -> ToolResult { + if let Some(parent_id) = parent_id { + runs.retain(|run| run.parent_id == Some(parent_id)); + } if let Some(workflow) = raw.workflow.as_deref() { runs.retain(|run| { run.workflow.name == workflow || run.workflow.slug.as_deref() == Some(workflow) @@ -278,8 +312,10 @@ mod tests { created_before: None, first: Some(10), after: Some(unrelated_cursor.id.to_string()), + parent_id: None, }, None, + None, ) .expect("filtering should succeed"); @@ -304,8 +340,10 @@ mod tests { created_before: None, first: Some(10), after: None, + parent_id: None, }, None, + None, ) .expect("filtering should succeed"); @@ -315,16 +353,59 @@ mod tests { #[test] fn search_summary_uses_bounded_goal_preview() { + let parent_id = run_id("01KRBZW4DW0000000000000002"); let mut run = run("01KRBZW5C00000000000000001", "keep", 30); + run.parent_id = Some(parent_id); + run.children_count = 4; run.goal = format!("{}tail-marker", "a".repeat(300)); let summary = search_run_summary_result(&run); + assert_eq!(summary.parent_id, Some(parent_id.to_string())); + assert_eq!(summary.children_count, 4); assert!(summary.goal_truncated); assert!(summary.goal_preview.len() < run.goal.len()); assert!(!summary.goal_preview.contains("tail-marker")); } + #[test] + fn parent_filter_keeps_matching_direct_children_and_composes_with_archived_default() { + let parent_id = run_id("01KRBZW5000000000000000004"); + let other_parent_id = run_id("01KRBZW4000000000000000005"); + let mut active_child = run("01KRBZW5C00000000000000001", "keep", 30); + active_child.parent_id = Some(parent_id); + let mut archived_child = archived_run("01KRBZW4DW0000000000000002", "keep", 20); + archived_child.parent_id = Some(parent_id); + let mut unrelated_child = run("01KRBZW3EF0000000000000003", "keep", 10); + unrelated_child.parent_id = Some(other_parent_id); + + let result = filter_sort_and_page_runs( + vec![ + archived_child.clone(), + unrelated_child.clone(), + active_child.clone(), + ], + &FabroRunSearchParams { + run_ids: None, + workflow: None, + labels: None, + status: None, + archived: None, + created_after: None, + created_before: None, + first: Some(10), + after: None, + parent_id: Some("nightly-parent".to_string()), + }, + None, + Some(parent_id), + ) + .expect("filtering should succeed"); + + let ids = result.runs.iter().map(|run| run.id).collect::>(); + assert_eq!(ids, vec![active_child.id]); + } + fn run(id: &str, group: &str, seconds: u32) -> Run { run_with_archived(id, group, seconds, false) } @@ -333,6 +414,10 @@ mod tests { run_with_archived(id, group, seconds, true) } + fn run_id(raw: &str) -> fabro_types::RunId { + raw.parse().expect("test run id should parse") + } + fn run_with_archived(id: &str, group: &str, seconds: u32, archived: bool) -> Run { let created_at = Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, seconds).unwrap(); Run { diff --git a/lib/crates/fabro-mcp-server/src/server.rs b/lib/crates/fabro-mcp-server/src/server.rs index b44fb4803..d2315f0d7 100644 --- a/lib/crates/fabro-mcp-server/src/server.rs +++ b/lib/crates/fabro-mcp-server/src/server.rs @@ -49,7 +49,7 @@ impl FabroMcpServer { #[tool( name = "fabro_run_create", - description = "Create one or more Fabro workflow runs, starting them by default." + description = "Create one or more Fabro workflow runs, optionally under a parent run, starting them by default." )] async fn fabro_run_create( &self, @@ -71,7 +71,7 @@ impl FabroMcpServer { #[tool( name = "fabro_run_search", - description = "Search Fabro workflow runs by id, workflow, labels, status, archival state, and creation time." + description = "Search Fabro workflow runs by id, parent, workflow, labels, status, archival state, and creation time." )] async fn fabro_run_search( &self, @@ -93,7 +93,7 @@ impl FabroMcpServer { #[tool( name = "fabro_run_interact", - description = "Get, start, message, interrupt, cancel, archive, unarchive, inspect questions, or answer a Fabro run." + description = "Get, start, message, interrupt, cancel, archive, unarchive, link or unlink a parent, inspect questions, or answer a Fabro run." )] async fn fabro_run_interact( &self,