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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-17 09:26:18 -07:00 committed by GitHub
parent d1fd6d3abf
commit 73ebb7d28b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 448 additions and 14 deletions

2
Cargo.lock generated
View file

@ -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",
]

View file

@ -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:

View file

@ -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",

View file

@ -30,3 +30,7 @@ serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
toml.workspace = true
[dev-dependencies]
httpmock = "0.8"
tempfile = "3"

View file

@ -34,6 +34,8 @@ pub(super) type ToolResult<T> = Result<T, ToolError>;
#[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct RunSummaryResult {
pub(crate) run_id: String,
pub(crate) parent_id: Option<String>,
pub(crate) children_count: u64,
pub(crate) workflow_name: String,
pub(crate) workflow_slug: Option<String>,
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")
}
}

View file

@ -22,6 +22,7 @@ pub(crate) struct CreateRunSpec {
pub(crate) workflow: String,
pub(crate) cwd: Option<PathBuf>,
pub(crate) run_id: Option<String>,
pub(crate) parent_id: Option<String>,
pub(crate) goal: Option<String>,
#[serde(default)]
pub(crate) inputs: HashMap<String, RunInputValue>,
@ -84,6 +85,7 @@ pub(crate) struct ValidatedCreateRunSpec {
pub(crate) workflow: String,
pub(crate) cwd: Option<PathBuf>,
pub(crate) run_id: Option<RunId>,
pub(crate) parent_id: Option<String>,
pub(crate) goal: Option<String>,
pub(crate) inputs: HashMap<String, toml::Value>,
pub(crate) labels: HashMap<String, String>,
@ -122,6 +124,15 @@ impl TryFrom<CreateRunSpec> 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<CreateRunSpec> 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<String>,
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<RunId>,
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
}
})
}
}

View file

@ -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<String>,
pub(crate) message: Option<String>,
pub(crate) interrupt: Option<bool>,
pub(crate) question_id: Option<String>,
@ -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<FabroRunInteractParams> 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,

View file

@ -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([

View file

@ -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<String>,
pub(crate) first: Option<usize>,
pub(crate) after: Option<String>,
pub(crate) parent_id: Option<String>,
}
#[derive(Debug)]
@ -34,7 +35,7 @@ pub(crate) struct ValidatedSearchRuns {
impl TryFrom<FabroRunSearchParams> for ValidatedSearchRuns {
type Error = ToolError;
fn try_from(params: FabroRunSearchParams) -> Result<Self, Self::Error> {
fn try_from(mut params: FabroRunSearchParams) -> Result<Self, Self::Error> {
if params.first.is_some_and(|first| first > 100) {
return Err(ToolError::message("first must be <= 100"));
}
@ -61,6 +62,13 @@ impl TryFrom<FabroRunSearchParams> 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<String>,
pub(crate) children_count: u64,
pub(crate) workflow_name: String,
pub(crate) workflow_slug: Option<String>,
pub(crate) status: String,
@ -97,15 +107,31 @@ pub(crate) async fn search_runs(
) -> ToolResult<SearchRunsResult> {
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<Run>,
raw: &FabroRunSearchParams,
status: Option<&[RunStatusKind]>,
parent_id: Option<RunId>,
) -> ToolResult<RunSearchPage> {
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::<Vec<_>>();
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 {

View file

@ -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,