mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
Merge remote-tracking branch 'origin/main' into add-acp-backend
# Conflicts: # lib/crates/fabro-server/src/server/handler/steer.rs
This commit is contained in:
commit
1db22ba7d8
60 changed files with 7791 additions and 294 deletions
11
.fabro/workflows/daytona-medium/workflow.fabro
Normal file
11
.fabro/workflows/daytona-medium/workflow.fabro
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
digraph DaytonaMedium {
|
||||
graph [goal="Verify the Daytona daytona-medium sandbox starts with standard tooling", retry_target=exit]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
exit [shape=Msquare, label="Exit"]
|
||||
|
||||
inspect [label="Inspect Sandbox", shape=parallelogram, goal_gate=true, script="set -e\nprintf 'cwd: '; pwd\nprintf 'user: '; whoami\nprintf 'git: '; git --version\nif command -v python3 >/dev/null; then printf 'python: '; python3 --version; else echo 'python: not installed'; fi\nif command -v node >/dev/null; then printf 'node: '; node --version; else echo 'node: not installed'; fi\nprintf 'top-level files:\\n'; ls -la | sed -n '1,40p'"]
|
||||
|
||||
start -> inspect -> exit
|
||||
}
|
||||
10
.fabro/workflows/daytona-medium/workflow.toml
Normal file
10
.fabro/workflows/daytona-medium/workflow.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
_version = 1
|
||||
|
||||
[workflow]
|
||||
graph = "workflow.fabro"
|
||||
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "daytona-medium"
|
||||
43
Cargo.lock
generated
43
Cargo.lock
generated
|
|
@ -1741,7 +1741,9 @@ dependencies = [
|
|||
"fabro-interview",
|
||||
"fabro-llm",
|
||||
"fabro-macros",
|
||||
"fabro-manifest",
|
||||
"fabro-mcp",
|
||||
"fabro-mcp-server",
|
||||
"fabro-model",
|
||||
"fabro-oauth",
|
||||
"fabro-proc",
|
||||
|
|
@ -2066,6 +2068,24 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.230.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-api",
|
||||
"fabro-config",
|
||||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
"fabro-template",
|
||||
"fabro-types",
|
||||
"fabro-workflow",
|
||||
"git2",
|
||||
"temp-env",
|
||||
"tempfile",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-mcp"
|
||||
version = "0.230.0-nightly.0"
|
||||
|
|
@ -2082,6 +2102,28 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.230.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"fabro-api",
|
||||
"fabro-client",
|
||||
"fabro-config",
|
||||
"fabro-manifest",
|
||||
"fabro-server",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"futures",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-model"
|
||||
version = "0.230.0-nightly.0"
|
||||
|
|
@ -2218,6 +2260,7 @@ dependencies = [
|
|||
"fabro-interview",
|
||||
"fabro-llm",
|
||||
"fabro-macros",
|
||||
"fabro-manifest",
|
||||
"fabro-model",
|
||||
"fabro-proc",
|
||||
"fabro-redact",
|
||||
|
|
|
|||
246
docs/internal/mcp-server-qa-test-plan.md
Normal file
246
docs/internal/mcp-server-qa-test-plan.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# Fabro MCP Server — QA Test Plan
|
||||
|
||||
One-time manual QA pass for the 5 tools exposed by `fabro-mcp-server`. Source of truth: `lib/crates/fabro-mcp-server/src/run_tools/`.
|
||||
|
||||
This plan is **not** a template for adding automated test coverage — it exists to drive a single hands-on sweep against a real running server. Tick boxes as scenarios pass; add notes inline for failures or surprising behavior. Open bugs/PRs for issues found; do not port these scenarios into the Rust test suite.
|
||||
|
||||
## Findings rollup
|
||||
|
||||
Live list of bugs and notable observations surfaced during the sweep. Each entry links back to the scenario where it was found.
|
||||
|
||||
### Bugs / mismatches
|
||||
None currently open.
|
||||
|
||||
### Rechecked / no longer open
|
||||
- **C4 — `inputs` schema/runtime mismatch**: fixed by narrowing MCP input values to scalar JSON (`string`, `boolean`, `integer`, `number`) and rejecting arrays/objects locally with scalar-only errors. Re-tested on 2026-05-11 against `127.0.0.1:32276`; `tools/list` now advertises scalar-only `inputs.additionalProperties`.
|
||||
- **C5 — Misleading null-input error message**: fixed. Re-tested on 2026-05-11; null now returns ``input `maybe` cannot be null; use a string, boolean, or number``.
|
||||
- **I7 / I9 — Misleading "Run not found." on terminal runs**: fixed on 2026-05-11 in the server API layer. `message`/steer against a durable terminal run that no longer has a live managed engine now returns `409` with `run_not_steerable`; `cancel` returns `409` with `Run is already terminal and cannot be cancelled.` True missing runs still return `404`.
|
||||
- **I10 — Archived runs not filtered from default search**: fixed on 2026-05-11 by aligning MCP search with the HTTP API. `fabro_run_search` now hides archived runs when `archived` is omitted, while `archived=true` still searches archived runs explicitly.
|
||||
- **I15 / I16 — yes/no answer flow**: re-tested on 2026-05-11 against `fabro server` `0.230.0-nightly.0` at `127.0.0.1:32276`. `answer=true` and `answer=false` both submit successfully for the bundled `interview` workflow's first `yes_no` question. `true` advanced the run to the next `confirmation` question.
|
||||
- **I22 — numeric answer local validation**: re-tested on 2026-05-11 against the same server. `answer=42` now returns `unsupported answer value: 42; expected boolean, string, or object` from the MCP layer before reaching the API.
|
||||
- **Section 2 side observation — Search payloads include full `goal` text**: fixed on 2026-05-11. `fabro_run_search` now returns bounded `goal_preview` plus `goal_truncated` instead of the full `goal`, keeping list responses compact while preserving full summaries on other run interactions.
|
||||
- **X6 — Cursor/filter ordering**: simplified on 2026-05-11 by applying search filters before sorting and applying the `after` cursor. This prevents unrelated runs outside the filtered result set from trimming the page. Pagination is explicitly not snapshot-isolated; a new matching run inserted before the cursor during traversal appears when the client starts a new search.
|
||||
|
||||
### UX / polish
|
||||
- **C12 — `cwd` errors don't distinguish "directory missing" from "workflow not in directory"**: both return `workflow not found: <slug>`.
|
||||
- **S9 (bonus) — Undocumented date format**: error message reveals `YYYY-MM-DD` is accepted alongside RFC3339, but the schema only says RFC3339.
|
||||
- **S17 — `run_ids` accepts more than IDs**: error message reveals it also matches ID prefixes and workflow names. Either rename the field or document.
|
||||
- **E4 — Events `search` is whole-envelope substring match**: search includes embedded payloads (workflow definitions, settings, sandbox dockerfile, etc.), so a search like `query="list_prs"` legitimately matches the `run.created` event because that event embeds the workflow JSON. Easy to misinterpret. Consider documenting or scoping search to event body only.
|
||||
|
||||
### Nice-to-haves
|
||||
- **C16 — Helpful error**: unknown workflow lists available workflows. Keep.
|
||||
|
||||
## Pre-flight (all tools)
|
||||
|
||||
- [ ] **P1** Server unreachable — stop `fabro server`, call any tool, expect a clear connection-error message (not a panic, not a hang).
|
||||
- [ ] **P2** Schema discovery — list tools through an MCP client; verify each tool has a complete JSON schema and the documented `anyOf` for `AnswerValue`.
|
||||
|
||||
---
|
||||
|
||||
## 1. `fabro_run_create`
|
||||
|
||||
Source: `run_tools/create.rs:124`
|
||||
|
||||
### Happy path
|
||||
- [x] **C1** Create one run from an existing workflow (e.g. `gh-list`); default `start=true` → expect `started=true`, `status` in `{queued, starting, running}`. — **PASS**. `status=queued`.
|
||||
- [x] **C2** Create with `start=false` → expect `started=false`, `status=submitted`. — **PASS**. Run `01KRC4MP2NEQS9GJDE9FJ0EECH` kept as fixture for I3.
|
||||
- [x] **C3** Batch create 5 runs in one call → all return; result preserves array order. — **PASS**. ULIDs monotonically increasing.
|
||||
|
||||
### Inputs / manifest
|
||||
- [x] **C4** Pass `inputs` with string / number / boolean / nested object / array → **PASS** after 2026-05-11 recheck. Scalar values are accepted. Arrays and objects are rejected locally with scalar-only errors, and the MCP schema now advertises scalar-only `inputs` values.
|
||||
- [x] **C5** `inputs` containing `null` → **PASS** after 2026-05-11 recheck. Returns ``input `maybe` cannot be null; use a string, boolean, or number``.
|
||||
- [x] **C6** `labels={"team": "qa"}` round-trip via search. — **PASS**. All 5 C3 runs returned with labels intact.
|
||||
- [x] **C7** Optional flags: `goal`, `model+provider`, `sandbox`, `preserve_sandbox+auto_approve+dry_run`. — **PASS** all accepted; `goal` override round-tripped via search.
|
||||
- [x] **C8** Custom `run_id`: valid ULID accepted (`01KRC500000000C8TEST00000A`); wrong length → `invalid length`; invalid Crockford char (e.g. `U`) → `invalid character`. — **PASS**.
|
||||
|
||||
### `cwd`
|
||||
- [x] **C9** Omit `cwd` → uses base CWD. — **PASS** (covered by every prior scenario).
|
||||
- [x] **C10** `cwd` to repo root resolves workflow. — **PASS**.
|
||||
- [x] **C11** `cwd=/tmp` (no `.fabro/workflows`) → `workflow not found: gh-list`. — **PASS**.
|
||||
- [x] **C12** `cwd=/this/path/does/not/exist/xyz123` → same generic `workflow not found: gh-list`. — **PASS but note**: error doesn't distinguish "directory missing" from "workflow not in directory". Minor UX gap.
|
||||
|
||||
### Validation
|
||||
- [x] **C13** Empty `runs: []` → `runs must contain at least 1 item(s)`. — **PASS**.
|
||||
- [x] **C14** 51 entries → `runs must contain no more than 50 item(s)`. — **PASS**.
|
||||
- [x] **C15** Missing required `workflow` → MCP layer `-32602: missing field 'workflow'`. — **PASS**.
|
||||
- [x] **C16** Unknown workflow slug → `Unknown workflow 'X'\n\nAvailable workflows: ...`. — **PASS** (very helpful — lists available workflows).
|
||||
|
||||
### Failure semantics
|
||||
- [x] **C17** Invalid sandbox name → `failed to resolve manifest settings: run.sandbox.provider: invalid value - unknown sandbox provider: this-sandbox-does-not-exist`. — **PASS**. Error raised at manifest-resolve time before any run record is created (no orphaned submitted run).
|
||||
|
||||
---
|
||||
|
||||
## 2. `fabro_run_search`
|
||||
|
||||
Source: `run_tools/search.rs:75`
|
||||
|
||||
### Happy path
|
||||
- [x] **S1** No params → returns up to 20 runs, sorted by `started_at OR created_at` desc. — **PASS**. Mixed-timestamp ordering correct (succeeded run at pos 8 sorts by its `started_at` between two `created_at`-only runs).
|
||||
- [x] **S2** `first=5` → exactly 5; `next_cursor` is the last run's ID. — **PASS**.
|
||||
- [x] **S3** `first=100` → all 17 runs, `next_cursor=null`. — **PASS**.
|
||||
- [x] **S4** Cursor follow-through: page 1 IDs `[A, B]`, page 2 with `after=B` returns `[C, D]`. No overlap. — **PASS**. Note: cursors are run IDs, not opaque tokens.
|
||||
|
||||
### Filters
|
||||
- [x] **S5** `workflow="smoke"` (slug) and `workflow="Smoke"` (name) both match same run. — **PASS**.
|
||||
- [x] **S6** `status=["succeeded"]` → 4; `["failed","dead"]` → 1; `["submitted"]` → 5. — **PASS**.
|
||||
- [x] **S7** Labels round-trip. — **PASS** (verified via C6).
|
||||
- [x] **S8** `archived=false` → all unarchived runs; `archived=true` → `[]` (no archived runs yet). Re-verify after I10. — **PARTIAL** (no archived fixtures yet).
|
||||
- [x] **S9** `created_after`/`created_before` (RFC3339) bound results correctly; tight window `17:00–18:00` returns only old runs. — **PASS**. **Bonus**: error message reveals `YYYY-MM-DD` is also accepted — undocumented in the schema.
|
||||
- [x] **S10** `run_ids=[A,B,A]` → 2 deduped runs. — **PASS**.
|
||||
- [x] **S11** Combined `workflow + status + labels + archived` → returns exactly the 5 batch=c3 runs. — **PASS**.
|
||||
|
||||
### Validation
|
||||
- [x] **S12** `first=101` → `first must be <= 100`. — **PASS**.
|
||||
- [x] **S13** `run_ids=[]` → `run_ids must contain at least 1 item(s)`. — **PASS**.
|
||||
- [x] **S14** `run_ids` length 101 → `run_ids must contain no more than 100 item(s)`. — **PASS**.
|
||||
- [x] **S15** `status=["bogus"]` → `unknown run status 'bogus'`. — **PASS**.
|
||||
- [x] **S16** `created_after="not-a-date"` → `created_after must be RFC3339 or YYYY-MM-DD: input contains invalid characters`. — **PASS**.
|
||||
|
||||
### Edge cases
|
||||
- [x] **S17** Non-existent ID in `run_ids` → `No run found matching '<ID>' (tried run ID prefix and workflow name)`. — **PASS** + **finding**: `run_ids` also accepts ID prefixes and workflow names, which is broader than the field name suggests.
|
||||
- [x] **S18** No matches → `{"runs": [], "next_cursor": null}`. — **PASS**.
|
||||
- [x] **S19** Bogus `after=<unknown>` → returns full first page (skip never applies). — **PASS** as documented.
|
||||
|
||||
### Side observation
|
||||
Search responses include the full `goal` text per run; a single `ImplementPlan` run can add ~30 KB to every search payload. Consider truncating `goal` (or excluding it from list responses) the way events have `max_content_length`. **Logged in Findings.**
|
||||
|
||||
---
|
||||
|
||||
## 3. `fabro_run_gather`
|
||||
|
||||
Source: `run_tools/gather.rs:56`
|
||||
|
||||
### Happy path
|
||||
- [x] **G1** Gather 1 already-terminal run → instant return, `timed_out=false`, `elapsed_seconds=0`. — **PASS**.
|
||||
- [x] **G2** In-flight `gh-list` with `timeout=60, poll=5` → reaches `succeeded`, `timed_out=false`, `elapsed=30`. — **PASS**.
|
||||
- [x] **G3** In-flight `gh-list` with `timeout=5, poll=5` → `timed_out=true`, `elapsed=5`, run still `starting`. — **PASS**.
|
||||
- [x] **G4** Mix of 2 terminal + 1 in-flight, `timeout=90, poll=5` → all 3 succeeded, `timed_out=false`, `elapsed=40`. — **PASS**.
|
||||
|
||||
### Validation
|
||||
- [x] **G5** `run_ids=[]` → `run_ids must contain at least 1 item(s)`. — **PASS**.
|
||||
- [x] **G6** 51 IDs → `run_ids must contain no more than 50 item(s)`. — **PASS**.
|
||||
- [x] **G7** `timeout_seconds=601` → `timeout_seconds must be <= 600`. — **PASS**.
|
||||
- [x] **G8** `poll_interval_seconds=4` → `poll_interval_seconds must be >= 5`. — **PASS**.
|
||||
- [x] **G9** Omit both → call accepted; terminal run still returns instantly. Default values per source: `timeout=300, poll=15`. — **PASS**.
|
||||
|
||||
### Edge cases
|
||||
- [x] **G10** Non-existent run ID → `No run found matching '<ID>' (tried run ID prefix and workflow name)`. — **PASS** (same fuzzy match as search).
|
||||
- [x] **G11** Poll cadence: G3 confirms last sleep clamps to deadline (`elapsed=5` exactly with `timeout=5, poll=5`). — **PASS** (inferred from G2/G3 timing).
|
||||
- [ ] **G12** Run cancelled mid-gather → terminal `failed(status_reason=cancelled)` quickly. — **DEFERRED** to after I8 (cancel).
|
||||
- [ ] **G13** Run becomes `blocked` — verify gather still waits. — **DEFERRED** to after Section 5 (interview workflow).
|
||||
|
||||
---
|
||||
|
||||
## 4. `fabro_run_events`
|
||||
|
||||
Source: `run_tools/events.rs:115`
|
||||
|
||||
### Actions
|
||||
- [x] **E1** `list` no filters → 45 events (`gh-list` has full lifecycle: run.*, sandbox.*, git.*, stage.*, etc.), `next_cursor=46`. — **PASS**.
|
||||
- [x] **E2** `details` with 2 event_ids → returns exactly those 2 envelopes. — **PASS**.
|
||||
- [x] **E3** `details` with no `event_ids` → `event_ids is required for details action`. — **PASS**.
|
||||
- [x] **E4** `search query="list_prs"` → 14 events. Includes `run.created` because it embeds the full workflow definition (which contains the `list_prs` node ID). — **PASS** + **observation**: search ranges over the entire serialized envelope, so big embedded payloads (workflow defs, settings) can produce non-obvious hits.
|
||||
- [x] **E5** `search` with missing `query` → `query is required for search action`. — **PASS**.
|
||||
|
||||
### Filters
|
||||
- [x] **E6** `event_types=["stage.started"]` → exactly 4 events (start, list_prs, list_issues, exit). — **PASS**.
|
||||
- [x] **E7** `categories=["git","sandbox"]` → 12 events all with prefix `git.*` or `sandbox.*`. — **PASS**.
|
||||
- [x] **E8** `created_after=17:03:10Z` + `created_before=17:03:13Z` → 5 events all timestamped 17:03:12.89x. — **PASS**.
|
||||
- [x] **E9** Combined `event_types + offset + first` covered by E14.
|
||||
|
||||
### Pagination & direction
|
||||
- [x] **E10** Page 1 `first=10` → seqs 1–10, `next_cursor=11`. Page 2 `after=11, first=5` → seqs 11–15, `next_cursor=16`. No duplicates; contiguous. — **PASS**.
|
||||
- [x] **E11** `direction=desc, first=5` → seqs 45, 44, 43, 42, 41; `next_cursor=41` (last seq, no +1 — per the desc branch). — **PASS**.
|
||||
- [x] **E12** Default direction = asc (E10 confirms). — **PASS**.
|
||||
- [x] **E13** `direction="weird"` → `direction must be 'asc' or 'desc'`. — **PASS**.
|
||||
- [x] **E14** `event_types=["stage.started"], offset=2, first=5` → returned 2 events (seqs 29, 39) — correctly skipped the first 2 (15, 19) of the 4 matching. — **PASS**.
|
||||
- [x] **E15** `limit=3` → 3 events. — **PASS** (alias works).
|
||||
|
||||
### Truncation
|
||||
- [x] **E16** `stage.completed, first=1, max_content_length=200` → 1 event, `truncated=true`, `event` is a JSON string. — **PASS**.
|
||||
- [x] **E17** UTF-8 boundary — **VERIFIED via existing unit test** at `events.rs:269-312`. Can't easily reproduce through MCP surface (no multibyte event content in default fixtures).
|
||||
- [x] **E18** Default `max_content_length=20000` → all 5 events `truncated=false` (including the ~5 KB `run.created`). — **PASS**.
|
||||
|
||||
### Validation
|
||||
- [x] **E19** `run_id=" "` (whitespace) → `run_id is required`. — **PASS**.
|
||||
- [x] **E20** `first=201` → `first must be <= 200`. — **PASS**.
|
||||
- [x] **E21** Non-existent run ID → fuzzy-match error (same as search/gather). — **PASS**.
|
||||
|
||||
---
|
||||
|
||||
## 5. `fabro_run_interact`
|
||||
|
||||
Source: `run_tools/interact.rs:201`
|
||||
|
||||
### Actions
|
||||
|
||||
#### `get`
|
||||
- [x] **I1** Returns `{summary, projection}`; projection includes `spec`, `graph`, `status`, `checkpoints`, `pending_interviews`, `stages`, `sandbox`, `conclusion`, etc. — **PASS**.
|
||||
- [x] **I2** Non-existent run → fuzzy match error. — **PASS**.
|
||||
|
||||
#### `start`
|
||||
- [x] **I3** Non-started run (from C2) → `start` transitions to `queued`. Second `start` → `an engine process is still running for this run — cannot start`. — **PASS**.
|
||||
|
||||
#### `message` (steer)
|
||||
- [ ] **I4** Steer a running LLM agent — **DEFERRED** (requires an active LLM agent stage; would burn LLM tokens; can be exercised manually once the answer bug below is resolved).
|
||||
- [ ] **I5** `interrupt=true` — **DEFERRED** along with I4.
|
||||
- [x] **I6** Missing `message` → `message is required for action message`. — **PASS**.
|
||||
- [x] **I7** Message a terminal run → initially returned `Run not found.`. — **FIXED**: durable terminal runs without a live managed engine now return `409 run_not_steerable`; true missing runs remain `404`.
|
||||
|
||||
#### `cancel`
|
||||
- [x] **I8** Cancel a `gh-list` run during `starting`. Returns summary at request time (status=`starting`). Subsequent `gather` returned terminal `failed` within 5s; `get` projection shows `status: {kind: "failed", reason: "cancelled"}` and `conclusion.failure_reason: "Pipeline cancelled"`. — **PASS** + **observation**: `cancel`'s returned summary is a snapshot at request time, not the eventual terminal status.
|
||||
- [x] **I9** Cancel an already-terminal run → initially returned `Run not found.`. — **FIXED**: durable terminal runs without a live managed engine now return `409` with `Run is already terminal and cannot be cancelled.`; true missing runs remain `404`.
|
||||
|
||||
#### `archive` / `unarchive`
|
||||
- [x] **I10** Archive terminal run → `archived=true` in summary; visible via `search archived=true`. — **FIXED**: default search now hides archived runs to match `/api/v1/runs`; `archived=true` still surfaces archived runs explicitly.
|
||||
- [x] **I11** Unarchive → reverses (`archived=false`). — **PASS**.
|
||||
- [x] **I12** Archive an active run → `run <id> must be terminal (succeeded, failed, or dead) to archive; current status is starting`. — **PASS** (excellent error).
|
||||
|
||||
#### `get_questions`
|
||||
- [x] **I13** Terminal run → `questions: []`. — **PASS**.
|
||||
- [x] **I14** Blocked interview run → returns full question record (id, text, options, question_type, stage, allow_freeform). — **PASS**.
|
||||
|
||||
#### `answer` — `AnswerValue` shapes
|
||||
|
||||
Re-check note: the earlier `yes_no` answer failure did not reproduce against `fabro server` `0.230.0-nightly.0` on `127.0.0.1:32276` (2026-05-11). Boolean answers are accepted for `yes_no` questions, and invalid question/type combinations are rejected by the API as expected.
|
||||
|
||||
- [x] **I15** `answer=true` on the first `yes_no` question → submitted successfully (`submitted=true`) and advanced to the `confirmation` question. — **PASS**. Run `01KRCAQ9AS14KFCW4CXBZQ0CW9`.
|
||||
- [x] **I16** `answer=false` on a fresh `yes_no` question → submitted successfully (`submitted=true`). — **PASS**. Run `01KRCATZ031CAPEPVB4CNFEE33`.
|
||||
- [ ] **I17** `answer="some text"` — **NOT RE-TESTED**. Should be tested against a `freeform` question or a question with `allow_freeform=true`; text is not valid for the bundled `yes_no` question.
|
||||
- [ ] **I18** `answer={"text":"hi"}` — **NOT RE-TESTED**. Same scope as I17.
|
||||
- [x] **I19** `answer={"option":"Y"}` against the first `yes_no` question → `Answer does not match question type.` — **PASS / expectation corrected**. The MCP layer maps this shape to `selected`, but `server.rs:2670-2710` only accepts `yes`/`no` for `yes_no` and `confirmation`; `selected` belongs to `multiple_choice`.
|
||||
- [ ] **I20** `answer={"options":[...]}` — **NOT RE-TESTED**. Should be tested against a `multi_select` question; `multi_selected` is not valid for `yes_no`.
|
||||
- [x] **I21** `answer={"value":"yes"}` → `answer object must contain one of: option, options, text` (local validation). — **PASS**.
|
||||
- [x] **I22** `answer=42` (number) → `unsupported answer value: 42; expected boolean, string, or object`. — **PASS** (local validation).
|
||||
- [x] **I23** `answer={"option": 5}` → `answer option must be a string: invalid type: integer '5', expected a string`. — **PASS**.
|
||||
- [x] **I24** `answer={"options": ["a", 2]}` → `answer options must be strings: invalid type: integer '2', expected a string`. — **PASS**.
|
||||
- [x] **I25** `action=answer` without `question_id` → `question_id is required for action answer`. — **PASS**.
|
||||
- [x] **I26** `action=answer` without `answer` → `answer is required for action answer`. — **PASS**.
|
||||
- [x] **I27** Already-answered question — observed indirectly: the same question_id returned `Question no longer exists or was already answered.` on retry. — **PASS**.
|
||||
|
||||
### Cross-cutting
|
||||
- [x] **I28** `run_id=" "` → `run_id is required`. — **PASS**.
|
||||
- [x] **I29** Action enum: `Get` and `get-questions` both rejected with `unknown variant 'X', expected one of: get, start, message, cancel, archive, unarchive, get_questions, answer`. — **PASS**.
|
||||
|
||||
---
|
||||
|
||||
## 6. End-to-end scenarios (multi-tool)
|
||||
|
||||
- [x] **X1 — Happy lifecycle** `gh-list` create → 35s gather → events filtered to `stage.started/completed` → 8 events for 4 stages (start, list_prs, list_issues, exit). Sequence matches workflow graph. — **PASS**.
|
||||
- [x] **X2 — Cancel mid-run** Covered by I8: `gh-list` cancel during `starting` → gather returned terminal `failed` in 5s; projection shows `status_reason=cancelled`. — **PASS**.
|
||||
- [ ] **X3 — Human-in-the-loop** — **PARTIAL**. The earlier yes/no answer blocker is no longer reproduced (I15/I16 now pass), and `gather` returning `timed_out=true` on a `blocked` run **was** verified (G13). Full interview completion remains unverified in this sweep.
|
||||
- [ ] **X4 — Steering** — **DEFERRED** (requires active LLM agent).
|
||||
- [x] **X5 — Archive flow** Covered by I10/I11: archive → search with `archived=true` returns it (also returned by default search — see I10 finding). Unarchive reverses. — **PASS** with caveat.
|
||||
- [x] **X6 — Search/cursor under churn** Page 1 `first=3` → cursor saved. Created new run `01KRC625KG…` mid-flow. Page 2 with original cursor returned 3 older runs; a fresh page 1 placed the new run at position 1. — **ACCEPTED / SIMPLIFIED**. Pagination is not snapshot-isolated; clients that need newly inserted earlier results should restart the search. Code now applies filters before sorting/cursoring so unrelated runs outside the filtered result set do not trim filtered pages.
|
||||
- [x] **X7 — Events while running** Started `gh-list` run, listed events `desc` immediately (max seq=8), gathered to completion, re-listed (max seq=46). Seq numbers grew monotonically; no early events lost. — **PASS**.
|
||||
- [x] **X8 — Truncated event recovery** Fetched the `ImplementPlan` `run.created` event (embeds ~30 KB goal) at default `max_content_length=20000` → `truncated:true`, payload returned as a JSON string. — **PASS**.
|
||||
- [ ] **X9 — Stranger inputs** — Skipped per scope decision. Trivially safe since inputs go through TOML conversion to be stored as values; the MCP layer never opens paths.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mechanics for the manual sweep
|
||||
|
||||
- **Driver** — run these scenarios through an MCP client (e.g. Claude Code with the `fabro` MCP server configured) against a locally running `fabro server`.
|
||||
- **Reusable run IDs** — keep a handful of already-terminal runs around (e.g. one `gh-list` succeeded, one failed `implement-plan`) as fixtures for `events`, `gather` (instant-return), `interact.get`, and `archive` scenarios.
|
||||
- **Server unreachable cases** — stop the API server with the MCP client still connected to exercise error propagation paths.
|
||||
- **Issue tracking** — file a GitHub issue per defect; link the scenario ID (e.g. `C13`) so this plan and the bugs cross-reference.
|
||||
292
docs/plans/2026-05-11-add-fabro-mcp-server-test-plan.md
Normal file
292
docs/plans/2026-05-11-add-fabro-mcp-server-test-plan.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Fabro MCP Server Test Plan
|
||||
|
||||
## Harness Requirements
|
||||
|
||||
The agreed testing strategy still holds after reading the implementation plan. The plan narrows the tool contract to five Devin-shaped run tools and requires the implementation to live in a new `fabro-mcp-server` crate, but it does not add paid APIs, live LLM calls, external infrastructure, or browser/UI behavior. The highest-value evidence remains a real `fabro mcp start` subprocess driven over stdio and backed by Fabro's real local test server/auth harness.
|
||||
|
||||
1. **Deterministic MCP stdio fixture**
|
||||
- **Does:** constructs the exact command, environment, and cwd used to spawn `env!("CARGO_BIN_EXE_fabro") mcp start`.
|
||||
- **Exposes:** `command: Vec<String>`, `env: HashMap<String, String>`, and `current_dir: PathBuf` usable by both `fabro_mcp::client::McpClient` and raw `std::process::Command` tests.
|
||||
- **Complexity:** low. Add a narrow helper in `lib/crates/fabro-cli/tests/it/cmd/mcp.rs`; if needed, add `fabro_test::isolated_env(home_dir)` to mirror `apply_test_isolation`.
|
||||
- **Tests depending on it:** 5, 6, 7, 8, 9, 10, 15, 16, 17.
|
||||
|
||||
2. **MCP tool-call assertion helpers**
|
||||
- **Does:** calls a named MCP tool, asserts tool success or tool error, extracts `structured_content`, and verifies fallback text is concise rather than a JSON dump.
|
||||
- **Exposes:** `call_tool_json(...)`, `call_tool_error_text(...)`, and normalization helpers for run IDs, timestamps, paths, event IDs, cursors, durations, and elapsed times.
|
||||
- **Complexity:** low to medium. Keep it local to `cmd/mcp.rs` unless more than one test file needs it.
|
||||
- **Tests depending on it:** 8, 9, 10, 11, 12, 13, 15, 16, 17.
|
||||
|
||||
3. **Real authenticated Fabro server fixture**
|
||||
- **Does:** starts `RealAuthHarness::start_with_dev_token(...)`, seeds CLI dev-token auth into the test home, creates dry-run workflows through public CLI/MCP/API surfaces, and shuts down the server.
|
||||
- **Exposes:** API target URL, persisted auth entry, HTTP client/server-visible state checks, and workflow fixture paths.
|
||||
- **Complexity:** medium, mostly reuse existing `lib/crates/fabro-cli/tests/it/support/auth_harness.rs`.
|
||||
- **Tests depending on it:** 8, 10, 11, 12, 13, 14, 17.
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. **`fabro mcp` help exposes the MCP namespace**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** output capture harness through existing `fabro_snapshot!`
|
||||
- **Preconditions:** isolated `TestContext`; no auth or server required.
|
||||
- **Actions:** run `fabro mcp --help`.
|
||||
- **Expected outcome:** stdout snapshots a `Model Context Protocol server` namespace with `start`, `config`, and `init` subcommands; stderr is empty; exit status is 0. Source of truth: user request for `fabro mcp start`, `fabro mcp config`, `fabro mcp init <agent>`, and implementation plan CLI contract.
|
||||
- **Interactions:** clap command tree, global CLI flags, snapshot filters.
|
||||
|
||||
2. **`fabro mcp start --help` documents stdio startup options**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** output capture harness through `fabro_snapshot!`
|
||||
- **Preconditions:** isolated `TestContext`; no auth or server required.
|
||||
- **Actions:** run `fabro mcp start --help`.
|
||||
- **Expected outcome:** stdout snapshots usage `fabro mcp start [OPTIONS]` with `--server <SERVER>` and `--storage-dir <DIR>`; stderr is empty; exit status is 0. Source of truth: implementation plan CLI contract.
|
||||
- **Interactions:** clap flattening for `ServerConnectionArgs`.
|
||||
|
||||
3. **`fabro mcp config --help` documents config rendering options**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** output capture harness through `fabro_snapshot!`
|
||||
- **Preconditions:** isolated `TestContext`; no auth or server required.
|
||||
- **Actions:** run `fabro mcp config --help`.
|
||||
- **Expected outcome:** stdout snapshots usage and the same connection override flags as `start`; stderr is empty; exit status is 0. Source of truth: implementation plan CLI contract.
|
||||
- **Interactions:** clap command help and global CLI flags.
|
||||
|
||||
4. **`fabro mcp init --help` documents supported agent selection**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** output capture harness through `fabro_snapshot!`
|
||||
- **Preconditions:** isolated `TestContext`; no auth or server required.
|
||||
- **Actions:** run `fabro mcp init --help`.
|
||||
- **Expected outcome:** stdout snapshots required `<AGENT>` with supported values `claude`, `cursor`, and `windsurf`; exit status is 0. Source of truth: user request and implementation plan supported-agent contract.
|
||||
- **Interactions:** clap value enum rendering.
|
||||
|
||||
5. **`fabro mcp config` prints generic MCP client JSON**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** output capture harness plus structured JSON parsing
|
||||
- **Preconditions:** isolated `TestContext`; no auth or server required.
|
||||
- **Actions:** run `fabro mcp config`; parse stdout as JSON.
|
||||
- **Expected outcome:** stdout is valid JSON with `mcpServers.fabro.command == "fabro"` and `args == ["mcp", "start"]`; stderr is empty; exit status is 0. Source of truth: Daytona-shaped user request and implementation plan config JSON contract.
|
||||
- **Interactions:** config rendering, stdout contract for a non-stdio command.
|
||||
|
||||
6. **`fabro mcp config` preserves connection flags in generated startup args**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** output capture harness plus structured JSON parsing
|
||||
- **Preconditions:** isolated `TestContext`; no auth or server required.
|
||||
- **Actions:** run `fabro mcp config --server https://example.test/api/v1 --storage-dir /tmp/fabro-mcp-storage`; parse stdout as JSON.
|
||||
- **Expected outcome:** JSON contains `args == ["mcp", "start", "--server", "https://example.test/api/v1", "--storage-dir", "/tmp/fabro-mcp-storage"]`; stderr is empty; exit status is 0. Source of truth: implementation plan examples for flag preservation.
|
||||
- **Interactions:** CLI argument forwarding into MCP client config.
|
||||
|
||||
7. **`fabro mcp init <agent>` writes idempotent config without clobbering unrelated keys**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** direct filesystem artifact assertion in isolated home
|
||||
- **Preconditions:** isolated `TestContext`; pre-existing Cursor config with `mcpServers.other` and unrelated top-level key.
|
||||
- **Actions:** run `fabro mcp init cursor --server https://example.test/api/v1` twice; read `~/.cursor/mcp.json`.
|
||||
- **Expected outcome:** parsed JSON preserves unrelated keys and existing `mcpServers.other`, contains exactly one `mcpServers.fabro` entry with command `fabro` and expected args, and the second run does not duplicate or reorder into an invalid shape. Source of truth: implementation plan idempotent config merge contract.
|
||||
- **Interactions:** filesystem directory creation, JSON merge/write, test home isolation.
|
||||
|
||||
8. **`fabro mcp init` writes each supported agent path**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** direct filesystem artifact assertion in isolated home
|
||||
- **Preconditions:** isolated `TestContext`; no existing Claude, Cursor, or Windsurf config.
|
||||
- **Actions:** run `fabro mcp init claude`, `fabro mcp init cursor`, and `fabro mcp init windsurf` in separate contexts; read the platform-specific config file for each.
|
||||
- **Expected outcome:** each config file exists at the path named by the implementation plan and contains `mcpServers.fabro` with `command: "fabro"` and `args: ["mcp", "start"]`. Source of truth: implementation plan agent path contract.
|
||||
- **Interactions:** platform-specific path selection, filesystem writes.
|
||||
|
||||
9. **`fabro mcp init` rejects invalid existing config without overwrite**
|
||||
- **Type:** boundary
|
||||
- **Disposition:** new
|
||||
- **Harness:** output capture and filesystem artifact assertion
|
||||
- **Preconditions:** isolated `TestContext`; Cursor config file contains invalid JSON bytes.
|
||||
- **Actions:** run `fabro mcp init cursor`; read the same file after failure.
|
||||
- **Expected outcome:** command exits non-zero with a clear error that includes the config path; the file content is byte-for-byte unchanged. Source of truth: implementation plan invalid JSON failure contract and error-handling strategy.
|
||||
- **Interactions:** JSON parsing, write avoidance on error, CLI error rendering.
|
||||
|
||||
10. **`fabro mcp start` initializes over stdio and lists the five run tools**
|
||||
- **Type:** scenario
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness using deterministic MCP stdio fixture and `fabro_mcp::client::McpClient`
|
||||
- **Preconditions:** isolated `TestContext`; no auth; no live Fabro server.
|
||||
- **Actions:** spawn `fabro mcp start`; perform MCP `initialize`; call `tools/list`.
|
||||
- **Expected outcome:** initialize succeeds without auth/server connectivity; `tools/list` returns exactly `fabro_run_create`, `fabro_run_search`, `fabro_run_interact`, `fabro_run_gather`, and `fabro_run_events`, each with an input schema. Source of truth: MCP lifecycle/tools spec as captured in the agreed strategy and implementation plan exact tool list.
|
||||
- **Interactions:** `rmcp` stdio transport, existing `fabro-mcp` client crate, child process lifecycle.
|
||||
|
||||
11. **`fabro mcp start` reserves stdout for JSON-RPC only**
|
||||
- **Type:** regression
|
||||
- **Disposition:** new
|
||||
- **Harness:** raw subprocess stdio harness
|
||||
- **Preconditions:** isolated `TestContext`; no auth; no live Fabro server.
|
||||
- **Actions:** spawn `fabro mcp start`; write a JSON-RPC `initialize` request to stdin; read the first stdout line.
|
||||
- **Expected outcome:** first stdout line parses as JSON and has `jsonrpc: "2.0"`; no leading human log/help text appears on stdout; stderr may contain logs. Source of truth: MCP stdio transport contract and implementation plan stdout invariant.
|
||||
- **Interactions:** CLI logging initialization, raw process pipes, JSON-RPC framing.
|
||||
|
||||
12. **MCP startup and tool discovery are fast without auth or server**
|
||||
- **Type:** invariant
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus timing assertion
|
||||
- **Preconditions:** isolated `TestContext`; no auth; no live Fabro server.
|
||||
- **Actions:** measure elapsed time for spawning `fabro mcp start`, initializing, and calling `tools/list`.
|
||||
- **Expected outcome:** operation completes under a generous smoke threshold, initially 2 seconds unless CI evidence requires a documented adjustment; all five tools are listed. Source of truth: agreed testing strategy performance smoke and implementation plan lazy API connection invariant.
|
||||
- **Interactions:** process startup, `rmcp` initialization, tool schema generation.
|
||||
|
||||
13. **`fabro_run_create` creates and starts a real dry-run using persisted CLI auth**
|
||||
- **Type:** scenario
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus real authenticated Fabro server fixture
|
||||
- **Preconditions:** `RealAuthHarness::start_with_dev_token(...)`; dev-token auth seeded into isolated home for the harness target; checked-in `simple.fabro` fixture installed.
|
||||
- **Actions:** spawn `fabro mcp start --server <target>`; call `fabro_run_create` with one run using `workflow`, `dry_run: true`, `auto_approve: true`, and label `source=mcp-test`.
|
||||
- **Expected outcome:** tool result is not an MCP error; `structured_content.runs[0]` includes a run id, workflow, `started: true`, and status; fallback text exists and does not start with `{` or `[`; server-visible state contains the created run. Source of truth: user request for run-management MCP tools, implementation plan create semantics, OpenAPI `POST /api/v1/runs`, and `POST /api/v1/runs/{id}/start`.
|
||||
- **Interactions:** persisted CLI auth store, Fabro API client, manifest builder/validation, run engine dry-run path.
|
||||
|
||||
14. **`fabro_run_search` filters, paginates, and includes archived runs by default**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus real authenticated Fabro server fixture
|
||||
- **Preconditions:** authenticated MCP server; at least two MCP-created dry-run runs with distinct labels; one terminal run archived through API or MCP.
|
||||
- **Actions:** call `fabro_run_search` with `run_ids`, `workflow`, `labels`, `status`, `archived`, `first`, and `after` combinations.
|
||||
- **Expected outcome:** results are normalized run summaries; filters include only matching runs; `first` limits page size and returns an opaque cursor when more results exist; archived runs appear unless `archived: false` is supplied. Source of truth: implementation plan search semantics and OpenAPI list-runs include-archived behavior adapted by the plan.
|
||||
- **Interactions:** server run listing, status string normalization, timestamp/date parsing, cursor handling.
|
||||
|
||||
15. **`fabro_run_interact get/start/message/cancel` uses selector resolution and server APIs**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus mocked HTTP server for precise API call assertions
|
||||
- **Preconditions:** isolated `TestContext`; HTTP mock server with `/api/v1/runs/resolve`, `/runs/{id}`, `/state`, `/start`, `/steer`, and `/cancel` endpoints; CLI auth seeded if the mock requires auth.
|
||||
- **Actions:** call `fabro_run_interact` with actions `get`, `start`, `message` with `interrupt: true`, and `cancel`, using a workflow-name selector rather than the exact run id.
|
||||
- **Expected outcome:** each action first resolves the selector through `/runs/resolve`; calls the matching endpoint; returns a structured object with `run_id`, `action`, and action-specific `result`; tool errors are not produced for mocked successful API responses. Source of truth: implementation plan interact semantics and OpenAPI operation descriptions for retrieve, state, start, steer, and cancel.
|
||||
- **Interactions:** run selector semantics, API error conversion, structured content projection.
|
||||
|
||||
16. **`fabro_run_interact archive/unarchive` changes real server-visible archived state**
|
||||
- **Type:** scenario
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus real authenticated Fabro server fixture
|
||||
- **Preconditions:** authenticated MCP server; completed dry-run created through MCP or public CLI.
|
||||
- **Actions:** call `fabro_run_interact` with `archive`; call `fabro_run_search` with `archived: true`; call `fabro_run_interact` with `unarchive`; call `fabro_run_search` with `archived: false`.
|
||||
- **Expected outcome:** archive action succeeds for the terminal run; archived search shows the run; unarchive action succeeds; unarchived search shows the run as terminal and not archived. Source of truth: implementation plan interact actions and OpenAPI archive/unarchive contracts.
|
||||
- **Interactions:** archive state transitions, list/search visibility, server-side idempotence.
|
||||
|
||||
17. **`fabro_run_interact get_questions/answer` maps answer JSON to the API contract**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus mocked HTTP server for endpoint/body assertions
|
||||
- **Preconditions:** isolated `TestContext`; HTTP mock server returns pending questions and accepts answer submissions.
|
||||
- **Actions:** call `fabro_run_interact` with `get_questions`; call `answer` using representative payloads: `true`, `false`, string text, `{ "option": "a" }`, `{ "options": ["a", "b"] }`, and `{ "text": "hello" }`.
|
||||
- **Expected outcome:** `get_questions` returns the API question list projection; `answer` sends `SubmitAnswerRequest` wire shapes with `kind: yes`, `no`, `text`, `selected`, and `multi_selected`, and returns a successful structured action result. Source of truth: implementation plan answer mapping and `lib/crates/fabro-api/tests/submit_answer_request_round_trip.rs`.
|
||||
- **Interactions:** generated API type shape, JSON body serialization, human-in-the-loop endpoints.
|
||||
|
||||
18. **`fabro_run_gather` waits for terminal runs and returns current state on timeout**
|
||||
- **Type:** scenario
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus real authenticated Fabro server fixture
|
||||
- **Preconditions:** authenticated MCP server; one completed dry-run and one submitted/non-terminal run available.
|
||||
- **Actions:** call `fabro_run_gather` on the completed run; call it on the non-terminal run with `timeout_seconds: 1` and `poll_interval_seconds: 5`.
|
||||
- **Expected outcome:** completed run result has `timed_out: false` and terminal status; timeout case returns a successful structured result with `timed_out: true`, current run summary, and bounded elapsed wall time rather than an MCP/process error. Source of truth: implementation plan gather semantics and agreed performance/timeout strategy.
|
||||
- **Interactions:** selector resolution, polling loop, server retrieve endpoint, terminal status classification.
|
||||
|
||||
19. **`fabro_run_events` lists, details, searches, filters, paginates, and truncates events**
|
||||
- **Type:** integration
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness plus real authenticated Fabro server fixture
|
||||
- **Preconditions:** authenticated MCP server; completed dry-run with stored events.
|
||||
- **Actions:** call `fabro_run_events` with `action: "list"` and `first`; call `details` with returned event ids; call `search` with a known event-name substring; call filters for `event_types`, `categories`, `direction: "desc"`, `after`, `offset`, `limit`, and a small `max_content_length`.
|
||||
- **Expected outcome:** returned events belong to the run; list ordering and pagination match requested parameters; details returns only requested event ids; search returns serialized events containing the query; category filtering uses event-name prefix; oversized serialized payloads are truncated with `truncated: true`; `next_cursor` is derived from the last returned sequence. Source of truth: implementation plan events semantics and OpenAPI `GET /api/v1/runs/{id}/events`.
|
||||
- **Interactions:** event store pagination, event-name/category derivation, JSON serialization/truncation.
|
||||
|
||||
20. **Local validation errors happen before auth or network lookup and do not stop the server**
|
||||
- **Type:** boundary
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness with `--server http://127.0.0.1:9` and no auth
|
||||
- **Preconditions:** isolated `TestContext`; no auth entry; unreachable server URL.
|
||||
- **Actions:** call `fabro_run_gather` with 51 run ids; call `tools/list`; call `fabro_run_interact` action `message` without `message`; call `tools/list` again.
|
||||
- **Expected outcome:** each invalid tool call returns an MCP tool error mentioning the invalid field (`run_ids` or `message`); no auth guidance or connection error masks the local validation failure; subsequent `tools/list` succeeds. Source of truth: implementation plan validate-before-client invariant and MCP tool-error contract.
|
||||
- **Interactions:** parameter validation, lazy client initialization, MCP service liveness after errors.
|
||||
|
||||
21. **Auth failures use existing Fabro login guidance and remain tool errors**
|
||||
- **Type:** boundary
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness with protected real or mocked API target
|
||||
- **Preconditions:** isolated `TestContext`; no saved auth for the target; server requires auth.
|
||||
- **Actions:** spawn `fabro mcp start --server <protected-target>`; call a valid read tool such as `fabro_run_search`.
|
||||
- **Expected outcome:** call returns an MCP tool error, not process exit; error text includes `Run \`fabro auth login\` to authenticate.`; subsequent `tools/list` still succeeds. Source of truth: user request for no separate MCP auth and implementation plan auth invariant.
|
||||
- **Interactions:** auth store lookup, client connection, error classification/rendering.
|
||||
|
||||
22. **Invalid create inputs are rejected with field-specific tool errors**
|
||||
- **Type:** boundary
|
||||
- **Disposition:** new
|
||||
- **Harness:** interaction harness with no auth and unreachable server
|
||||
- **Preconditions:** isolated `TestContext`; no auth entry.
|
||||
- **Actions:** call `fabro_run_create` with empty `runs`, with 51 runs, and with `inputs` containing a null value.
|
||||
- **Expected outcome:** each call returns an MCP tool error naming the invalid field/key before any auth/server error; server remains alive for a subsequent `tools/list`. Source of truth: implementation plan create validation and JSON-to-TOML null rejection.
|
||||
- **Interactions:** schema/validation layer, JSON-to-TOML conversion.
|
||||
|
||||
23. **Run tool successes always include structured content and concise text**
|
||||
- **Type:** invariant
|
||||
- **Disposition:** new
|
||||
- **Harness:** MCP tool-call assertion helpers reused by scenario tests
|
||||
- **Preconditions:** any successful calls from tests 13, 14, 16, 18, and 19.
|
||||
- **Actions:** for each successful call, inspect `CallToolResult`.
|
||||
- **Expected outcome:** `structured_content` is present; at least one text content item is present; text content is short and does not begin with `{` or `[`; `is_error` is absent or false. Source of truth: implementation plan successful tool-result invariant.
|
||||
- **Interactions:** `rmcp::model::CallToolResult` construction and MCP client display fallback.
|
||||
|
||||
24. **Pure conversion helpers cover JSON-to-TOML and answer-request mapping**
|
||||
- **Type:** unit
|
||||
- **Disposition:** new
|
||||
- **Harness:** `cargo nextest run -p fabro-mcp-server run_tools`
|
||||
- **Preconditions:** none beyond crate compilation.
|
||||
- **Actions:** call conversion helpers directly for strings, bools, integers, floats, arrays, objects, null input, and every supported answer payload shape.
|
||||
- **Expected outcome:** JSON-compatible input values map to equivalent `toml::Value`; null returns an error naming the key; answer payloads serialize to `SubmitAnswerRequest` wire JSON with documented `kind` values; unsupported answer objects return a tool error. Source of truth: implementation plan conversion requirements and `fabro-api` submit-answer round-trip tests.
|
||||
- **Interactions:** serde, generated API types, conversion error text.
|
||||
|
||||
25. **Existing MCP client crate behavior is not regressed**
|
||||
- **Type:** regression
|
||||
- **Disposition:** existing
|
||||
- **Harness:** existing `fabro-mcp` crate tests
|
||||
- **Preconditions:** repository builds with the new `fabro-mcp-server` crate added.
|
||||
- **Actions:** run `cargo nextest run -p fabro-mcp`.
|
||||
- **Expected outcome:** existing stdio client initialize/list/call tests pass. Source of truth: existing automated evidence and implementation plan decision to keep `fabro-mcp` as the external MCP client crate.
|
||||
- **Interactions:** workspace dependency feature unification for `rmcp`, existing client transport behavior.
|
||||
|
||||
26. **Relevant existing CLI run/auth regressions still pass**
|
||||
- **Type:** regression
|
||||
- **Disposition:** existing
|
||||
- **Harness:** existing `fabro-cli` integration tests
|
||||
- **Preconditions:** implementation complete.
|
||||
- **Actions:** run the existing tests matching `scenario::auth::auth_login_refresh_logout_flow`, `scenario::lifecycle::dry_run_create_start_attach_works_with_default_run_lookup`, and `cmd::ps::ps_explicit_local_tcp_target_uses_auth_store`; if names drift, list tests and run the corresponding auth/lifecycle/local-target checks.
|
||||
- **Expected outcome:** all selected tests pass unchanged. Source of truth: agreed strategy existing automated evidence and user requirement that MCP reuse CLI auth/config behavior.
|
||||
- **Interactions:** auth refresh/logout, local server run lifecycle, server target resolution.
|
||||
|
||||
27. **Final MCP command contract and workspace checks pass**
|
||||
- **Type:** regression
|
||||
- **Disposition:** extend
|
||||
- **Harness:** repository command checks
|
||||
- **Preconditions:** all feature implementation and snapshots complete.
|
||||
- **Actions:** run `cargo nextest run -p fabro-cli --test it cmd::mcp`, `cargo +nightly-2026-04-14 fmt --check --all`, `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`, `ulimit -n 4096 && cargo nextest run --workspace`, and `cargo insta pending-snapshots`.
|
||||
- **Expected outcome:** MCP command tests pass; formatting and clippy pass; workspace tests pass; no pending snapshots remain unless explicitly inspected and accepted for this feature. Source of truth: repository `AGENTS.md` build/test commands and snapshot policy.
|
||||
- **Interactions:** entire workspace, rustfmt/clippy pinned nightly, nextest parallelism and file descriptor limit.
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
Covered action space:
|
||||
|
||||
- CLI executable commands: `fabro mcp --help`, `fabro mcp start --help`, `fabro mcp config --help`, `fabro mcp init --help`, `fabro mcp config`, `fabro mcp config --server --storage-dir`, and `fabro mcp init claude|cursor|windsurf`.
|
||||
- MCP protocol actions: stdio process startup, `initialize`, `tools/list`, and `tools/call`.
|
||||
- MCP tool actions: `fabro_run_create`; `fabro_run_search`; `fabro_run_interact` actions `get`, `start`, `message`, `cancel`, `archive`, `unarchive`, `get_questions`, `answer`; `fabro_run_gather`; `fabro_run_events` actions `list`, `details`, and `search`.
|
||||
- Error and boundary behavior: invalid local parameters, too many run ids, null input conversion, missing action fields, unsupported answer shapes, invalid agent config JSON, missing auth, unreachable server after local validation, timeout expiry, and service liveness after tool errors.
|
||||
- Integration boundaries: CLI auth store reuse, Fabro API client, real local Fabro server, run manifest construction/validation, event store, generated API answer types, and existing `fabro-mcp` client crate.
|
||||
- Performance smoke: initialize plus `tools/list` without auth/server.
|
||||
|
||||
Explicitly excluded per the agreed strategy:
|
||||
|
||||
- Live LLM/provider tests. Dry-run workflows and local/mocked servers cover run-management behavior without external credentials or spend.
|
||||
- Manual QA of agent apps. `init` tests assert Fabro's written config path and JSON merge contract, not whether Claude/Cursor/Windsurf accept the file in a live app.
|
||||
- Browser/UI tests. This feature adds CLI and MCP stdio surfaces only.
|
||||
- Differential tests against Daytona or Devin. Their docs inspired shape, but no runnable reference implementation is available or required.
|
||||
|
||||
Residual risks:
|
||||
|
||||
- Agent config formats may evolve externally; tests protect Fabro's chosen file/path contract only.
|
||||
- MCP SDK behavior can change with `rmcp` upgrades; protocol tests and existing `fabro-mcp` tests should catch startup/list/call regressions.
|
||||
- Full workspace tests may be slower and subject to local FD limits; use the documented `ulimit -n 4096` command before `cargo nextest run --workspace`.
|
||||
1718
docs/plans/2026-05-11-add-fabro-mcp-server.md
Normal file
1718
docs/plans/2026-05-11-add-fabro-mcp-server.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -79,6 +79,7 @@ fabro [OPTIONS] [COMMAND]
|
|||
| `fabro inspect` | Show detailed information about a workflow run |
|
||||
| `fabro install` | Set up the Fabro environment (LLMs, certs, GitHub) |
|
||||
| `fabro logs` | View the raw worker tracing log of a workflow run |
|
||||
| `fabro mcp` | Model Context Protocol server |
|
||||
| `fabro model` | List and test LLM models |
|
||||
| `fabro pr` | Pull request operations |
|
||||
| `fabro preflight` | Validate run configuration without executing |
|
||||
|
|
@ -510,6 +511,73 @@ fabro logs [OPTIONS] <RUN>
|
|||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `-n, --tail <tail>` | Lines from end (default: all) |
|
||||
|
||||
### `fabro mcp`
|
||||
|
||||
Model Context Protocol server
|
||||
|
||||
```bash
|
||||
fabro mcp [OPTIONS] <COMMAND>
|
||||
```
|
||||
|
||||
#### Subcommands
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `fabro mcp config` | Print MCP client configuration JSON |
|
||||
| `fabro mcp init` | Configure an MCP client to launch Fabro |
|
||||
| `fabro mcp start` | Start the Fabro MCP server over stdio |
|
||||
|
||||
#### `fabro mcp config`
|
||||
|
||||
Print MCP client configuration JSON
|
||||
|
||||
```bash
|
||||
fabro mcp config [OPTIONS]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
|
||||
|
||||
#### `fabro mcp init`
|
||||
|
||||
Configure an MCP client to launch Fabro
|
||||
|
||||
```bash
|
||||
fabro mcp init [OPTIONS] <AGENT>
|
||||
```
|
||||
|
||||
#### Arguments
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `AGENT` | Values: `claude`, `cursor`, `windsurf` |
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
|
||||
|
||||
#### `fabro mcp start`
|
||||
|
||||
Start the Fabro MCP server over stdio
|
||||
|
||||
```bash
|
||||
fabro mcp start [OPTIONS]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
|
||||
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
|
||||
|
||||
### `fabro model`
|
||||
|
||||
List and test LLM models
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ mod tests {
|
|||
command: vec!["python3".into(), test_server],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,6 +493,8 @@ impl Session {
|
|||
resolved.push(McpServerSettings {
|
||||
name: config.name.clone(),
|
||||
transport: McpTransport::Http { url, headers },
|
||||
current_dir: config.current_dir.clone(),
|
||||
clear_env: config.clear_env,
|
||||
startup_timeout_secs: config.startup_timeout_secs,
|
||||
tool_timeout_secs: config.tool_timeout_secs,
|
||||
});
|
||||
|
|
@ -3367,6 +3369,8 @@ mod tests {
|
|||
command: vec!["python3".into(), test_server],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ fabro-hooks = { path = "../fabro-hooks" }
|
|||
fabro-install = { path = "../fabro-install" }
|
||||
fabro-interview = { path = "../fabro-interview" }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
fabro-mcp-server = { path = "../fabro-mcp-server" }
|
||||
fabro-manifest = { path = "../fabro-manifest" }
|
||||
fabro-proc = { path = "../fabro-proc" }
|
||||
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] }
|
||||
fabro-checkpoint = { path = "../fabro-checkpoint" }
|
||||
|
|
|
|||
|
|
@ -168,6 +168,49 @@ pub(crate) struct ServerConnectionArgs {
|
|||
pub(crate) target: ServerTargetArgs,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct McpNamespace {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: McpCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum McpCommand {
|
||||
/// Start the Fabro MCP server over stdio
|
||||
Start(McpStartArgs),
|
||||
/// Print MCP client configuration JSON
|
||||
Config(McpConfigArgs),
|
||||
/// Configure an MCP client to launch Fabro
|
||||
Init(McpInitArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub(crate) struct McpStartArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) connection: ServerConnectionArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub(crate) struct McpConfigArgs {
|
||||
#[command(flatten)]
|
||||
pub(crate) connection: ServerConnectionArgs,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub(crate) struct McpInitArgs {
|
||||
pub(crate) agent: McpAgent,
|
||||
|
||||
#[command(flatten)]
|
||||
pub(crate) connection: ServerConnectionArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum McpAgent {
|
||||
Claude,
|
||||
Cursor,
|
||||
Windsurf,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
pub(crate) struct InputOverrideArgs {
|
||||
/// Override a workflow input value (repeatable, format: KEY=VALUE)
|
||||
|
|
@ -1118,6 +1161,8 @@ pub(crate) enum Commands {
|
|||
#[command(subcommand)]
|
||||
command: Option<ModelsCommand>,
|
||||
},
|
||||
/// Model Context Protocol server
|
||||
Mcp(McpNamespace),
|
||||
/// Server operations
|
||||
Server(ServerNamespace),
|
||||
/// Check environment and integration health
|
||||
|
|
@ -1209,6 +1254,11 @@ impl Commands {
|
|||
Some(ModelsCommand::Test(_)) => "model test",
|
||||
None => "model",
|
||||
},
|
||||
Self::Mcp(ns) => match &ns.command {
|
||||
McpCommand::Start(_) => "mcp start",
|
||||
McpCommand::Config(_) => "mcp config",
|
||||
McpCommand::Init(_) => "mcp init",
|
||||
},
|
||||
Self::Server(ns) => match &ns.command {
|
||||
ServerCommand::Start(_) => "server start",
|
||||
ServerCommand::Stop(_) => "server stop",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ impl CommandContext {
|
|||
&self.cwd
|
||||
}
|
||||
|
||||
pub(crate) fn storage_dir(&self) -> &Path {
|
||||
&self.storage_dir
|
||||
}
|
||||
|
||||
pub(crate) fn run_settings(&self) -> Result<&RunNamespace> {
|
||||
self.run_settings
|
||||
.as_ref()
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ use std::io::Write;
|
|||
use anyhow::{Context, bail};
|
||||
use fabro_api::types;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
|
||||
use fabro_util::terminal::Styles;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::args::{GraphArgs, GraphDirection, GraphOutputFormat};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::commands::run::output::api_diagnostics_to_local;
|
||||
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest};
|
||||
use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, relative_path};
|
||||
|
||||
pub(crate) async fn run(
|
||||
|
|
|
|||
91
lib/crates/fabro-cli/src/commands/mcp/mod.rs
Normal file
91
lib/crates/fabro-cli/src/commands/mcp/mod.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use std::fmt::Write as _;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use crate::args::{McpAgent, McpCommand, McpNamespace, ServerConnectionArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::server_client;
|
||||
|
||||
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))?;
|
||||
let _ = write!(base_ctx.printer().stdout_important(), "{json}");
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Init(args) => {
|
||||
fabro_mcp_server::init_agent(&init_settings(args.agent, &args.connection)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn server_settings(
|
||||
base_ctx: &CommandContext,
|
||||
connection: &ServerConnectionArgs,
|
||||
) -> Result<fabro_mcp_server::FabroMcpServerSettings> {
|
||||
let connection_ctx = base_ctx.with_connection(connection)?;
|
||||
let target = connection.target.clone();
|
||||
let user_settings = connection_ctx.user_settings().clone();
|
||||
let storage_dir = connection_ctx.storage_dir().to_path_buf();
|
||||
let base_config_path = connection_ctx.base_config_path().to_path_buf();
|
||||
let config_path = base_config_path.clone();
|
||||
let client_factory: fabro_mcp_server::FabroClientFactory = std::sync::Arc::new(move || {
|
||||
let target = target.clone();
|
||||
let user_settings = user_settings.clone();
|
||||
let storage_dir = storage_dir.clone();
|
||||
let base_config_path = base_config_path.clone();
|
||||
let future: fabro_mcp_server::FabroClientFuture = Box::pin(async move {
|
||||
server_client::connect_server_with_settings(
|
||||
&target,
|
||||
&user_settings,
|
||||
&storage_dir,
|
||||
&base_config_path,
|
||||
)
|
||||
.await
|
||||
});
|
||||
future
|
||||
});
|
||||
Ok(fabro_mcp_server::FabroMcpServerSettings {
|
||||
client_factory,
|
||||
config_path,
|
||||
cwd: base_ctx.cwd().to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
fn init_settings(
|
||||
agent: McpAgent,
|
||||
connection: &ServerConnectionArgs,
|
||||
) -> Result<fabro_mcp_server::McpInitSettings> {
|
||||
Ok(fabro_mcp_server::McpInitSettings {
|
||||
agent: McpAgentForServer(agent).into(),
|
||||
config: config_settings(connection),
|
||||
home_dir: home_dir()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn config_settings(connection: &ServerConnectionArgs) -> fabro_mcp_server::McpConfigSettings {
|
||||
fabro_mcp_server::McpConfigSettings {
|
||||
server: connection.target.server.clone(),
|
||||
storage_dir: connection.storage_dir.clone_path(),
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> Result<std::path::PathBuf> {
|
||||
dirs::home_dir().context("failed to resolve home directory for MCP config")
|
||||
}
|
||||
|
||||
struct McpAgentForServer(McpAgent);
|
||||
|
||||
impl From<McpAgentForServer> for fabro_mcp_server::McpAgent {
|
||||
fn from(value: McpAgentForServer) -> Self {
|
||||
match value.0 {
|
||||
McpAgent::Claude => Self::Claude,
|
||||
McpAgent::Cursor => Self::Cursor,
|
||||
McpAgent::Windsurf => Self::Windsurf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ pub(crate) mod dump;
|
|||
pub(crate) mod exec;
|
||||
pub(crate) mod graph;
|
||||
pub(crate) mod install;
|
||||
pub(crate) mod mcp;
|
||||
pub(crate) mod model;
|
||||
pub(crate) mod parse;
|
||||
pub(crate) mod pr;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use anyhow::bail;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::PreflightArgs;
|
||||
|
|
@ -8,7 +9,7 @@ use crate::commands::run::output::{
|
|||
api_check_report_to_local, api_diagnostics_to_local, print_workflow_summary,
|
||||
};
|
||||
use crate::commands::run::overrides::preflight_args_overrides;
|
||||
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, preflight_manifest_args};
|
||||
use crate::manifest_args::preflight_manifest_args;
|
||||
use crate::shared::{cyan_spinner, print_json_pretty};
|
||||
|
||||
pub(crate) async fn execute(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use anyhow::{Context as _, bail};
|
||||
use fabro_config::RunLayer;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
|
||||
use fabro_server::manifest_validation;
|
||||
use fabro_types::RunId;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
|
@ -9,7 +10,7 @@ use super::output::{api_diagnostics_to_local, print_workflow_summary};
|
|||
use super::overrides::run_args_overrides;
|
||||
use crate::args::RunArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args};
|
||||
use crate::manifest_args::run_manifest_args;
|
||||
|
||||
pub(crate) struct CreatedRun {
|
||||
pub(crate) run_id: RunId,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@ use std::collections::HashMap;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_config::{
|
||||
CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
|
||||
RunSandboxLayer, parse_input_overrides,
|
||||
};
|
||||
use fabro_config::{CliLayer, CliOutputLayer, RunGoalLayer, RunLayer, parse_input_overrides};
|
||||
use fabro_manifest::{RunOverrideInput, build_run_overrides};
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_types::settings::cli::OutputVerbosity;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, RunMode};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
||||
|
|
@ -32,47 +29,6 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap<String, String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option<RunModelLayer> {
|
||||
if model.is_none() && provider.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunModelLayer {
|
||||
provider: provider.map(InterpString::parse),
|
||||
name: model.map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_layer(
|
||||
sandbox: Option<SandboxProvider>,
|
||||
preserve: Option<bool>,
|
||||
) -> Option<RunSandboxLayer> {
|
||||
if sandbox.is_none() && preserve.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunSandboxLayer {
|
||||
provider: sandbox.map(|p| p.to_string()),
|
||||
preserve,
|
||||
..RunSandboxLayer::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn execution_layer(dry_run: Option<bool>, auto_approve: Option<bool>) -> Option<RunExecutionLayer> {
|
||||
if dry_run.is_none() && auto_approve.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunExecutionLayer {
|
||||
mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
|
||||
approval: auto_approve.map(|a| {
|
||||
if a {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn cli_layer_for_verbose(verbose: bool) -> Option<CliLayer> {
|
||||
verbose.then(|| CliLayer {
|
||||
output: Some(CliOutputLayer {
|
||||
|
|
@ -119,24 +75,22 @@ fn current_dir_or_dot() -> PathBuf {
|
|||
}
|
||||
|
||||
pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverrides> {
|
||||
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
|
||||
let sandbox = sandbox_layer(
|
||||
args.sandbox.map(Into::into),
|
||||
sparse_flag(args.preserve_sandbox),
|
||||
);
|
||||
let execution = execution_layer(sparse_flag(args.dry_run), sparse_flag(args.auto_approve));
|
||||
|
||||
let cwd = current_dir_or_dot();
|
||||
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
|
||||
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
metadata: ReplaceMap::from(parse_labels(&args.label)),
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
..RunLayer::default()
|
||||
};
|
||||
let sandbox = args.sandbox.map(SandboxProvider::from);
|
||||
let sandbox_provider = sandbox.as_ref().map(ToString::to_string);
|
||||
let mut run = build_run_overrides(RunOverrideInput {
|
||||
goal: None,
|
||||
model: args.model.as_deref(),
|
||||
provider: args.provider.as_deref(),
|
||||
sandbox: sandbox_provider.as_deref(),
|
||||
docker_image: None,
|
||||
preserve_sandbox: sparse_flag(args.preserve_sandbox),
|
||||
dry_run: sparse_flag(args.dry_run),
|
||||
auto_approve: sparse_flag(args.auto_approve),
|
||||
labels: parse_labels(&args.label),
|
||||
});
|
||||
run.goal = goal;
|
||||
|
||||
Ok(ManifestSettingsOverrides {
|
||||
run: Some(run),
|
||||
|
|
@ -146,21 +100,23 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverr
|
|||
}
|
||||
|
||||
pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestSettingsOverrides> {
|
||||
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
|
||||
let sandbox = args.sandbox.map(|s| RunSandboxLayer {
|
||||
provider: Some(SandboxProvider::from(s).to_string()),
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
|
||||
let cwd = current_dir_or_dot();
|
||||
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
|
||||
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
model,
|
||||
sandbox,
|
||||
..RunLayer::default()
|
||||
};
|
||||
let sandbox_provider = args
|
||||
.sandbox
|
||||
.map(|sandbox| SandboxProvider::from(sandbox).to_string());
|
||||
let mut run = build_run_overrides(RunOverrideInput {
|
||||
goal: None,
|
||||
model: args.model.as_deref(),
|
||||
provider: args.provider.as_deref(),
|
||||
sandbox: sandbox_provider.as_deref(),
|
||||
docker_image: None,
|
||||
preserve_sandbox: None,
|
||||
dry_run: None,
|
||||
auto_approve: None,
|
||||
labels: HashMap::new(),
|
||||
});
|
||||
run.goal = goal;
|
||||
|
||||
Ok(ManifestSettingsOverrides {
|
||||
run: Some(run),
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ pub(crate) async fn start_run_with_client(
|
|||
run_id: &RunId,
|
||||
resume: bool,
|
||||
) -> Result<()> {
|
||||
client.start_run(run_id, resume).await
|
||||
client.start_run(run_id, resume).await.map(|_| ())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ async fn run_bulk(action: Action, identifiers: &[String], ctx: &CommandContext)
|
|||
Action::Unarchive => client.unarchive_run(&run_id).await,
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
Ok(_) => {
|
||||
let run_id_string = run_id.to_string();
|
||||
changed.push(run_id_string.clone());
|
||||
if !json {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use anyhow::bail;
|
||||
use fabro_config::RunLayer;
|
||||
use fabro_config::user::active_settings_path;
|
||||
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
|
||||
use fabro_server::manifest_validation;
|
||||
use fabro_util::terminal::Styles;
|
||||
|
||||
use crate::args::ValidateArgs;
|
||||
use crate::command_context::CommandContext;
|
||||
use crate::commands::run::output::api_diagnostics_to_local;
|
||||
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest};
|
||||
use crate::shared::{print_diagnostics, print_json_pretty, relative_path};
|
||||
|
||||
pub(crate) fn run(
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
#![expect(
|
||||
dead_code,
|
||||
reason = "the library exports manifest builder helpers while the binary owns most CLI dispatch"
|
||||
)]
|
||||
|
||||
mod args;
|
||||
mod manifest_builder;
|
||||
|
||||
pub use manifest_builder::{BuiltManifest, ManifestBuildInput, build_run_manifest};
|
||||
|
|
@ -10,11 +10,7 @@ mod gh;
|
|||
mod landing;
|
||||
mod local_server;
|
||||
mod logging;
|
||||
#[allow(
|
||||
unreachable_pub,
|
||||
reason = "The library exports manifest builder helpers for tests; the binary includes the same module privately."
|
||||
)]
|
||||
mod manifest_builder;
|
||||
mod manifest_args;
|
||||
mod server_client;
|
||||
mod server_runs;
|
||||
mod shared;
|
||||
|
|
@ -281,6 +277,9 @@ async fn main_inner(worker_token: Option<String>) -> (String, Result<()>) {
|
|||
Commands::Model { command } => {
|
||||
commands::model::execute(command, &base_ctx).await?;
|
||||
}
|
||||
Commands::Mcp(ns) => {
|
||||
commands::mcp::dispatch(ns, &base_ctx).await?;
|
||||
}
|
||||
Commands::Server(ns) => {
|
||||
Box::pin(commands::server::dispatch(
|
||||
ns.command,
|
||||
|
|
@ -1196,7 +1195,7 @@ destination = "{destination}"
|
|||
.expect("should parse");
|
||||
match *cli.command.unwrap() {
|
||||
Commands::RunCmd(RunCommands::Run(args)) => {
|
||||
let manifest_args = manifest_builder::run_manifest_args(&args)
|
||||
let manifest_args = manifest_args::run_manifest_args(&args)
|
||||
.expect("input-only args should be retained");
|
||||
assert_eq!(manifest_args.input, vec!["foo=bar"]);
|
||||
}
|
||||
|
|
|
|||
39
lib/crates/fabro-cli/src/manifest_args.rs
Normal file
39
lib/crates/fabro-cli/src/manifest_args.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use fabro_api::types;
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
||||
pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
|
||||
let payload = types::ManifestArgs {
|
||||
auto_approve: args.auto_approve.then_some(true),
|
||||
dry_run: args.dry_run.then_some(true),
|
||||
label: args.label.clone(),
|
||||
model: args.model.clone(),
|
||||
preserve_sandbox: args.preserve_sandbox.then_some(true),
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
.sandbox
|
||||
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
|
||||
docker_image: None,
|
||||
input: args.inputs.values.clone(),
|
||||
verbose: args.verbose.then_some(true),
|
||||
};
|
||||
(!fabro_manifest::manifest_args_is_empty(&payload)).then_some(payload)
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option<types::ManifestArgs> {
|
||||
let payload = types::ManifestArgs {
|
||||
auto_approve: None,
|
||||
dry_run: None,
|
||||
label: Vec::new(),
|
||||
model: args.model.clone(),
|
||||
preserve_sandbox: None,
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
.sandbox
|
||||
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
|
||||
docker_image: None,
|
||||
input: args.inputs.values.clone(),
|
||||
verbose: args.verbose.then_some(true),
|
||||
};
|
||||
(!fabro_manifest::manifest_args_is_empty(&payload)).then_some(payload)
|
||||
}
|
||||
|
|
@ -127,18 +127,7 @@ pub(crate) fn color_if(use_color: bool, color: Color) -> Option<Color> {
|
|||
}
|
||||
|
||||
pub(crate) fn run_status_kind(status: RunStatus) -> &'static str {
|
||||
match status {
|
||||
RunStatus::Submitted => "submitted",
|
||||
RunStatus::Queued => "queued",
|
||||
RunStatus::Starting => "starting",
|
||||
RunStatus::Running => "running",
|
||||
RunStatus::Blocked { .. } => "blocked",
|
||||
RunStatus::Paused { .. } => "paused",
|
||||
RunStatus::Removing => "removing",
|
||||
RunStatus::Succeeded { .. } => "succeeded",
|
||||
RunStatus::Failed { .. } => "failed",
|
||||
RunStatus::Dead => "dead",
|
||||
}
|
||||
status.kind().into()
|
||||
}
|
||||
|
||||
pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fn help() {
|
|||
archive Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings
|
||||
unarchive Restore archived runs to their prior terminal status
|
||||
model List and test LLM models
|
||||
mcp Model Context Protocol server
|
||||
server Server operations
|
||||
doctor Check environment and integration health
|
||||
version Show client and server version information
|
||||
|
|
|
|||
2305
lib/crates/fabro-cli/tests/it/cmd/mcp.rs
Normal file
2305
lib/crates/fabro-cli/tests/it/cmd/mcp.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,7 @@ mod inspect;
|
|||
mod install;
|
||||
mod json_global;
|
||||
mod logs;
|
||||
mod mcp;
|
||||
mod model;
|
||||
mod model_list;
|
||||
mod model_test;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fabro_cli::{ManifestBuildInput, build_run_manifest};
|
||||
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
|
||||
use fabro_workflow::ManifestPath;
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -794,25 +794,27 @@ impl Client {
|
|||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub async fn start_run(&self, run_id: &RunId, resume: bool) -> Result<()> {
|
||||
self.send_api(|client| async move {
|
||||
client
|
||||
.start_run()
|
||||
.id(run_id.to_string())
|
||||
.body(types::StartRunRequest { resume })
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
pub async fn start_run(&self, run_id: &RunId, resume: bool) -> Result<RunSummary> {
|
||||
let response = self
|
||||
.send_api(|client| async move {
|
||||
client
|
||||
.start_run()
|
||||
.id(run_id.to_string())
|
||||
.body(types::StartRunRequest { resume })
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn cancel_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.send_api(
|
||||
|client| async move { client.cancel_run().id(run_id.to_string()).send().await },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
pub async fn cancel_run(&self, run_id: &RunId) -> Result<RunSummary> {
|
||||
let response = self
|
||||
.send_api(
|
||||
|client| async move { client.cancel_run().id(run_id.to_string()).send().await },
|
||||
)
|
||||
.await?;
|
||||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn interrupt_run(&self, run_id: &RunId) -> Result<()> {
|
||||
|
|
@ -844,20 +846,22 @@ impl Client {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn archive_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.send_api(
|
||||
|client| async move { client.archive_run().id(run_id.to_string()).send().await },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
pub async fn archive_run(&self, run_id: &RunId) -> Result<RunSummary> {
|
||||
let response = self
|
||||
.send_api(
|
||||
|client| async move { client.archive_run().id(run_id.to_string()).send().await },
|
||||
)
|
||||
.await?;
|
||||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn unarchive_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.send_api(|client| async move {
|
||||
client.unarchive_run().id(run_id.to_string()).send().await
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
pub async fn unarchive_run(&self, run_id: &RunId) -> Result<RunSummary> {
|
||||
let response = self
|
||||
.send_api(
|
||||
|client| async move { client.unarchive_run().id(run_id.to_string()).send().await },
|
||||
)
|
||||
.await?;
|
||||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn rewind_run(
|
||||
|
|
@ -1123,6 +1127,50 @@ impl Client {
|
|||
Ok(all_events)
|
||||
}
|
||||
|
||||
pub async fn list_run_events_until(
|
||||
&self,
|
||||
run_id: &RunId,
|
||||
since_seq: Option<u32>,
|
||||
max_events: usize,
|
||||
) -> Result<Vec<EventEnvelope>> {
|
||||
if max_events == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut next_since_seq = since_seq;
|
||||
let mut all_events = Vec::new();
|
||||
while all_events.len() < max_events {
|
||||
let remaining = max_events - all_events.len();
|
||||
let response = self
|
||||
.send_api(|client| async move {
|
||||
let mut request = client
|
||||
.list_run_events()
|
||||
.id(run_id.to_string())
|
||||
.limit(remaining.min(1000) as u64);
|
||||
if let Some(seq) = next_since_seq.and_then(non_zero_u64_from_u32) {
|
||||
request = request.since_seq(seq);
|
||||
}
|
||||
request.send().await
|
||||
})
|
||||
.await?;
|
||||
let parsed = response.into_inner();
|
||||
let page_events = parsed
|
||||
.data
|
||||
.into_iter()
|
||||
.map(convert_type::<_, EventEnvelope>)
|
||||
.collect::<Result<Vec<EventEnvelope>>>()?;
|
||||
let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1));
|
||||
all_events.extend(page_events);
|
||||
|
||||
if !parsed.meta.has_more || next_page_since_seq.is_none() {
|
||||
break;
|
||||
}
|
||||
next_since_seq = next_page_since_seq;
|
||||
}
|
||||
|
||||
Ok(all_events)
|
||||
}
|
||||
|
||||
pub async fn attach_run_events(
|
||||
&self,
|
||||
run_id: &RunId,
|
||||
|
|
|
|||
|
|
@ -346,6 +346,8 @@ pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerS
|
|||
McpServerSettings {
|
||||
name: name.to_string(),
|
||||
transport,
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs,
|
||||
tool_timeout_secs,
|
||||
}
|
||||
|
|
|
|||
29
lib/crates/fabro-manifest/Cargo.toml
Normal file
29
lib/crates/fabro-manifest/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "fabro-manifest"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Fabro run manifest construction"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
fabro-api = { path = "../fabro-api" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
fabro-template = { path = "../fabro-template" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
fabro-workflow = { path = "../fabro-workflow" }
|
||||
git2.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
temp-env = "0.3"
|
||||
|
|
@ -10,19 +10,21 @@ use anyhow::{Context, Result, anyhow};
|
|||
use fabro_api::types;
|
||||
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
|
||||
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
|
||||
use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder};
|
||||
use fabro_config::{
|
||||
CliLayer, DaytonaDockerfileLayer, DockerSandboxLayer, ReplaceMap, RunExecutionLayer,
|
||||
RunGoalLayer, RunLayer, RunModelLayer, RunSandboxLayer, WorkflowSettingsBuilder,
|
||||
};
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{TemplateContext, render as render_template};
|
||||
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal};
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
|
||||
use fabro_types::{DirtyStatus, GitContext, PreRunPushOutcome, RunId, WorkflowSettings};
|
||||
use fabro_workflow::ManifestPath;
|
||||
use fabro_workflow::git::{
|
||||
GitSyncStatus, branch_needs_push, head_sha, push_branch_noninteractive, sync_status,
|
||||
};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManifestBuildInput {
|
||||
pub workflow: PathBuf,
|
||||
|
|
@ -43,6 +45,80 @@ pub struct BuiltManifest {
|
|||
pub target_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RunOverrideInput<'a> {
|
||||
pub goal: Option<&'a str>,
|
||||
pub model: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
pub sandbox: Option<&'a str>,
|
||||
pub docker_image: Option<&'a str>,
|
||||
pub preserve_sandbox: Option<bool>,
|
||||
pub dry_run: Option<bool>,
|
||||
pub auto_approve: Option<bool>,
|
||||
pub labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer {
|
||||
let goal = input
|
||||
.goal
|
||||
.map(|goal| RunGoalLayer::Inline(InterpString::parse(goal)));
|
||||
let model = (input.model.is_some() || input.provider.is_some()).then(|| RunModelLayer {
|
||||
provider: input.provider.map(InterpString::parse),
|
||||
name: input.model.map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
});
|
||||
let sandbox = (input.sandbox.is_some()
|
||||
|| input.docker_image.is_some()
|
||||
|| input.preserve_sandbox.is_some())
|
||||
.then(|| RunSandboxLayer {
|
||||
provider: input.sandbox.map(ToOwned::to_owned),
|
||||
docker: input.docker_image.map(|image| DockerSandboxLayer {
|
||||
image: Some(image.to_string()),
|
||||
..DockerSandboxLayer::default()
|
||||
}),
|
||||
preserve: input.preserve_sandbox,
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
let execution =
|
||||
(input.dry_run.is_some() || input.auto_approve.is_some()).then(|| RunExecutionLayer {
|
||||
mode: input.dry_run.map(|dry_run| {
|
||||
if dry_run {
|
||||
RunMode::DryRun
|
||||
} else {
|
||||
RunMode::Normal
|
||||
}
|
||||
}),
|
||||
approval: input.auto_approve.map(|auto_approve| {
|
||||
if auto_approve {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
RunLayer {
|
||||
goal,
|
||||
metadata: ReplaceMap::from(input.labels),
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
..RunLayer::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option<RunLayer> {
|
||||
let run = build_run_overrides(input);
|
||||
(run.goal.is_some()
|
||||
|| !run.metadata.is_empty()
|
||||
|| run.model.is_some()
|
||||
|| run.sandbox.is_some()
|
||||
|| run.execution.is_some())
|
||||
.then_some(run)
|
||||
}
|
||||
|
||||
struct CollectContext<'a> {
|
||||
cwd: &'a Path,
|
||||
inputs: &'a HashMap<String, toml::Value>,
|
||||
|
|
@ -172,42 +248,6 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
|
||||
let payload = types::ManifestArgs {
|
||||
auto_approve: args.auto_approve.then_some(true),
|
||||
dry_run: args.dry_run.then_some(true),
|
||||
label: args.label.clone(),
|
||||
model: args.model.clone(),
|
||||
preserve_sandbox: args.preserve_sandbox.then_some(true),
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
.sandbox
|
||||
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
|
||||
docker_image: None,
|
||||
input: args.inputs.values.clone(),
|
||||
verbose: args.verbose.then_some(true),
|
||||
};
|
||||
(!manifest_args_is_empty(&payload)).then_some(payload)
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option<types::ManifestArgs> {
|
||||
let payload = types::ManifestArgs {
|
||||
auto_approve: None,
|
||||
dry_run: None,
|
||||
label: Vec::new(),
|
||||
model: args.model.clone(),
|
||||
preserve_sandbox: None,
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
.sandbox
|
||||
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
|
||||
docker_image: None,
|
||||
input: args.inputs.values.clone(),
|
||||
verbose: args.verbose.then_some(true),
|
||||
};
|
||||
(!manifest_args_is_empty(&payload)).then_some(payload)
|
||||
}
|
||||
|
||||
fn collect_workflow_entry(
|
||||
context: &mut CollectContext<'_>,
|
||||
workflow: &Path,
|
||||
|
|
@ -650,7 +690,7 @@ fn manifest_path_from_absolute(path: &Path, cwd: &Path) -> Result<ManifestPath>
|
|||
.ok_or_else(|| anyhow!("Failed to compute manifest path for {}", path.display()))
|
||||
}
|
||||
|
||||
fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
||||
pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
||||
args.auto_approve.is_none()
|
||||
&& args.dry_run.is_none()
|
||||
&& args.label.is_empty()
|
||||
|
|
@ -667,6 +707,65 @@ fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_run_overrides_sets_common_cli_and_mcp_layers() {
|
||||
let overrides = build_run_overrides(RunOverrideInput {
|
||||
goal: Some("ship it"),
|
||||
model: Some("gpt-5.4-mini"),
|
||||
provider: Some("openai"),
|
||||
sandbox: Some("local"),
|
||||
docker_image: None,
|
||||
preserve_sandbox: Some(true),
|
||||
dry_run: Some(true),
|
||||
auto_approve: Some(false),
|
||||
labels: [("source".to_string(), "mcp".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
});
|
||||
|
||||
let goal = overrides.goal.expect("goal override");
|
||||
assert!(matches!(goal, fabro_config::RunGoalLayer::Inline(_)));
|
||||
assert_eq!(
|
||||
overrides
|
||||
.model
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.name
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_source(),
|
||||
"gpt-5.4-mini"
|
||||
);
|
||||
assert_eq!(
|
||||
overrides
|
||||
.model
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.provider
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_source(),
|
||||
"openai"
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.sandbox.as_ref().unwrap().provider.as_deref(),
|
||||
Some("local")
|
||||
);
|
||||
assert_eq!(overrides.sandbox.as_ref().unwrap().preserve, Some(true));
|
||||
assert_eq!(
|
||||
overrides.execution.as_ref().unwrap().mode,
|
||||
Some(RunMode::DryRun)
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.execution.as_ref().unwrap().approval,
|
||||
Some(ApprovalMode::Prompt)
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.metadata.0.get("source").map(String::as_str),
|
||||
Some("mcp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_manifest_bundles_imports_prompts_and_children() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
31
lib/crates/fabro-mcp-server/Cargo.toml
Normal file
31
lib/crates/fabro-mcp-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "fabro-mcp-server"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Fabro MCP stdio server"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
fabro-api = { path = "../fabro-api" }
|
||||
fabro-client = { path = "../fabro-client" }
|
||||
fabro-manifest = { path = "../fabro-manifest" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-server = { path = "../fabro-server" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
futures.workspace = true
|
||||
rmcp = { workspace = true, features = ["server", "macros", "schemars", "transport-io"] }
|
||||
schemars = "1.2.1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
146
lib/crates/fabro-mcp-server/src/config.rs
Normal file
146
lib/crates/fabro-mcp-server/src/config.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "MCP client config setup intentionally performs small synchronous JSON file reads/writes from a CLI command."
|
||||
)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use serde_json::map::Entry;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{McpAgent, McpConfigSettings, McpInitSettings};
|
||||
|
||||
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 entry = server_entry(&settings.config);
|
||||
for path in agent_config_paths(settings.agent, &settings.home_dir) {
|
||||
merge_server_entry(&path, entry.clone())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generic_config(settings: &McpConfigSettings) -> Value {
|
||||
json!({
|
||||
"mcpServers": {
|
||||
SERVER_NAME: server_entry(settings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn server_entry(settings: &McpConfigSettings) -> Value {
|
||||
json!({
|
||||
"command": "fabro",
|
||||
"args": start_args(settings),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn merge_server_entry(path: &Path, entry: Value) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut root = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => serde_json::from_str::<Value>(&contents)
|
||||
.with_context(|| format!("failed to parse MCP config {}", path.display()))?,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
|
||||
Err(err) => return Err(err).with_context(|| format!("failed to read {}", path.display())),
|
||||
};
|
||||
|
||||
let root_object = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("MCP config {} must contain a JSON object", path.display()))?;
|
||||
|
||||
let servers = match root_object.entry("mcpServers") {
|
||||
Entry::Vacant(entry) => entry.insert(Value::Object(Map::new())),
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
};
|
||||
let servers_object = servers.as_object_mut().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"MCP config {} field mcpServers must contain a JSON object",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
servers_object.insert(SERVER_NAME.to_string(), entry);
|
||||
|
||||
let rendered = serde_json::to_string_pretty(&root)
|
||||
.map(|json| format!("{json}\n"))
|
||||
.with_context(|| format!("failed to render MCP config {}", path.display()))?;
|
||||
std::fs::write(path, rendered).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn agent_config_paths(agent: McpAgent, home_dir: &Path) -> Vec<PathBuf> {
|
||||
match agent {
|
||||
McpAgent::Claude => vec![
|
||||
claude_desktop_config_path(home_dir),
|
||||
claude_code_config_path(home_dir),
|
||||
],
|
||||
McpAgent::Cursor => vec![home_dir.join(".cursor").join("mcp.json")],
|
||||
McpAgent::Windsurf => vec![
|
||||
home_dir
|
||||
.join(".codeium")
|
||||
.join("windsurf")
|
||||
.join("mcp_config.json"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_code_config_path(home_dir: &Path) -> PathBuf {
|
||||
home_dir.join(".claude.json")
|
||||
}
|
||||
|
||||
fn claude_desktop_config_path(home_dir: &Path) -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
home_dir
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Claude")
|
||||
.join("claude_desktop_config.json")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
home_dir
|
||||
.join(".config")
|
||||
.join("Claude")
|
||||
.join("claude_desktop_config.json")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = std::env::var_os("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home_dir.join("AppData").join("Roaming"));
|
||||
app_data.join("Claude").join("claude_desktop_config.json")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
home_dir
|
||||
.join(".config")
|
||||
.join("Claude")
|
||||
.join("claude_desktop_config.json")
|
||||
}
|
||||
}
|
||||
55
lib/crates/fabro-mcp-server/src/lib.rs
Normal file
55
lib/crates/fabro-mcp-server/src/lib.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
mod config;
|
||||
mod run_tools;
|
||||
mod server;
|
||||
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
pub use config::{config_json, init_agent};
|
||||
use fabro_client::Client;
|
||||
pub use server::start;
|
||||
|
||||
pub type FabroClientFuture = Pin<Box<dyn Future<Output = Result<Client>> + Send>>;
|
||||
|
||||
pub type FabroClientFactory = Arc<dyn Fn() -> FabroClientFuture + Send + Sync>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FabroMcpServerSettings {
|
||||
pub client_factory: FabroClientFactory,
|
||||
pub config_path: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FabroMcpServerSettings {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("FabroMcpServerSettings")
|
||||
.field("client_factory", &"<factory>")
|
||||
.field("config_path", &self.config_path)
|
||||
.field("cwd", &self.cwd)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct McpConfigSettings {
|
||||
pub server: Option<String>,
|
||||
pub storage_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpInitSettings {
|
||||
pub agent: McpAgent,
|
||||
pub config: McpConfigSettings,
|
||||
pub home_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum McpAgent {
|
||||
Claude,
|
||||
Cursor,
|
||||
Windsurf,
|
||||
}
|
||||
21
lib/crates/fabro-mcp-server/src/run_tools.rs
Normal file
21
lib/crates/fabro-mcp-server/src/run_tools.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#![allow(
|
||||
dead_code,
|
||||
reason = "MCP DTO fields are consumed by serde and schema generation even when not read directly."
|
||||
)]
|
||||
|
||||
mod common;
|
||||
mod create;
|
||||
mod events;
|
||||
mod gather;
|
||||
mod interact;
|
||||
mod manifest;
|
||||
mod search;
|
||||
|
||||
pub(crate) use common::{ToolError, error_result, success_result};
|
||||
pub(crate) use create::{FabroRunCreateParams, ValidatedCreateRuns, create_runs, create_runs_text};
|
||||
pub(crate) use events::{FabroRunEventsParams, ValidatedRunEvents, run_events, run_events_text};
|
||||
pub(crate) use gather::{FabroRunGatherParams, ValidatedGatherRuns, gather_runs, gather_runs_text};
|
||||
pub(crate) use interact::{
|
||||
FabroRunInteractParams, ValidatedInteractRun, interact_run, interact_run_text,
|
||||
};
|
||||
pub(crate) use search::{FabroRunSearchParams, ValidatedSearchRuns, search_runs, search_runs_text};
|
||||
141
lib/crates/fabro-mcp-server/src/run_tools/common.rs
Normal file
141
lib/crates/fabro-mcp-server/src/run_tools/common.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use fabro_client::Client;
|
||||
use fabro_types::{Run, RunId, RunStatus};
|
||||
use fabro_util::exit::{self, ExitClass};
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Serialize;
|
||||
|
||||
#[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(super) type ToolResult<T> = Result<T, ToolError>;
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct RunSummaryResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) workflow_name: String,
|
||||
pub(crate) workflow_slug: Option<String>,
|
||||
pub(crate) status: String,
|
||||
pub(crate) archived: bool,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) started_at: Option<String>,
|
||||
pub(crate) completed_at: Option<String>,
|
||||
pub(crate) labels: HashMap<String, String>,
|
||||
pub(crate) source_directory: Option<String>,
|
||||
pub(crate) repo_origin_url: Option<String>,
|
||||
pub(crate) goal: String,
|
||||
}
|
||||
|
||||
pub(crate) fn success_result<T: Serialize>(
|
||||
value: &T,
|
||||
text: impl Into<String>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
let structured_content = serde_json::to_value(value).map_err(|err| {
|
||||
rmcp::ErrorData::internal_error(
|
||||
format!("failed to serialize Fabro MCP tool result: {err}"),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
let mut result = CallToolResult::structured(structured_content);
|
||||
result.content = vec![Content::text(text.into())];
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn error_result(err: ToolError) -> CallToolResult {
|
||||
CallToolResult::error(vec![Content::text(err.message)])
|
||||
}
|
||||
|
||||
pub(super) fn validate_len(name: &str, len: usize, min: usize, max: usize) -> ToolResult<()> {
|
||||
if len < min {
|
||||
return Err(ToolError::message(format!(
|
||||
"{name} must contain at least {min} item(s)"
|
||||
)));
|
||||
}
|
||||
if len > max {
|
||||
return Err(ToolError::message(format!(
|
||||
"{name} must contain no more than {max} item(s)"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn retrieve_run(client: &Client, run_id: &RunId) -> ToolResult<Run> {
|
||||
client
|
||||
.retrieve_run(run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))
|
||||
}
|
||||
|
||||
pub(super) fn run_summary_result(run: &Run) -> RunSummaryResult {
|
||||
RunSummaryResult {
|
||||
run_id: run.id.to_string(),
|
||||
workflow_name: run.workflow.name.clone(),
|
||||
workflow_slug: run.workflow.slug.clone(),
|
||||
status: run_status_kind(run.lifecycle.status).to_string(),
|
||||
archived: run.lifecycle.archived,
|
||||
created_at: run.timestamps.created_at.to_rfc3339(),
|
||||
started_at: run
|
||||
.timestamps
|
||||
.started_at
|
||||
.map(|timestamp| timestamp.to_rfc3339()),
|
||||
completed_at: run
|
||||
.timestamps
|
||||
.completed_at
|
||||
.map(|timestamp| timestamp.to_rfc3339()),
|
||||
labels: run.labels.clone(),
|
||||
source_directory: run.source_directory.clone(),
|
||||
repo_origin_url: run
|
||||
.repository
|
||||
.as_ref()
|
||||
.and_then(|repository| repository.origin_url.clone()),
|
||||
goal: run.goal.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_datetime_filter(name: &str, raw: &str) -> ToolResult<DateTime<Utc>> {
|
||||
if let Ok(timestamp) = DateTime::parse_from_rfc3339(raw) {
|
||||
return Ok(timestamp.with_timezone(&Utc));
|
||||
}
|
||||
let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").map_err(|err| {
|
||||
ToolError::message(format!("{name} must be RFC3339 or YYYY-MM-DD: {err}"))
|
||||
})?;
|
||||
let datetime = date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ToolError::message(format!("{name} contains an invalid date")))?;
|
||||
Ok(DateTime::from_naive_utc_and_offset(datetime, Utc))
|
||||
}
|
||||
|
||||
pub(super) fn run_status_kind(status: RunStatus) -> &'static str {
|
||||
status.kind().into()
|
||||
}
|
||||
|
||||
fn format_tool_error(err: &anyhow::Error) -> String {
|
||||
let mut rendered = format!("{err:#}");
|
||||
if exit::exit_class_for(err) == Some(ExitClass::AuthRequired)
|
||||
&& !rendered.contains("fabro auth login")
|
||||
{
|
||||
rendered.push_str("\nRun `fabro auth login` to authenticate.");
|
||||
}
|
||||
rendered
|
||||
}
|
||||
231
lib/crates/fabro-mcp-server/src/run_tools/create.rs
Normal file
231
lib/crates/fabro-mcp-server/src/run_tools/create.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_client::Client;
|
||||
use fabro_types::RunId;
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::common::{ToolError, ToolResult};
|
||||
use super::{common, manifest};
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunCreateParams {
|
||||
pub(crate) runs: Vec<CreateRunSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct CreateRunSpec {
|
||||
pub(crate) workflow: String,
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) run_id: Option<String>,
|
||||
pub(crate) goal: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) inputs: HashMap<String, RunInputValue>,
|
||||
#[serde(default)]
|
||||
pub(crate) labels: HashMap<String, String>,
|
||||
pub(crate) dry_run: Option<bool>,
|
||||
pub(crate) auto_approve: Option<bool>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) provider: Option<String>,
|
||||
pub(crate) sandbox: Option<String>,
|
||||
pub(crate) preserve_sandbox: Option<bool>,
|
||||
pub(crate) start: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub(crate) struct RunInputValue(Value);
|
||||
|
||||
impl From<Value> for RunInputValue {
|
||||
fn from(value: Value) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl RunInputValue {
|
||||
fn into_inner(self) -> Value {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RunInputValue {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RunInputValue".into()
|
||||
}
|
||||
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Run input override value. Inputs are TOML-compatible scalar values: string, boolean, integer, or float.",
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "boolean" },
|
||||
{ "type": "integer" },
|
||||
{ "type": "number" }
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedCreateRuns {
|
||||
pub(crate) runs: Vec<ValidatedCreateRunSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedCreateRunSpec {
|
||||
pub(crate) workflow: String,
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
pub(crate) run_id: Option<RunId>,
|
||||
pub(crate) goal: Option<String>,
|
||||
pub(crate) inputs: HashMap<String, toml::Value>,
|
||||
pub(crate) labels: HashMap<String, String>,
|
||||
pub(crate) dry_run: Option<bool>,
|
||||
pub(crate) auto_approve: Option<bool>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) provider: Option<String>,
|
||||
pub(crate) sandbox: Option<String>,
|
||||
pub(crate) preserve_sandbox: Option<bool>,
|
||||
pub(crate) start: Option<bool>,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunCreateParams> for ValidatedCreateRuns {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunCreateParams) -> Result<Self, Self::Error> {
|
||||
common::validate_len("runs", params.runs.len(), 1, 50)?;
|
||||
let runs = params
|
||||
.runs
|
||||
.into_iter()
|
||||
.map(ValidatedCreateRunSpec::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Self { runs })
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<CreateRunSpec> for ValidatedCreateRunSpec {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(spec: CreateRunSpec) -> Result<Self, Self::Error> {
|
||||
let run_id = spec
|
||||
.run_id
|
||||
.as_deref()
|
||||
.map(str::parse::<RunId>)
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
ToolError::message(format!("run_id must be a valid Fabro run id: {err}"))
|
||||
})?;
|
||||
let inputs = spec
|
||||
.inputs
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let value = value.into_inner();
|
||||
manifest::json_to_toml_value(&key, &value).map(|value| (key, value))
|
||||
})
|
||||
.collect::<ToolResult<HashMap<_, _>>>()?;
|
||||
Ok(Self {
|
||||
workflow: spec.workflow,
|
||||
cwd: spec.cwd,
|
||||
run_id,
|
||||
goal: spec.goal,
|
||||
inputs,
|
||||
labels: spec.labels,
|
||||
dry_run: spec.dry_run,
|
||||
auto_approve: spec.auto_approve,
|
||||
model: spec.model,
|
||||
provider: spec.provider,
|
||||
sandbox: spec.sandbox,
|
||||
preserve_sandbox: spec.preserve_sandbox,
|
||||
start: spec.start,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct CreateRunsResult {
|
||||
pub(crate) runs: Vec<CreatedRunResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct CreatedRunResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) workflow: String,
|
||||
pub(crate) started: bool,
|
||||
pub(crate) status: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn create_runs(
|
||||
client: Arc<Client>,
|
||||
base_cwd: &Path,
|
||||
user_settings_path: &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 manifest = manifest::build_mcp_run_manifest(&spec, &cwd, user_settings_path)?;
|
||||
let run_id = client
|
||||
.create_run_from_manifest(manifest)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
let started = spec.start.unwrap_or(true);
|
||||
let summary = if started {
|
||||
client
|
||||
.start_run(&run_id, false)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
} else {
|
||||
client
|
||||
.retrieve_run(&run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
};
|
||||
created.push(CreatedRunResult {
|
||||
run_id: summary.id.to_string(),
|
||||
workflow: spec.workflow,
|
||||
started,
|
||||
status: common::run_status_kind(summary.lifecycle.status).to_string(),
|
||||
});
|
||||
}
|
||||
Ok(CreateRunsResult { runs: created })
|
||||
}
|
||||
|
||||
pub(crate) fn create_runs_text(result: &CreateRunsResult) -> String {
|
||||
let started = result.runs.iter().filter(|run| run.started).count();
|
||||
format!(
|
||||
"created {} Fabro run(s), started {started}",
|
||||
result.runs.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use schemars::SchemaGenerator;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_input_value_schema_allows_only_json_scalars() {
|
||||
let mut generator = SchemaGenerator::default();
|
||||
let schema = RunInputValue::json_schema(&mut generator);
|
||||
let schema = serde_json::to_value(schema).expect("schema should serialize");
|
||||
|
||||
assert_eq!(
|
||||
schema["anyOf"],
|
||||
json!([
|
||||
{ "type": "string" },
|
||||
{ "type": "boolean" },
|
||||
{ "type": "integer" },
|
||||
{ "type": "number" },
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
313
lib/crates/fabro-mcp-server/src/run_tools/events.rs
Normal file
313
lib/crates/fabro-mcp-server/src/run_tools/events.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_client::Client;
|
||||
use fabro_types::EventEnvelope;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::common;
|
||||
use super::common::{ToolError, ToolResult};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum RunEventsAction {
|
||||
List,
|
||||
Details,
|
||||
Search,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunEventsParams {
|
||||
pub(crate) action: RunEventsAction,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) event_types: Option<Vec<String>>,
|
||||
pub(crate) categories: Option<Vec<String>>,
|
||||
pub(crate) direction: Option<String>,
|
||||
pub(crate) created_after: Option<String>,
|
||||
pub(crate) created_before: Option<String>,
|
||||
pub(crate) first: Option<usize>,
|
||||
pub(crate) after: Option<u32>,
|
||||
pub(crate) event_ids: Option<Vec<String>>,
|
||||
pub(crate) offset: Option<usize>,
|
||||
pub(crate) limit: Option<usize>,
|
||||
pub(crate) max_content_length: Option<usize>,
|
||||
pub(crate) query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedRunEvents {
|
||||
pub(crate) raw: FabroRunEventsParams,
|
||||
pub(crate) descending: bool,
|
||||
pub(crate) first: usize,
|
||||
pub(crate) created_after: Option<DateTime<Utc>>,
|
||||
pub(crate) created_before: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunEventsParams> for ValidatedRunEvents {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunEventsParams) -> Result<Self, Self::Error> {
|
||||
if params.run_id.trim().is_empty() {
|
||||
return Err(ToolError::message("run_id is required"));
|
||||
}
|
||||
let first = params.first.or(params.limit).unwrap_or(50);
|
||||
if first > 200 {
|
||||
return Err(ToolError::message("first must be <= 200"));
|
||||
}
|
||||
let descending = match params.direction.as_deref() {
|
||||
None | Some("asc") => false,
|
||||
Some("desc") => true,
|
||||
Some(_) => return Err(ToolError::message("direction must be `asc` or `desc`")),
|
||||
};
|
||||
let created_after = params
|
||||
.created_after
|
||||
.as_deref()
|
||||
.map(|created_after| common::parse_datetime_filter("created_after", created_after))
|
||||
.transpose()?;
|
||||
let created_before = params
|
||||
.created_before
|
||||
.as_deref()
|
||||
.map(|created_before| common::parse_datetime_filter("created_before", created_before))
|
||||
.transpose()?;
|
||||
if matches!(params.action, RunEventsAction::Details)
|
||||
&& params.event_ids.as_ref().is_none_or(Vec::is_empty)
|
||||
{
|
||||
return Err(ToolError::message(
|
||||
"event_ids is required for details action",
|
||||
));
|
||||
}
|
||||
if matches!(params.action, RunEventsAction::Search)
|
||||
&& params
|
||||
.query
|
||||
.as_deref()
|
||||
.is_none_or(|query| query.trim().is_empty())
|
||||
{
|
||||
return Err(ToolError::message("query is required for search action"));
|
||||
}
|
||||
Ok(Self {
|
||||
raw: params,
|
||||
descending,
|
||||
first,
|
||||
created_after,
|
||||
created_before,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct RunEventsResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) action: RunEventsAction,
|
||||
pub(crate) events: Vec<RunEventResult>,
|
||||
pub(crate) next_cursor: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct RunEventResult {
|
||||
pub(crate) event_id: String,
|
||||
pub(crate) sequence: u32,
|
||||
pub(crate) event: Value,
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_events(
|
||||
client: Arc<Client>,
|
||||
params: ValidatedRunEvents,
|
||||
) -> ToolResult<RunEventsResult> {
|
||||
let descending = params.descending;
|
||||
let first = params.first;
|
||||
let created_after = params.created_after;
|
||||
let created_before = params.created_before;
|
||||
let raw = params.raw;
|
||||
let run_id = client
|
||||
.resolve_run(&raw.run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
.id;
|
||||
let fetch_after = if descending { None } else { raw.after };
|
||||
let mut events = if let Some(limit) = event_fetch_limit(&raw, first) {
|
||||
client
|
||||
.list_run_events_until(&run_id, fetch_after, limit)
|
||||
.await
|
||||
} else {
|
||||
client.list_run_events(&run_id, fetch_after, None).await
|
||||
}
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
if descending {
|
||||
if let Some(after) = raw.after {
|
||||
events.retain(|event| event.seq < after);
|
||||
}
|
||||
}
|
||||
filter_events(&mut events, &raw, created_after, created_before);
|
||||
if descending {
|
||||
events.reverse();
|
||||
}
|
||||
let offset = raw.offset.unwrap_or(0);
|
||||
let page = events
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(first)
|
||||
.collect::<Vec<_>>();
|
||||
let max_content_length = raw.max_content_length.unwrap_or(20_000);
|
||||
let results = page
|
||||
.iter()
|
||||
.map(|event| run_event_result(event, max_content_length))
|
||||
.collect::<ToolResult<Vec<_>>>()?;
|
||||
let next_cursor = page.last().map(|event| {
|
||||
if descending {
|
||||
event.seq
|
||||
} else {
|
||||
event.seq.saturating_add(1)
|
||||
}
|
||||
});
|
||||
|
||||
Ok(RunEventsResult {
|
||||
run_id: run_id.to_string(),
|
||||
action: raw.action,
|
||||
events: results,
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_events_text(result: &RunEventsResult) -> String {
|
||||
format!("returned {} Fabro event(s)", result.events.len())
|
||||
}
|
||||
|
||||
fn event_fetch_limit(params: &FabroRunEventsParams, first: usize) -> Option<usize> {
|
||||
let needs_full_scan = params.event_ids.is_some()
|
||||
|| params.event_types.is_some()
|
||||
|| params.categories.is_some()
|
||||
|| params.created_after.is_some()
|
||||
|| params.created_before.is_some()
|
||||
|| params.direction.as_deref() == Some("desc")
|
||||
|| matches!(
|
||||
params.action,
|
||||
RunEventsAction::Details | RunEventsAction::Search
|
||||
);
|
||||
if needs_full_scan {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requested = first.saturating_add(params.offset.unwrap_or(0));
|
||||
Some(requested.max(1))
|
||||
}
|
||||
|
||||
fn filter_events(
|
||||
events: &mut Vec<EventEnvelope>,
|
||||
params: &FabroRunEventsParams,
|
||||
created_after: Option<DateTime<Utc>>,
|
||||
created_before: Option<DateTime<Utc>>,
|
||||
) {
|
||||
if let Some(event_ids) = params.event_ids.as_ref() {
|
||||
events.retain(|event| event_ids.contains(&event.event.id));
|
||||
}
|
||||
if let Some(event_types) = params.event_types.as_ref() {
|
||||
events.retain(|event| {
|
||||
event_types
|
||||
.iter()
|
||||
.any(|event_type| event_type == event.event.event_name())
|
||||
});
|
||||
}
|
||||
if let Some(categories) = params.categories.as_ref() {
|
||||
events.retain(|event| {
|
||||
let category = event
|
||||
.event
|
||||
.event_name()
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
categories.iter().any(|candidate| candidate == category)
|
||||
});
|
||||
}
|
||||
if let Some(cutoff) = created_after {
|
||||
events.retain(|event| event.event.ts >= cutoff);
|
||||
}
|
||||
if let Some(cutoff) = created_before {
|
||||
events.retain(|event| event.event.ts <= cutoff);
|
||||
}
|
||||
if matches!(params.action, RunEventsAction::Search) {
|
||||
if let Some(query) = params.query.as_deref() {
|
||||
events.retain(|event| {
|
||||
serde_json::to_string(event).is_ok_and(|serialized| serialized.contains(query))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_event_result(
|
||||
event: &EventEnvelope,
|
||||
max_content_length: usize,
|
||||
) -> ToolResult<RunEventResult> {
|
||||
let mut serialized = serde_json::to_string(event)
|
||||
.map_err(|err| ToolError::message(format!("failed to serialize event: {err}")))?;
|
||||
let truncated = serialized.len() > max_content_length;
|
||||
let event_value = if truncated {
|
||||
serialized.truncate(floor_char_boundary(&serialized, max_content_length));
|
||||
Value::String(serialized)
|
||||
} else {
|
||||
serde_json::to_value(event)
|
||||
.map_err(|err| ToolError::message(format!("failed to serialize event: {err}")))?
|
||||
};
|
||||
Ok(RunEventResult {
|
||||
event_id: event.event.id.clone(),
|
||||
sequence: event.seq,
|
||||
event: event_value,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
fn floor_char_boundary(value: &str, max_len: usize) -> usize {
|
||||
let mut boundary = max_len.min(value.len());
|
||||
while !value.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
boundary
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
use fabro_types::{EventBody, EventEnvelope, RunEvent, fixtures};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_event_result_truncates_at_utf8_boundary() {
|
||||
let event = EventEnvelope {
|
||||
seq: 1,
|
||||
event: RunEvent {
|
||||
id: "evt_utf8".to_string(),
|
||||
ts: Utc::now(),
|
||||
run_id: fixtures::RUN_1,
|
||||
node_id: None,
|
||||
node_label: None,
|
||||
stage_id: None,
|
||||
parallel_group_id: None,
|
||||
parallel_branch_id: None,
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
actor: None,
|
||||
body: EventBody::Unknown {
|
||||
name: "test.utf8".to_string(),
|
||||
properties: json!({ "message": "éééé" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
let first_multibyte = serialized
|
||||
.find('é')
|
||||
.expect("serialized event should contain é");
|
||||
|
||||
let result = run_event_result(&event, first_multibyte + 1).unwrap();
|
||||
|
||||
assert!(result.truncated);
|
||||
let Value::String(event_json) = result.event else {
|
||||
panic!("truncated events should return string payloads");
|
||||
};
|
||||
assert!(event_json.is_char_boundary(event_json.len()));
|
||||
}
|
||||
}
|
||||
109
lib/crates/fabro-mcp-server/src/run_tools/gather.rs
Normal file
109
lib/crates/fabro-mcp-server/src/run_tools/gather.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fabro_client::Client;
|
||||
use futures::future::try_join_all;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time;
|
||||
|
||||
use super::common;
|
||||
use super::common::{RunSummaryResult, ToolError, ToolResult};
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunGatherParams {
|
||||
pub(crate) run_ids: Vec<String>,
|
||||
pub(crate) timeout_seconds: Option<u64>,
|
||||
pub(crate) poll_interval_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedGatherRuns {
|
||||
pub(crate) run_ids: Vec<String>,
|
||||
pub(crate) timeout_seconds: u64,
|
||||
pub(crate) poll_interval_seconds: u64,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunGatherParams> for ValidatedGatherRuns {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunGatherParams) -> Result<Self, Self::Error> {
|
||||
common::validate_len("run_ids", params.run_ids.len(), 1, 50)?;
|
||||
if params.timeout_seconds.is_some_and(|timeout| timeout > 600) {
|
||||
return Err(ToolError::message("timeout_seconds must be <= 600"));
|
||||
}
|
||||
if params
|
||||
.poll_interval_seconds
|
||||
.is_some_and(|interval| interval < 5)
|
||||
{
|
||||
return Err(ToolError::message("poll_interval_seconds must be >= 5"));
|
||||
}
|
||||
Ok(Self {
|
||||
run_ids: params.run_ids,
|
||||
timeout_seconds: params.timeout_seconds.unwrap_or(300),
|
||||
poll_interval_seconds: params.poll_interval_seconds.unwrap_or(15),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct GatherRunsResult {
|
||||
pub(crate) runs: Vec<RunSummaryResult>,
|
||||
pub(crate) timed_out: bool,
|
||||
pub(crate) elapsed_seconds: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn gather_runs(
|
||||
client: Arc<Client>,
|
||||
params: ValidatedGatherRuns,
|
||||
) -> ToolResult<GatherRunsResult> {
|
||||
let start = Instant::now();
|
||||
let deadline = start + Duration::from_secs(params.timeout_seconds);
|
||||
let run_ids = try_join_all(params.run_ids.into_iter().map(|selector| {
|
||||
let client = Arc::clone(&client);
|
||||
async move {
|
||||
client
|
||||
.resolve_run(&selector)
|
||||
.await
|
||||
.map(|run| run.id)
|
||||
.map_err(|err| ToolError::from_anyhow(&err))
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
loop {
|
||||
let summaries = try_join_all(run_ids.iter().map(|run_id| {
|
||||
let client = Arc::clone(&client);
|
||||
async move { common::retrieve_run(&client, run_id).await }
|
||||
}))
|
||||
.await?;
|
||||
if summaries
|
||||
.iter()
|
||||
.all(|run| run.lifecycle.status.is_terminal())
|
||||
{
|
||||
return Ok(GatherRunsResult {
|
||||
runs: summaries.iter().map(common::run_summary_result).collect(),
|
||||
timed_out: false,
|
||||
elapsed_seconds: start.elapsed().as_secs(),
|
||||
});
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
return Ok(GatherRunsResult {
|
||||
runs: summaries.iter().map(common::run_summary_result).collect(),
|
||||
timed_out: true,
|
||||
elapsed_seconds: start.elapsed().as_secs(),
|
||||
});
|
||||
}
|
||||
let sleep_for = Duration::from_secs(params.poll_interval_seconds).min(deadline - now);
|
||||
time::sleep(sleep_for).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gather_runs_text(result: &GatherRunsResult) -> String {
|
||||
format!(
|
||||
"gathered {} Fabro run(s), timed_out={}",
|
||||
result.runs.len(),
|
||||
result.timed_out
|
||||
)
|
||||
}
|
||||
399
lib/crates/fabro-mcp-server/src/run_tools/interact.rs
Normal file
399
lib/crates/fabro-mcp-server/src/run_tools/interact.rs
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_api::types;
|
||||
use fabro_client::Client;
|
||||
use fabro_types::RunId;
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::common;
|
||||
use super::common::{ToolError, ToolResult};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum RunInteractAction {
|
||||
Get,
|
||||
Start,
|
||||
Message,
|
||||
Cancel,
|
||||
Archive,
|
||||
Unarchive,
|
||||
GetQuestions,
|
||||
Answer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunInteractParams {
|
||||
pub(crate) action: RunInteractAction,
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) message: Option<String>,
|
||||
pub(crate) interrupt: Option<bool>,
|
||||
pub(crate) question_id: Option<String>,
|
||||
pub(crate) answer: Option<AnswerValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub(crate) struct AnswerValue(Value);
|
||||
|
||||
impl From<Value> for AnswerValue {
|
||||
fn from(value: Value) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AnswerValue {
|
||||
fn into_inner(self) -> Value {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for AnswerValue {
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AnswerValue".into()
|
||||
}
|
||||
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Answer payload for a pending Fabro question. Use a boolean for yes/no, a string or {\"text\": \"...\"} for freeform text, {\"option\": \"key\"} for a single choice, or {\"options\": [\"key\"]} for multi-select.",
|
||||
"anyOf": [
|
||||
{ "type": "boolean" },
|
||||
{ "type": "string" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"option": { "type": "string" }
|
||||
},
|
||||
"required": ["option"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["options"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedInteractRun {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) action: ValidatedInteractAction,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ValidatedInteractAction {
|
||||
Get,
|
||||
Start,
|
||||
Message {
|
||||
message: String,
|
||||
interrupt: bool,
|
||||
},
|
||||
Cancel,
|
||||
Archive,
|
||||
Unarchive,
|
||||
GetQuestions,
|
||||
Answer {
|
||||
question_id: String,
|
||||
body: types::SubmitAnswerRequest,
|
||||
},
|
||||
}
|
||||
|
||||
impl ValidatedInteractAction {
|
||||
fn action(&self) -> RunInteractAction {
|
||||
match self {
|
||||
Self::Get => RunInteractAction::Get,
|
||||
Self::Start => RunInteractAction::Start,
|
||||
Self::Message { .. } => RunInteractAction::Message,
|
||||
Self::Cancel => RunInteractAction::Cancel,
|
||||
Self::Archive => RunInteractAction::Archive,
|
||||
Self::Unarchive => RunInteractAction::Unarchive,
|
||||
Self::GetQuestions => RunInteractAction::GetQuestions,
|
||||
Self::Answer { .. } => RunInteractAction::Answer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunInteractParams> for ValidatedInteractRun {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunInteractParams) -> Result<Self, Self::Error> {
|
||||
if params.run_id.trim().is_empty() {
|
||||
return Err(ToolError::message("run_id is required"));
|
||||
}
|
||||
let action = match params.action {
|
||||
RunInteractAction::Get => ValidatedInteractAction::Get,
|
||||
RunInteractAction::Start => ValidatedInteractAction::Start,
|
||||
RunInteractAction::Message => {
|
||||
let Some(message) = params
|
||||
.message
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|message| !message.is_empty())
|
||||
else {
|
||||
return Err(ToolError::message("message is required for action message"));
|
||||
};
|
||||
ValidatedInteractAction::Message {
|
||||
message: message.to_string(),
|
||||
interrupt: params.interrupt.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
RunInteractAction::Cancel => ValidatedInteractAction::Cancel,
|
||||
RunInteractAction::Archive => ValidatedInteractAction::Archive,
|
||||
RunInteractAction::Unarchive => ValidatedInteractAction::Unarchive,
|
||||
RunInteractAction::GetQuestions => ValidatedInteractAction::GetQuestions,
|
||||
RunInteractAction::Answer => {
|
||||
let Some(question_id) = params
|
||||
.question_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|question_id| !question_id.is_empty())
|
||||
else {
|
||||
return Err(ToolError::message(
|
||||
"question_id is required for action answer",
|
||||
));
|
||||
};
|
||||
let Some(answer) = params.answer else {
|
||||
return Err(ToolError::message("answer is required for action answer"));
|
||||
};
|
||||
ValidatedInteractAction::Answer {
|
||||
question_id: question_id.to_string(),
|
||||
body: answer_to_submit_request(answer.into_inner())?,
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
run_id: params.run_id.trim().to_string(),
|
||||
action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct InteractRunResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) action: RunInteractAction,
|
||||
pub(crate) result: Value,
|
||||
}
|
||||
|
||||
pub(crate) async fn interact_run(
|
||||
client: Arc<Client>,
|
||||
params: ValidatedInteractRun,
|
||||
) -> ToolResult<InteractRunResult> {
|
||||
let run_id = client
|
||||
.resolve_run(¶ms.run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
.id;
|
||||
let action = params.action.action();
|
||||
let result = match params.action {
|
||||
ValidatedInteractAction::Get => interact_get(&client, &run_id).await?,
|
||||
ValidatedInteractAction::Start => {
|
||||
let summary = client
|
||||
.start_run(&run_id, false)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::Message { message, interrupt } => {
|
||||
client
|
||||
.steer_run(&run_id, message.clone(), interrupt)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "message": message, "interrupt": interrupt })
|
||||
}
|
||||
ValidatedInteractAction::Cancel => {
|
||||
let summary = client
|
||||
.cancel_run(&run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::Archive => {
|
||||
let summary = client
|
||||
.archive_run(&run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "summary": common::run_summary_result(&summary) })
|
||||
}
|
||||
ValidatedInteractAction::Unarchive => {
|
||||
let summary = client
|
||||
.unarchive_run(&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)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "questions": questions })
|
||||
}
|
||||
ValidatedInteractAction::Answer { question_id, body } => {
|
||||
client
|
||||
.submit_run_answer(&run_id, &question_id, body)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
json!({ "question_id": question_id, "submitted": true })
|
||||
}
|
||||
};
|
||||
|
||||
Ok(InteractRunResult {
|
||||
run_id: run_id.to_string(),
|
||||
action,
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn interact_run_text(result: &InteractRunResult) -> String {
|
||||
format!(
|
||||
"completed {:?} for Fabro run {}",
|
||||
result.action, result.run_id
|
||||
)
|
||||
}
|
||||
|
||||
async fn interact_get(client: &Client, run_id: &RunId) -> ToolResult<Value> {
|
||||
let summary = common::retrieve_run(client, run_id).await?;
|
||||
let projection = client
|
||||
.get_run_state(run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
Ok(json!({
|
||||
"summary": common::run_summary_result(&summary),
|
||||
"projection": projection,
|
||||
}))
|
||||
}
|
||||
|
||||
fn answer_to_submit_request(answer: Value) -> ToolResult<types::SubmitAnswerRequest> {
|
||||
match answer {
|
||||
Value::Bool(true) => Ok(types::SubmitAnswerYesRequest {
|
||||
kind: types::SubmitAnswerYesRequestKind::Yes,
|
||||
}
|
||||
.into()),
|
||||
Value::Bool(false) => Ok(types::SubmitAnswerNoRequest {
|
||||
kind: types::SubmitAnswerNoRequestKind::No,
|
||||
}
|
||||
.into()),
|
||||
Value::String(text) => Ok(text_answer_request(text)),
|
||||
Value::Object(mut object) => {
|
||||
if let Some(option) = object.remove("option") {
|
||||
let option_key = serde_json::from_value::<String>(option).map_err(|err| {
|
||||
ToolError::message(format!("answer option must be a string: {err}"))
|
||||
})?;
|
||||
Ok(types::SubmitAnswerSelectedRequest {
|
||||
kind: types::SubmitAnswerSelectedRequestKind::Selected,
|
||||
option_key,
|
||||
}
|
||||
.into())
|
||||
} else if let Some(options) = object.remove("options") {
|
||||
let option_keys =
|
||||
serde_json::from_value::<Vec<String>>(options).map_err(|err| {
|
||||
ToolError::message(format!("answer options must be strings: {err}"))
|
||||
})?;
|
||||
Ok(types::SubmitAnswerMultiSelectedRequest {
|
||||
kind: types::SubmitAnswerMultiSelectedRequestKind::MultiSelected,
|
||||
option_keys,
|
||||
}
|
||||
.into())
|
||||
} else if let Some(text) = object.remove("text") {
|
||||
let text = serde_json::from_value::<String>(text).map_err(|err| {
|
||||
ToolError::message(format!("answer text must be a string: {err}"))
|
||||
})?;
|
||||
Ok(text_answer_request(text))
|
||||
} else {
|
||||
Err(ToolError::message(
|
||||
"answer object must contain one of: option, options, text",
|
||||
))
|
||||
}
|
||||
}
|
||||
other => Err(ToolError::message(format!(
|
||||
"unsupported answer value: {other}; expected boolean, string, or object",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_answer_request(text: String) -> types::SubmitAnswerRequest {
|
||||
types::SubmitAnswerTextRequest {
|
||||
kind: types::SubmitAnswerTextRequestKind::Text,
|
||||
text,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn answer_payloads_map_to_submit_answer_wire_json() {
|
||||
let cases = [
|
||||
(json!(true), json!({ "kind": "yes" })),
|
||||
(json!(false), json!({ "kind": "no" })),
|
||||
(json!("hello"), json!({ "kind": "text", "text": "hello" })),
|
||||
(
|
||||
json!({ "option": "a" }),
|
||||
json!({ "kind": "selected", "option_key": "a" }),
|
||||
),
|
||||
(
|
||||
json!({ "options": ["a", "b"] }),
|
||||
json!({ "kind": "multi_selected", "option_keys": ["a", "b"] }),
|
||||
),
|
||||
(
|
||||
json!({ "text": "hello" }),
|
||||
json!({ "kind": "text", "text": "hello" }),
|
||||
),
|
||||
];
|
||||
|
||||
for (answer, expected) in cases {
|
||||
let request = answer_to_submit_request(answer).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_answer_object_is_rejected() {
|
||||
let err = answer_to_submit_request(json!({ "value": "yes" })).unwrap_err();
|
||||
|
||||
assert!(err.as_str().contains("option, options, text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_answer_validation_rejects_unsupported_json_before_api_calls() {
|
||||
let err = ValidatedInteractRun::try_from(FabroRunInteractParams {
|
||||
action: RunInteractAction::Answer,
|
||||
run_id: "run_123".to_string(),
|
||||
message: None,
|
||||
interrupt: None,
|
||||
question_id: Some("question-1".to_string()),
|
||||
answer: Some(json!({ "value": "yes" }).into()),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.as_str().contains("option, options, text"));
|
||||
}
|
||||
}
|
||||
178
lib/crates/fabro-mcp-server/src/run_tools/manifest.rs
Normal file
178
lib/crates/fabro-mcp-server/src/run_tools/manifest.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_api::types;
|
||||
use fabro_config::{CliLayer, RunLayer};
|
||||
use fabro_manifest::{self, ManifestBuildInput, RunOverrideInput};
|
||||
use fabro_server::manifest_validation;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::common::{ToolError, ToolResult};
|
||||
use super::create::ValidatedCreateRunSpec;
|
||||
|
||||
pub(super) fn build_mcp_run_manifest(
|
||||
spec: &ValidatedCreateRunSpec,
|
||||
cwd: &Path,
|
||||
user_settings_path: &Path,
|
||||
) -> ToolResult<types::RunManifest> {
|
||||
let built = fabro_manifest::build_run_manifest(ManifestBuildInput {
|
||||
workflow: PathBuf::from(&spec.workflow),
|
||||
cwd: cwd.to_path_buf(),
|
||||
run_overrides: mcp_run_overrides(spec),
|
||||
cli_overrides: Some(CliLayer::default()),
|
||||
input_overrides: spec.inputs.clone(),
|
||||
args: mcp_manifest_args(spec),
|
||||
run_id: spec.run_id,
|
||||
user_settings_path: Some(user_settings_path.to_path_buf()),
|
||||
})
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
let validation = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
if !validation.ok {
|
||||
return Err(ToolError::message("workflow manifest validation failed"));
|
||||
}
|
||||
Ok(built.manifest)
|
||||
}
|
||||
|
||||
pub(super) fn json_to_toml_value(key: &str, value: &Value) -> ToolResult<toml::Value> {
|
||||
match value {
|
||||
Value::Null => Err(ToolError::message(format!(
|
||||
"input `{key}` cannot be null; use a string, boolean, or number"
|
||||
))),
|
||||
Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
|
||||
Value::Number(value) => {
|
||||
if let Some(integer) = value.as_i64() {
|
||||
Ok(toml::Value::Integer(integer))
|
||||
} else if let Some(float) = value.as_f64() {
|
||||
Ok(toml::Value::Float(float))
|
||||
} else {
|
||||
Err(ToolError::message(format!(
|
||||
"input `{key}` contains a number outside TOML's supported range"
|
||||
)))
|
||||
}
|
||||
}
|
||||
Value::String(value) => Ok(toml::Value::String(value.clone())),
|
||||
Value::Array(_) => Err(ToolError::message(format!(
|
||||
"input `{key}` does not support array values; use a string, boolean, or number",
|
||||
))),
|
||||
Value::Object(_) => Err(ToolError::message(format!(
|
||||
"input `{key}` does not support object values; use a string, boolean, or number",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_manifest_args(spec: &ValidatedCreateRunSpec) -> Option<types::ManifestArgs> {
|
||||
let mut input = spec
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>();
|
||||
input.sort();
|
||||
let mut label = spec
|
||||
.labels
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect::<Vec<_>>();
|
||||
label.sort();
|
||||
let payload = types::ManifestArgs {
|
||||
auto_approve: spec.auto_approve.filter(|value| *value),
|
||||
docker_image: None,
|
||||
dry_run: spec.dry_run.filter(|value| *value),
|
||||
input,
|
||||
label,
|
||||
model: spec.model.clone(),
|
||||
preserve_sandbox: spec.preserve_sandbox.filter(|value| *value),
|
||||
provider: spec.provider.clone(),
|
||||
sandbox: spec.sandbox.clone(),
|
||||
verbose: None,
|
||||
};
|
||||
(!fabro_manifest::manifest_args_is_empty(&payload)).then_some(payload)
|
||||
}
|
||||
|
||||
fn mcp_run_overrides(spec: &ValidatedCreateRunSpec) -> Option<RunLayer> {
|
||||
fabro_manifest::build_sparse_run_overrides(RunOverrideInput {
|
||||
goal: spec.goal.as_deref(),
|
||||
model: spec.model.as_deref(),
|
||||
provider: spec.provider.as_deref(),
|
||||
sandbox: spec.sandbox.as_deref(),
|
||||
docker_image: None,
|
||||
preserve_sandbox: spec.preserve_sandbox,
|
||||
dry_run: spec.dry_run,
|
||||
auto_approve: spec.auto_approve,
|
||||
labels: spec.labels.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::create::CreateRunSpec;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_inputs_convert_scalar_values_to_toml_values() {
|
||||
let cases = [
|
||||
(json!("hello"), toml::Value::String("hello".to_string())),
|
||||
(json!(true), toml::Value::Boolean(true)),
|
||||
(json!(42), toml::Value::Integer(42)),
|
||||
(json!(0.5), toml::Value::Float(0.5)),
|
||||
];
|
||||
|
||||
for (json, expected) in cases {
|
||||
assert_eq!(json_to_toml_value("input", &json).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_arrays_and_objects_are_rejected() {
|
||||
let array_err = json_to_toml_value("matrix", &json!(["a", 1])).unwrap_err();
|
||||
assert_eq!(
|
||||
array_err.as_str(),
|
||||
"input `matrix` does not support array values; use a string, boolean, or number",
|
||||
);
|
||||
|
||||
let object_err = json_to_toml_value("settings", &json!({ "enabled": true })).unwrap_err();
|
||||
assert_eq!(
|
||||
object_err.as_str(),
|
||||
"input `settings` does not support object values; use a string, boolean, or number",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_null_is_rejected_with_key_name() {
|
||||
let err = json_to_toml_value("goal", &Value::Null).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.as_str(),
|
||||
"input `goal` cannot be null; use a string, boolean, or number",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_manifest_args_preserve_input_provenance() {
|
||||
let spec = ValidatedCreateRunSpec::try_from(CreateRunSpec {
|
||||
workflow: "simple".to_string(),
|
||||
run_id: None,
|
||||
cwd: None,
|
||||
goal: None,
|
||||
inputs: HashMap::from([
|
||||
("count".to_string(), json!(3).into()),
|
||||
("decision".to_string(), json!("approve").into()),
|
||||
]),
|
||||
labels: HashMap::new(),
|
||||
model: None,
|
||||
provider: None,
|
||||
sandbox: None,
|
||||
dry_run: None,
|
||||
auto_approve: None,
|
||||
preserve_sandbox: None,
|
||||
start: None,
|
||||
})
|
||||
.expect("create spec should validate");
|
||||
let args = mcp_manifest_args(&spec).expect("input args should be present");
|
||||
|
||||
assert_eq!(args.input, vec![r"count=3", r#"decision="approve""#]);
|
||||
}
|
||||
}
|
||||
378
lib/crates/fabro-mcp-server/src/run_tools/search.rs
Normal file
378
lib/crates/fabro-mcp-server/src/run_tools/search.rs
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_client::Client;
|
||||
use fabro_types::{Run, RunStatusKind};
|
||||
use futures::future::try_join_all;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::common;
|
||||
use super::common::{RunSummaryResult, ToolError, ToolResult};
|
||||
|
||||
const SEARCH_GOAL_PREVIEW_CHARS: usize = 240;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(crate) struct FabroRunSearchParams {
|
||||
pub(crate) run_ids: Option<Vec<String>>,
|
||||
pub(crate) workflow: Option<String>,
|
||||
pub(crate) labels: Option<HashMap<String, String>>,
|
||||
pub(crate) status: Option<Vec<String>>,
|
||||
pub(crate) archived: Option<bool>,
|
||||
pub(crate) created_after: Option<String>,
|
||||
pub(crate) created_before: Option<String>,
|
||||
pub(crate) first: Option<usize>,
|
||||
pub(crate) after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValidatedSearchRuns {
|
||||
pub(crate) raw: FabroRunSearchParams,
|
||||
pub(crate) status: Option<Vec<RunStatusKind>>,
|
||||
}
|
||||
|
||||
impl TryFrom<FabroRunSearchParams> for ValidatedSearchRuns {
|
||||
type Error = ToolError;
|
||||
|
||||
fn try_from(params: FabroRunSearchParams) -> Result<Self, Self::Error> {
|
||||
if params.first.is_some_and(|first| first > 100) {
|
||||
return Err(ToolError::message("first must be <= 100"));
|
||||
}
|
||||
if let Some(run_ids) = params.run_ids.as_ref() {
|
||||
common::validate_len("run_ids", run_ids.len(), 1, 100)?;
|
||||
}
|
||||
let status = params
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|statuses| {
|
||||
statuses
|
||||
.iter()
|
||||
.map(|status| {
|
||||
status.parse::<RunStatusKind>().map_err(|_| {
|
||||
ToolError::message(format!("unknown run status `{status}`"))
|
||||
})
|
||||
})
|
||||
.collect::<ToolResult<Vec<_>>>()
|
||||
})
|
||||
.transpose()?;
|
||||
if let Some(created_after) = params.created_after.as_deref() {
|
||||
common::parse_datetime_filter("created_after", created_after)?;
|
||||
}
|
||||
if let Some(created_before) = params.created_before.as_deref() {
|
||||
common::parse_datetime_filter("created_before", created_before)?;
|
||||
}
|
||||
Ok(Self {
|
||||
raw: params,
|
||||
status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct SearchRunsResult {
|
||||
pub(crate) runs: Vec<SearchRunSummaryResult>,
|
||||
pub(crate) next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(crate) struct SearchRunSummaryResult {
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) workflow_name: String,
|
||||
pub(crate) workflow_slug: Option<String>,
|
||||
pub(crate) status: String,
|
||||
pub(crate) archived: bool,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) started_at: Option<String>,
|
||||
pub(crate) completed_at: Option<String>,
|
||||
pub(crate) labels: HashMap<String, String>,
|
||||
pub(crate) source_directory: Option<String>,
|
||||
pub(crate) repo_origin_url: Option<String>,
|
||||
pub(crate) goal_preview: String,
|
||||
pub(crate) goal_truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn search_runs(
|
||||
client: Arc<Client>,
|
||||
params: ValidatedSearchRuns,
|
||||
) -> ToolResult<SearchRunsResult> {
|
||||
let status = params.status;
|
||||
let raw = params.raw;
|
||||
let runs = if let Some(run_ids) = raw.run_ids.as_ref() {
|
||||
resolve_requested_runs(&client, run_ids).await?
|
||||
} 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())?;
|
||||
|
||||
Ok(SearchRunsResult {
|
||||
runs: page.runs.iter().map(search_run_summary_result).collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
fn search_run_summary_result(run: &Run) -> SearchRunSummaryResult {
|
||||
let RunSummaryResult {
|
||||
run_id,
|
||||
workflow_name,
|
||||
workflow_slug,
|
||||
status,
|
||||
archived,
|
||||
created_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
labels,
|
||||
source_directory,
|
||||
repo_origin_url,
|
||||
goal,
|
||||
} = common::run_summary_result(run);
|
||||
let (goal_preview, goal_truncated) = goal_preview(&goal);
|
||||
|
||||
SearchRunSummaryResult {
|
||||
run_id,
|
||||
workflow_name,
|
||||
workflow_slug,
|
||||
status,
|
||||
archived,
|
||||
created_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
labels,
|
||||
source_directory,
|
||||
repo_origin_url,
|
||||
goal_preview,
|
||||
goal_truncated,
|
||||
}
|
||||
}
|
||||
|
||||
fn goal_preview(goal: &str) -> (String, bool) {
|
||||
let mut chars = goal.chars();
|
||||
let mut preview = chars
|
||||
.by_ref()
|
||||
.take(SEARCH_GOAL_PREVIEW_CHARS)
|
||||
.collect::<String>();
|
||||
let truncated = chars.next().is_some();
|
||||
if truncated {
|
||||
preview.push_str("...");
|
||||
}
|
||||
(preview, truncated)
|
||||
}
|
||||
|
||||
struct RunSearchPage {
|
||||
runs: Vec<Run>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn filter_sort_and_page_runs(
|
||||
mut runs: Vec<Run>,
|
||||
raw: &FabroRunSearchParams,
|
||||
status: Option<&[RunStatusKind]>,
|
||||
) -> ToolResult<RunSearchPage> {
|
||||
if let Some(workflow) = raw.workflow.as_deref() {
|
||||
runs.retain(|run| {
|
||||
run.workflow.name == workflow || run.workflow.slug.as_deref() == Some(workflow)
|
||||
});
|
||||
}
|
||||
if let Some(labels) = raw.labels.as_ref() {
|
||||
runs.retain(|run| {
|
||||
labels
|
||||
.iter()
|
||||
.all(|(key, value)| run.labels.get(key) == Some(value))
|
||||
});
|
||||
}
|
||||
if let Some(status) = status {
|
||||
runs.retain(|run| {
|
||||
status
|
||||
.iter()
|
||||
.any(|status| *status == run.lifecycle.status.kind())
|
||||
});
|
||||
}
|
||||
let archived = raw.archived.unwrap_or(false);
|
||||
runs.retain(|run| run.lifecycle.archived == archived);
|
||||
if let Some(created_after) = raw.created_after.as_deref() {
|
||||
let cutoff = common::parse_datetime_filter("created_after", created_after)?;
|
||||
runs.retain(|run| run.timestamps.created_at >= cutoff);
|
||||
}
|
||||
if let Some(created_before) = raw.created_before.as_deref() {
|
||||
let cutoff = common::parse_datetime_filter("created_before", created_before)?;
|
||||
runs.retain(|run| run.timestamps.created_at <= cutoff);
|
||||
}
|
||||
|
||||
runs.sort_by(|a, b| {
|
||||
let a_sort_time = a.timestamps.started_at.unwrap_or(a.timestamps.created_at);
|
||||
let b_sort_time = b.timestamps.started_at.unwrap_or(b.timestamps.created_at);
|
||||
b_sort_time.cmp(&a_sort_time).then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
|
||||
if let Some(after) = raw.after.as_deref() {
|
||||
if let Some(position) = runs.iter().position(|run| run.id.to_string() == after) {
|
||||
runs = runs.into_iter().skip(position + 1).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let first = raw.first.unwrap_or(20).min(100);
|
||||
let has_more = runs.len() > first;
|
||||
let page = runs.into_iter().take(first).collect::<Vec<_>>();
|
||||
let next_cursor = has_more
|
||||
.then(|| page.last().map(|run| run.id.to_string()))
|
||||
.flatten();
|
||||
Ok(RunSearchPage {
|
||||
runs: page,
|
||||
next_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn search_runs_text(result: &SearchRunsResult) -> String {
|
||||
format!("found {} Fabro run(s)", result.runs.len())
|
||||
}
|
||||
|
||||
async fn resolve_requested_runs(client: &Arc<Client>, run_ids: &[String]) -> ToolResult<Vec<Run>> {
|
||||
let runs = try_join_all(run_ids.iter().map(|run_id| {
|
||||
let client = Arc::clone(client);
|
||||
async move {
|
||||
client
|
||||
.resolve_run(run_id)
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let mut unique = HashMap::new();
|
||||
for run in runs {
|
||||
unique.entry(run.id).or_insert(run);
|
||||
}
|
||||
Ok(unique.into_values().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cursor_is_applied_after_filters() {
|
||||
let matching_newer = run("01KRBZW5C00000000000000001", "keep", 30);
|
||||
let unrelated_cursor = run("01KRBZW4DW0000000000000002", "skip", 20);
|
||||
let matching_older = run("01KRBZW3EF0000000000000003", "keep", 10);
|
||||
|
||||
let result = filter_sort_and_page_runs(
|
||||
vec![
|
||||
matching_older.clone(),
|
||||
unrelated_cursor.clone(),
|
||||
matching_newer.clone(),
|
||||
],
|
||||
&FabroRunSearchParams {
|
||||
run_ids: None,
|
||||
workflow: None,
|
||||
labels: Some(HashMap::from([("group".to_string(), "keep".to_string())])),
|
||||
status: None,
|
||||
archived: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
first: Some(10),
|
||||
after: Some(unrelated_cursor.id.to_string()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("filtering should succeed");
|
||||
|
||||
let ids = result.runs.iter().map(|run| run.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids, vec![matching_newer.id, matching_older.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_archived_filter_hides_archived_runs_by_default() {
|
||||
let active = run("01KRBZW5C00000000000000001", "keep", 30);
|
||||
let archived = archived_run("01KRBZW4DW0000000000000002", "keep", 20);
|
||||
|
||||
let result = filter_sort_and_page_runs(
|
||||
vec![archived.clone(), active.clone()],
|
||||
&FabroRunSearchParams {
|
||||
run_ids: None,
|
||||
workflow: None,
|
||||
labels: None,
|
||||
status: None,
|
||||
archived: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
first: Some(10),
|
||||
after: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("filtering should succeed");
|
||||
|
||||
let ids = result.runs.iter().map(|run| run.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids, vec![active.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_summary_uses_bounded_goal_preview() {
|
||||
let mut run = run("01KRBZW5C00000000000000001", "keep", 30);
|
||||
run.goal = format!("{}tail-marker", "a".repeat(300));
|
||||
|
||||
let summary = search_run_summary_result(&run);
|
||||
|
||||
assert!(summary.goal_truncated);
|
||||
assert!(summary.goal_preview.len() < run.goal.len());
|
||||
assert!(!summary.goal_preview.contains("tail-marker"));
|
||||
}
|
||||
|
||||
fn run(id: &str, group: &str, seconds: u32) -> Run {
|
||||
run_with_archived(id, group, seconds, false)
|
||||
}
|
||||
|
||||
fn archived_run(id: &str, group: &str, seconds: u32) -> Run {
|
||||
run_with_archived(id, group, seconds, true)
|
||||
}
|
||||
|
||||
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 {
|
||||
id: id.parse().expect("test run id should parse"),
|
||||
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::from([("group".to_string(), group.to_string())]),
|
||||
lifecycle: RunLifecycle {
|
||||
status: RunStatus::Submitted,
|
||||
pending_control: None,
|
||||
queue_position: None,
|
||||
error: None,
|
||||
archived,
|
||||
archived_at: None,
|
||||
},
|
||||
sandbox: None,
|
||||
models: Vec::new(),
|
||||
source_directory: None,
|
||||
timestamps: RunTimestamps {
|
||||
created_at,
|
||||
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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
171
lib/crates/fabro-mcp-server/src/server.rs
Normal file
171
lib/crates/fabro-mcp-server/src/server.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_client::Client;
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::{CallToolResult, ServerCapabilities, ServerInfo};
|
||||
use rmcp::transport::stdio;
|
||||
use rmcp::{ErrorData, ServerHandler, serve_server, tool, tool_handler, tool_router};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::{FabroMcpServerSettings, run_tools};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FabroMcpServer {
|
||||
settings: Arc<FabroMcpServerSettings>,
|
||||
client: Arc<OnceCell<Arc<Client>>>,
|
||||
cwd: PathBuf,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
pub async fn start(settings: FabroMcpServerSettings) -> Result<()> {
|
||||
let server = FabroMcpServer::new(Arc::new(settings));
|
||||
let service = serve_server(server, stdio()).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for FabroMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_instructions("Use these tools to create, inspect, control, wait for, and read events from Fabro workflow runs.")
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = tool_router)]
|
||||
impl FabroMcpServer {
|
||||
pub(crate) fn new(settings: Arc<FabroMcpServerSettings>) -> Self {
|
||||
let cwd = settings.cwd.clone();
|
||||
Self {
|
||||
settings,
|
||||
client: Arc::new(OnceCell::new()),
|
||||
cwd,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_create",
|
||||
description = "Create one or more Fabro workflow runs, starting them by default."
|
||||
)]
|
||||
async fn fabro_run_create(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunCreateParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedCreateRuns::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::create_runs(client, &self.cwd, &self.settings.config_path, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::create_runs_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_search",
|
||||
description = "Search Fabro workflow runs by id, workflow, labels, status, archival state, and creation time."
|
||||
)]
|
||||
async fn fabro_run_search(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunSearchParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedSearchRuns::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::search_runs(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::search_runs_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_interact",
|
||||
description = "Get, start, message, cancel, archive, unarchive, inspect questions, or answer a Fabro run."
|
||||
)]
|
||||
async fn fabro_run_interact(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunInteractParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedInteractRun::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::interact_run(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::interact_run_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_gather",
|
||||
description = "Wait for Fabro runs to reach terminal states, returning current state on timeout."
|
||||
)]
|
||||
async fn fabro_run_gather(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunGatherParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedGatherRuns::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::gather_runs(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::gather_runs_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "fabro_run_events",
|
||||
description = "List, inspect, or search stored events for a Fabro workflow run."
|
||||
)]
|
||||
async fn fabro_run_events(
|
||||
&self,
|
||||
params: Parameters<run_tools::FabroRunEventsParams>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
let params = match run_tools::ValidatedRunEvents::try_from(params.0) {
|
||||
Ok(params) => params,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
let client = match self.client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return Ok(run_tools::error_result(err)),
|
||||
};
|
||||
match run_tools::run_events(client, params).await {
|
||||
Ok(result) => run_tools::success_result(&result, run_tools::run_events_text(&result)),
|
||||
Err(err) => Ok(run_tools::error_result(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn client(&self) -> Result<Arc<Client>, run_tools::ToolError> {
|
||||
self.client
|
||||
.get_or_try_init(|| async {
|
||||
(self.settings.client_factory)()
|
||||
.await
|
||||
.map(Arc::new)
|
||||
.map_err(|err| run_tools::ToolError::from_anyhow(&err))
|
||||
})
|
||||
.await
|
||||
.map(Arc::clone)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,8 @@ enum ClientState {
|
|||
Connecting(Option<PendingTransport>),
|
||||
/// Handshake complete, ready for tool calls.
|
||||
Ready(Arc<RunningService<RoleClient, LoggingClientHandler>>),
|
||||
/// Connection was explicitly closed.
|
||||
Closed,
|
||||
}
|
||||
|
||||
enum PendingTransport {
|
||||
|
|
@ -51,9 +53,15 @@ impl McpClient {
|
|||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
if config.clear_env {
|
||||
cmd.env_clear();
|
||||
}
|
||||
if !env.is_empty() {
|
||||
cmd.envs(env);
|
||||
}
|
||||
if let Some(current_dir) = config.current_dir.as_ref() {
|
||||
cmd.current_dir(current_dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
cmd.process_group(0);
|
||||
|
|
@ -116,6 +124,7 @@ impl McpClient {
|
|||
.take()
|
||||
.ok_or_else(|| anyhow!("client already initializing"))?,
|
||||
ClientState::Ready(_) => return Err(anyhow!("client already initialized")),
|
||||
ClientState::Closed => return Err(anyhow!("MCP client is shut down")),
|
||||
};
|
||||
|
||||
// Drop the lock before the blocking handshake
|
||||
|
|
@ -243,11 +252,38 @@ impl McpClient {
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) -> Result<()> {
|
||||
let service = {
|
||||
let mut guard = self.state.lock().await;
|
||||
match std::mem::replace(&mut *guard, ClientState::Closed) {
|
||||
ClientState::Connecting(_) | ClientState::Closed => None,
|
||||
ClientState::Ready(service) => Some(service),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(service) = service {
|
||||
match Arc::try_unwrap(service) {
|
||||
Ok(mut service) => {
|
||||
service
|
||||
.close_with_timeout(Duration::from_secs(2))
|
||||
.await
|
||||
.context("failed to shut down MCP client")?;
|
||||
}
|
||||
Err(service) => {
|
||||
service.cancellation_token().cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn service(&self) -> Result<Arc<RunningService<RoleClient, LoggingClientHandler>>> {
|
||||
let guard = self.state.lock().await;
|
||||
match &*guard {
|
||||
ClientState::Ready(service) => Ok(Arc::clone(service)),
|
||||
ClientState::Connecting(_) => Err(anyhow!("MCP client not initialized")),
|
||||
ClientState::Closed => Err(anyhow!("MCP client is shut down")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ fn test_server_config() -> McpServerSettings {
|
|||
command: vec!["python3".into(), test_server],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
}
|
||||
|
|
@ -30,6 +32,78 @@ async fn stdio_client_initialize_and_list_tools() {
|
|||
assert_eq!(tools[0].1, "Echo back the message");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "stdio integration test stages a local process cwd and inherits PATH for python3 lookup"
|
||||
)]
|
||||
async fn stdio_client_uses_configured_cwd_and_exact_env() {
|
||||
let test_server = format!("{}/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR"));
|
||||
let temp_dir = std::env::temp_dir().join(format!(
|
||||
"fabro-mcp-stdio-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir(&temp_dir).unwrap();
|
||||
let canonical_temp_dir = std::fs::canonicalize(&temp_dir).unwrap();
|
||||
let mut env = HashMap::new();
|
||||
env.insert(
|
||||
"PATH".to_string(),
|
||||
std::env::var("PATH").expect("PATH should be set for python3 lookup"),
|
||||
);
|
||||
env.insert("FABRO_MCP_TEST_SENTINEL".to_string(), "fixture".to_string());
|
||||
let config = McpServerSettings {
|
||||
name: "test-echo".into(),
|
||||
transport: McpTransport::Stdio {
|
||||
command: vec!["python3".into(), test_server],
|
||||
env,
|
||||
},
|
||||
current_dir: Some(canonical_temp_dir.clone()),
|
||||
clear_env: true,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 30,
|
||||
};
|
||||
let client = McpClient::new(&config).unwrap();
|
||||
client.initialize(config.startup_timeout()).await.unwrap();
|
||||
|
||||
let cwd = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "__cwd__"}),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
call_result_to_string(&cwd).unwrap(),
|
||||
canonical_temp_dir.display().to_string()
|
||||
);
|
||||
let sentinel = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "__env:FABRO_MCP_TEST_SENTINEL__"}),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(call_result_to_string(&sentinel).unwrap(), "fixture");
|
||||
let home = client
|
||||
.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "__env:HOME__"}),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(call_result_to_string(&home).unwrap(), "");
|
||||
|
||||
client.shutdown().await.unwrap();
|
||||
std::fs::remove_dir(&temp_dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_client_call_tool_echo() {
|
||||
let config = test_server_config();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Speaks JSON-RPC 2.0 over stdin/stdout per the MCP specification.
|
|||
Exposes a single tool: echo(message) -> message.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
SERVER_INFO = {
|
||||
|
|
@ -53,6 +54,11 @@ def handle_request(req):
|
|||
arguments = params.get("arguments", {})
|
||||
if tool_name == "echo":
|
||||
msg = arguments.get("message", "")
|
||||
if msg == "__cwd__":
|
||||
msg = os.getcwd()
|
||||
elif msg.startswith("__env:") and msg.endswith("__"):
|
||||
key = msg[len("__env:") : -len("__")]
|
||||
msg = os.environ.get(key, "")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker"] }
|
|||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-manifest = { path = "../fabro-manifest" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-proc = { path = "../fabro-proc" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ use fabro_api::types;
|
|||
use fabro_auth::auth_issue_message;
|
||||
use fabro_config::run::parse_run_layer_from_settings_toml;
|
||||
use fabro_config::{
|
||||
CliLayer, CliOutputLayer, DaytonaDockerfileLayer, DockerSandboxLayer, ReplaceMap,
|
||||
RunExecutionLayer, RunLayer, RunModelLayer, RunSandboxLayer, WorkflowSettingsBuilder,
|
||||
CliLayer, CliOutputLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder,
|
||||
parse_input_overrides,
|
||||
};
|
||||
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
|
||||
|
|
@ -28,8 +27,8 @@ use fabro_static::EnvVars;
|
|||
use fabro_types::settings::cli::OutputVerbosity;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{
|
||||
ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerSettings, DockerfileSource, RunGoal,
|
||||
RunMode, RunNamespace,
|
||||
DaytonaNetworkLayer, DaytonaSettings, DockerSettings, DockerfileSource, RunGoal, RunMode,
|
||||
RunNamespace,
|
||||
};
|
||||
use fabro_types::{RunId, WorkflowSettings};
|
||||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
|
|
@ -316,46 +315,16 @@ fn manifest_args_overrides(
|
|||
return Ok(ManifestSettingsOverrides::default());
|
||||
};
|
||||
|
||||
let model = (args.model.is_some() || args.provider.is_some()).then(|| RunModelLayer {
|
||||
provider: args.provider.as_deref().map(InterpString::parse),
|
||||
name: args.model.as_deref().map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
});
|
||||
let sandbox =
|
||||
(args.sandbox.is_some() || args.preserve_sandbox.is_some() || args.docker_image.is_some())
|
||||
.then(|| RunSandboxLayer {
|
||||
provider: args.sandbox.clone(),
|
||||
preserve: args.preserve_sandbox,
|
||||
docker: args.docker_image.as_ref().map(|image| DockerSandboxLayer {
|
||||
image: Some(image.clone()),
|
||||
..DockerSandboxLayer::default()
|
||||
}),
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
|
||||
let execution_has_any = args.dry_run.is_some() || args.auto_approve.is_some();
|
||||
let execution = execution_has_any.then(|| RunExecutionLayer {
|
||||
mode: args
|
||||
.dry_run
|
||||
.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
|
||||
approval: args.auto_approve.map(|a| {
|
||||
if a {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
let run_has_any =
|
||||
model.is_some() || sandbox.is_some() || execution.is_some() || !args.label.is_empty();
|
||||
|
||||
let run = run_has_any.then(|| RunLayer {
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
metadata: ReplaceMap::from(parse_labels(&args.label)),
|
||||
..RunLayer::default()
|
||||
let run = fabro_manifest::build_sparse_run_overrides(fabro_manifest::RunOverrideInput {
|
||||
goal: None,
|
||||
model: args.model.as_deref(),
|
||||
provider: args.provider.as_deref(),
|
||||
sandbox: args.sandbox.as_deref(),
|
||||
docker_image: args.docker_image.as_deref(),
|
||||
preserve_sandbox: args.preserve_sandbox,
|
||||
dry_run: args.dry_run,
|
||||
auto_approve: args.auto_approve,
|
||||
labels: parse_labels(&args.label),
|
||||
});
|
||||
|
||||
// Verbose is a CLI output concern in v2; route it through cli.output.verbosity.
|
||||
|
|
|
|||
|
|
@ -2322,6 +2322,15 @@ async fn load_pending_control(
|
|||
.and_then(|summary| summary.lifecycle.pending_control))
|
||||
}
|
||||
|
||||
async fn durable_run_status(state: &AppState, run_id: RunId) -> anyhow::Result<Option<RunStatus>> {
|
||||
Ok(state
|
||||
.store
|
||||
.runs()
|
||||
.find(&run_id)
|
||||
.await?
|
||||
.map(|summary| summary.lifecycle.status))
|
||||
}
|
||||
|
||||
fn fail_managed_run(state: &Arc<AppState>, run_id: RunId, reason: FailureReason, message: String) {
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
if let Some(managed_run) = runs.get_mut(&run_id) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use super::super::{
|
|||
Principal, RequiredUser, Response, RewindRequest, RewindResponse, Router, RunAnswerTransport,
|
||||
RunControlAction, RunExecutionMode, RunId, RunStatus, StartRunRequest, State, StatusCode,
|
||||
Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,
|
||||
get, load_pending_control, managed_run, operations, parse_run_id_path,
|
||||
durable_run_status, get, load_pending_control, managed_run, operations, parse_run_id_path,
|
||||
persist_cancelled_run_status, post, reject_if_archived, sleep, update_live_run_from_event,
|
||||
workflow_event,
|
||||
};
|
||||
|
|
@ -171,7 +171,7 @@ async fn cancel_run(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
let (persist_cancelled_status, answer_transport, cancel_token, cancel_tx, worker_pid) = {
|
||||
let cancel_target = {
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
match runs.get_mut(&id) {
|
||||
Some(managed_run) => match managed_run.status {
|
||||
|
|
@ -192,7 +192,7 @@ async fn cancel_run(
|
|||
reason: FailureReason::Cancelled,
|
||||
};
|
||||
}
|
||||
(
|
||||
Some((
|
||||
persist_cancelled_status,
|
||||
managed_run.answer_transport.clone(),
|
||||
managed_run.cancel_token.clone(),
|
||||
|
|
@ -200,16 +200,21 @@ async fn cancel_run(
|
|||
.then(|| managed_run.cancel_tx.take())
|
||||
.flatten(),
|
||||
managed_run.worker_pid,
|
||||
)
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
return ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.")
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
None => return ApiError::not_found("Run not found.").into_response(),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
let Some((persist_cancelled_status, answer_transport, cancel_token, cancel_tx, worker_pid)) =
|
||||
cancel_target
|
||||
else {
|
||||
return unmanaged_cancel_response(state.as_ref(), id).await;
|
||||
};
|
||||
|
||||
if pending_control != Some(RunControlAction::Cancel) {
|
||||
if let Err(err) = append_control_request(
|
||||
|
|
@ -256,6 +261,23 @@ async fn cancel_run(
|
|||
run_response(state.as_ref(), id, StatusCode::OK).await
|
||||
}
|
||||
|
||||
async fn unmanaged_cancel_response(state: &AppState, id: RunId) -> Response {
|
||||
match durable_run_status(state, id).await {
|
||||
Ok(Some(status)) if status.is_terminal() => ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"Run is already terminal and cannot be cancelled.",
|
||||
)
|
||||
.into_response(),
|
||||
Ok(Some(_)) => {
|
||||
ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.").into_response()
|
||||
}
|
||||
Ok(None) => ApiError::not_found("Run not found.").into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `pause_run` should enact the transition, chosen from the current run
|
||||
/// status.
|
||||
enum PauseMode {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ use fabro_api::types::SteerRunRequest;
|
|||
use fabro_types::Principal;
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
|
||||
use super::super::{AnswerTransportError, AppState, parse_run_id_path, reject_if_archived};
|
||||
use super::super::{
|
||||
AnswerTransportError, AppState, durable_run_status, parse_run_id_path, reject_if_archived,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::principal_middleware::RequiredUser;
|
||||
|
||||
|
|
@ -77,75 +79,72 @@ async fn control_run(
|
|||
|
||||
// Status + steerability gate. Take the answer_transport snapshot under
|
||||
// the same lock so we can hand it off without further state races.
|
||||
let answer_transport = {
|
||||
let managed_answer_transport = {
|
||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||
let Some(managed_run) = runs.get(&id) else {
|
||||
return ApiError::not_found("Run not found.").into_response();
|
||||
};
|
||||
match managed_run.status {
|
||||
RunStatus::Blocked { .. } => {
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Run is blocked on a question; use the interview-answer endpoint instead.",
|
||||
"use_answer_endpoint",
|
||||
)
|
||||
.into_response();
|
||||
match runs.get(&id) {
|
||||
Some(managed_run) => {
|
||||
match managed_run.status {
|
||||
RunStatus::Blocked { .. } => {
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Run is blocked on a question; use the interview-answer endpoint \
|
||||
instead.",
|
||||
"use_answer_endpoint",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
RunStatus::Submitted
|
||||
| RunStatus::Queued
|
||||
| RunStatus::Starting
|
||||
| RunStatus::Paused { .. } => {
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Run is not currently running.",
|
||||
"run_not_steerable",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
RunStatus::Failed { .. }
|
||||
| RunStatus::Succeeded { .. }
|
||||
| RunStatus::Removing
|
||||
| RunStatus::Dead => {
|
||||
return terminal_control_response(&control);
|
||||
}
|
||||
RunStatus::Running => {}
|
||||
}
|
||||
// Steerability predicate. Best-effort, target-oriented:
|
||||
// - If at least one API-mode session is active → forward.
|
||||
// - Else if no agent stages are active at all → forward (worker hub buffers
|
||||
// for the next session).
|
||||
// - Else (active agents exist but all are non-steerable) → 409.
|
||||
if managed_run.active_api_stages.is_empty()
|
||||
&& !managed_run.active_non_steerable_agent_stages.is_empty()
|
||||
{
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"All currently running agent stages use a non-steerable backend.",
|
||||
"agent_not_steerable",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if managed_run.active_api_stages.is_empty() && control.requires_active_api_session()
|
||||
{
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Run has no active API-mode agent session.",
|
||||
"no_active_api_session",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Some(managed_run.answer_transport.clone())
|
||||
}
|
||||
RunStatus::Submitted
|
||||
| RunStatus::Queued
|
||||
| RunStatus::Starting
|
||||
| RunStatus::Paused { .. } => {
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Run is not currently running.",
|
||||
"run_not_steerable",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
RunStatus::Failed { .. }
|
||||
| RunStatus::Succeeded { .. }
|
||||
| RunStatus::Removing
|
||||
| RunStatus::Dead => {
|
||||
let code = if matches!(&control, RunControlRequest::Interrupt) {
|
||||
"run_not_interruptible"
|
||||
} else {
|
||||
"run_not_steerable"
|
||||
};
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Run is no longer steerable.",
|
||||
code,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
RunStatus::Running => {}
|
||||
None => None,
|
||||
}
|
||||
// Steerability predicate. Best-effort, target-oriented:
|
||||
// - If at least one API-mode session is active → forward.
|
||||
// - Else if no agent stages are active at all → forward (worker hub buffers
|
||||
// for the next session).
|
||||
// - Else (active agents exist but all are non-steerable) → 409.
|
||||
if managed_run.active_api_stages.is_empty()
|
||||
&& !managed_run.active_non_steerable_agent_stages.is_empty()
|
||||
{
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"All currently running agent stages use a non-steerable backend.",
|
||||
"agent_not_steerable",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if managed_run.active_api_stages.is_empty() && control.requires_active_api_session() {
|
||||
return ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Run has no active API-mode agent session.",
|
||||
"no_active_api_session",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
managed_run.answer_transport.clone()
|
||||
};
|
||||
|
||||
let Some(answer_transport) = managed_answer_transport else {
|
||||
return unmanaged_control_response(state.as_ref(), id, &control).await;
|
||||
};
|
||||
let Some(answer_transport) = answer_transport else {
|
||||
return ApiError::with_code(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
|
@ -180,3 +179,32 @@ async fn control_run(
|
|||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_control_response(control: &RunControlRequest) -> Response {
|
||||
let code = if matches!(control, RunControlRequest::Interrupt) {
|
||||
"run_not_interruptible"
|
||||
} else {
|
||||
"run_not_steerable"
|
||||
};
|
||||
ApiError::with_code(StatusCode::CONFLICT, "Run is no longer steerable.", code).into_response()
|
||||
}
|
||||
|
||||
async fn unmanaged_control_response(
|
||||
state: &AppState,
|
||||
id: fabro_types::RunId,
|
||||
control: &RunControlRequest,
|
||||
) -> Response {
|
||||
match durable_run_status(state, id).await {
|
||||
Ok(Some(status)) if status.is_terminal() => terminal_control_response(control),
|
||||
Ok(Some(_)) => ApiError::with_code(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Run has no live worker control channel.",
|
||||
"worker_control_unavailable",
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => ApiError::not_found("Run not found.").into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6877,6 +6877,40 @@ async fn cancel_nonexistent_run_returns_not_found() {
|
|||
assert_status!(response, StatusCode::NOT_FOUND).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_terminal_durable_run_returns_conflict() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let run_id = fixtures::RUN_1;
|
||||
create_durable_run_with_events(&state, run_id, &[
|
||||
workflow_event::Event::WorkflowRunCompleted {
|
||||
duration_ms: 1000,
|
||||
artifact_count: 0,
|
||||
status: "succeeded".to_string(),
|
||||
reason: SuccessReason::Completed,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
diff_summary: None,
|
||||
billing: None,
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/cancel")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::CONFLICT).await;
|
||||
assert_eq!(
|
||||
body["errors"][0]["detail"],
|
||||
"Run is already terminal and cannot be cancelled."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_nonexistent_run_returns_not_found() {
|
||||
let app = test_app_with();
|
||||
|
|
@ -6893,6 +6927,39 @@ async fn steer_nonexistent_run_returns_not_found() {
|
|||
assert_status!(response, StatusCode::NOT_FOUND).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_terminal_durable_run_returns_run_not_steerable() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let run_id = fixtures::RUN_1;
|
||||
create_durable_run_with_events(&state, run_id, &[
|
||||
workflow_event::Event::WorkflowRunCompleted {
|
||||
duration_ms: 1000,
|
||||
artifact_count: 0,
|
||||
status: "succeeded".to_string(),
|
||||
reason: SuccessReason::Completed,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
diff_summary: None,
|
||||
billing: None,
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/steer")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"text":"try again"}"#))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::CONFLICT).await;
|
||||
assert_eq!(body["errors"][0]["code"], "run_not_steerable");
|
||||
assert_eq!(body["errors"][0]["detail"], "Run is no longer steerable.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_empty_text_returns_bad_request() {
|
||||
let state = test_app_state();
|
||||
|
|
|
|||
|
|
@ -152,6 +152,43 @@ pub fn apply_test_isolation(cmd: &mut std::process::Command, home_dir: &Path) {
|
|||
apply_test_isolation_with_lookup(cmd, home_dir, |name| std::env::var_os(name));
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn isolated_env(home_dir: &Path) -> HashMap<String, String> {
|
||||
let mut env = HashMap::new();
|
||||
if let Some(coverage) =
|
||||
std::env::var_os(EnvVars::LLVM_PROFILE_FILE).and_then(|value| value.into_string().ok())
|
||||
{
|
||||
env.insert(EnvVars::LLVM_PROFILE_FILE.to_string(), coverage);
|
||||
}
|
||||
if let Some(path) = std::env::var_os(EnvVars::PATH).and_then(|value| value.into_string().ok()) {
|
||||
env.insert(EnvVars::PATH.to_string(), path);
|
||||
}
|
||||
env.insert(EnvVars::NO_COLOR.to_string(), "1".to_string());
|
||||
env.insert(EnvVars::HOME.to_string(), home_dir.display().to_string());
|
||||
env.insert(
|
||||
EnvVars::FABRO_NO_UPGRADE_CHECK.to_string(),
|
||||
"true".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
EnvVars::FABRO_HTTP_PROXY_POLICY.to_string(),
|
||||
"disabled".to_string(),
|
||||
);
|
||||
env.insert(EnvVars::FABRO_TELEMETRY.to_string(), "off".to_string());
|
||||
env.insert(
|
||||
EnvVars::FABRO_SUPPRESS_OPEN_BROWSER.to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
EnvVars::FABRO_SERVER_MAX_CONCURRENT_RUNS.to_string(),
|
||||
"64".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
EnvVars::FABRO_TEST_IN_MEMORY_STORE.to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
env
|
||||
}
|
||||
|
||||
fn apply_test_isolation_with_lookup(
|
||||
cmd: &mut std::process::Command,
|
||||
home_dir: &Path,
|
||||
|
|
|
|||
|
|
@ -104,5 +104,6 @@ pub use stage_id::{InvalidStageVisit, ParallelBranchId, StageId};
|
|||
pub use start::StartRecord;
|
||||
pub use status::{
|
||||
BlockedReason, FailureReason, InvalidTransition, ParseFailureReasonError,
|
||||
ParseSuccessReasonError, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
|
||||
ParseSuccessReasonError, RunControlAction, RunStatus, RunStatusKind, SuccessReason,
|
||||
TerminalStatus,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
//! behavior, and artifact collection.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use serde::ser::SerializeStruct;
|
||||
|
|
@ -330,6 +331,10 @@ pub struct RunAgentSettings {
|
|||
pub struct McpServerSettings {
|
||||
pub name: String,
|
||||
pub transport: McpTransport,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_dir: Option<PathBuf>,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub clear_env: bool,
|
||||
pub startup_timeout_secs: u64,
|
||||
pub tool_timeout_secs: u64,
|
||||
}
|
||||
|
|
@ -342,6 +347,8 @@ impl Default for McpServerSettings {
|
|||
command: Vec::new(),
|
||||
env: HashMap::new(),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 10,
|
||||
tool_timeout_secs: 60,
|
||||
}
|
||||
|
|
@ -378,6 +385,14 @@ pub enum McpTransport {
|
|||
},
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::trivially_copy_pass_by_ref,
|
||||
reason = "serde skip_serializing_if helpers receive borrowed field values"
|
||||
)]
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TlsMode {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,35 @@ use std::fmt;
|
|||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumString, IntoStaticStr};
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Display,
|
||||
EnumString,
|
||||
IntoStaticStr,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum RunStatusKind {
|
||||
Submitted,
|
||||
Queued,
|
||||
Starting,
|
||||
Running,
|
||||
Blocked,
|
||||
Paused,
|
||||
Removing,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Dead,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
|
|
@ -19,6 +48,10 @@ pub enum RunStatus {
|
|||
}
|
||||
|
||||
impl RunStatus {
|
||||
pub fn kind(self) -> RunStatusKind {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// Whether the run has reached a terminal outcome and stops poll loops,
|
||||
/// finalization, and similar "done" handling.
|
||||
pub fn is_terminal(self) -> bool {
|
||||
|
|
@ -138,6 +171,23 @@ impl RunStatus {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<RunStatus> for RunStatusKind {
|
||||
fn from(status: RunStatus) -> Self {
|
||||
match status {
|
||||
RunStatus::Submitted => Self::Submitted,
|
||||
RunStatus::Queued => Self::Queued,
|
||||
RunStatus::Starting => Self::Starting,
|
||||
RunStatus::Running => Self::Running,
|
||||
RunStatus::Blocked { .. } => Self::Blocked,
|
||||
RunStatus::Paused { .. } => Self::Paused,
|
||||
RunStatus::Removing => Self::Removing,
|
||||
RunStatus::Succeeded { .. } => Self::Succeeded,
|
||||
RunStatus::Failed { .. } => Self::Failed,
|
||||
RunStatus::Dead => Self::Dead,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RunStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
|
|
|||
|
|
@ -547,6 +547,8 @@ fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings
|
|||
env: env.clone(),
|
||||
},
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: settings.startup_timeout_secs,
|
||||
tool_timeout_secs: settings.tool_timeout_secs,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2149,6 +2149,8 @@ async fn daytona_playwright_mcp_sandbox_transport() {
|
|||
port: mcp_port,
|
||||
env: std::collections::HashMap::new(),
|
||||
},
|
||||
current_dir: None,
|
||||
clear_env: false,
|
||||
startup_timeout_secs: 30,
|
||||
tool_timeout_secs: 120,
|
||||
};
|
||||
|
|
@ -2205,6 +2207,8 @@ async fn daytona_playwright_mcp_sandbox_transport() {
|
|||
fabro_mcp::config::McpServerSettings {
|
||||
name: mcp_config.name.clone(),
|
||||
transport: fabro_mcp::config::McpTransport::Http { url, headers },
|
||||
current_dir: mcp_config.current_dir.clone(),
|
||||
clear_env: mcp_config.clear_env,
|
||||
startup_timeout_secs: mcp_config.startup_timeout_secs,
|
||||
tool_timeout_secs: mcp_config.tool_timeout_secs,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue