diff --git a/Cargo.lock b/Cargo.lock index ca3bfe4..a0983eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -249,6 +258,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "castaway" version = "0.2.4" @@ -282,6 +297,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -408,6 +435,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -501,8 +534,18 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -518,17 +561,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "deltae" version = "0.3.2" @@ -642,6 +709,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.17.0" @@ -807,6 +880,31 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -824,6 +922,29 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.32" @@ -836,8 +957,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -975,6 +1101,21 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "git-workon-mcp" +version = "0.1.0" +dependencies = [ + "git-workon-annotations", + "git2", + "miette", + "rmcp", + "schemars", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "git-workon-review" version = "0.1.0" @@ -1099,6 +1240,30 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1236,6 +1401,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1266,7 +1433,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1790,6 +1957,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2149,6 +2322,26 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex" version = "1.12.3" @@ -2178,6 +2371,41 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rmcp" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" +dependencies = [ + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "roff" version = "1.1.0" @@ -2258,6 +2486,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2300,6 +2554,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2748,6 +3013,73 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "tree-sitter" version = "0.26.11" @@ -3173,12 +3505,65 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index bd89f53..f1e4171 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "git-workon-fixture", "git-workon-review", "git-workon-annotations", + "git-workon-mcp", ] [workspace.package] diff --git a/docs/adr/040-mcp-suite-crate.md b/docs/adr/040-mcp-suite-crate.md new file mode 100644 index 0000000..0d6bd6c --- /dev/null +++ b/docs/adr/040-mcp-suite-crate.md @@ -0,0 +1,77 @@ +# 040: `git-workon-mcp`, One MCP Crate for the Whole Suite + +Status: accepted (2026-09-03) + +## Context + +ADR-039 created `git-workon-annotations` as a lib and named `git-workon-mcp`, the MCP server +that serves the store to a coding agent over stdio, as its second consumer. It left the +server's crate home to this ADR. Two homes were on the table. The first was a feature-gated +`[[bin]]` inside `git-workon-annotations`, reached only with `--features mcp`: the fewest +workspace members, and it dodges the publish blocker the same way any `publish = false` +crate does. The second was a crate of its own. The binary and its `git workon mcp` surface +are suite-scoped, while the annotations crate is one domain: every tool it serves today is +an annotation tool, so the first home costs nothing until a worktree or stack tool needs +`git-workon-lib`, which `git-workon-annotations` has no reason to depend on. A feature-gated +bin also hides its test from a bare `cargo test --workspace`, which is what CI runs. +`docs/rfc/agent-integration.md` (Model C, "Phase 3: MCP Server (New Crate)") already +specified `git-workon-mcp` as its own crate depending on `git-workon-lib`. I took the second +home. Same eight tools as the annotations design, no worktree tools yet. + +## Decision + +**Own crate, bin-only, `publish = false` + `[package.metadata.dist] dist = false`, reached by +PATH dispatch, never a `Cmd::Mcp`.** The actual publish blocker this decision resolves: a +published crate cannot depend on a `publish = false` crate, and `git-workon-annotations` stays +`publish = false` (its schema and API are still settling). Building `git-workon-mcp` as its +own crate and having `git-workon`'s existing external-subcommand dispatch +(`git-workon/src/dispatch.rs`) exec it from `PATH` for `git workon mcp` keeps the user-visible +surface without the published `git-workon` binary ever taking a compile-time dependency on an +unpublished crate. A built-in `Cmd::Mcp` would look simpler today, but it would permanently +shadow the external dispatch and re-couple the crates the moment either is ready to publish, +so the dispatch route is taken now. + +**Tools grouped by domain module, annotations first.** `src/tools/annotations.rs` holds the +eight annotation `#[tool]` fns, their argument structs, and their helpers (repo +discovery, store access, anchor building, JSON encoding). `src/tools/mod.rs` re-exports it and +is where a worktree or stack module would land next; `docs/rfc/agent-integration.md` Model C +is still the plan for those, not yet built here. `src/server.rs` owns `WorkonServer` and its +`ServerHandler` impl; `src/main.rs` only wires stdio and calls `serve`. rmcp's `#[tool_router]` +macro generates its router-building associated function without a visibility keyword by +default, so calling it from a sibling module (`server.rs` constructing a `WorkonServer` whose +router impl lives in `tools/annotations.rs`) needs `#[tool_router(vis = "pub(crate)")]`, the +one macro accommodation this split required. Everything else about the module boundary is +ordinary Rust visibility. + +**Transport: rmcp 3.2, minimal features, confined to this one crate.** `default-features = +false, features = ["server", "macros", "transport-io"]`, run on a current-thread tokio +runtime; this pulls in serde derive, schemars, and tokio for this crate only, not for +`git-workon-review` or `git-workon-annotations`. rmcp is the official SDK, tracks MCP protocol +revisions we don't want to hand-roll (a handshake-less transport change landed 2026-07-28), +and its MSRV (1.88) already matches the workspace floor ADR-033 set. Known cost: quarterly +breaking majors, and an open upstream issue where `#[tool_router]` can silently register zero +tools: `tool_router_serves_exactly_eight_tools` asserts the count for exactly that reason. + +## Consequences + +- The stdio round-trip test (`tests/stdio.rs`) runs under a bare `cargo test --workspace`, + which is what CI runs; a feature-gated bin would have needed a flag CI does not pass. +- `git-workon-annotations` stays a lib with no bin target and no `rmcp`/`tokio`/`git2` + dependency: `cargo tree -p git-workon-annotations -e normal` carries none of them. +- `git-workon-mcp`'s own distribution (a second binary through cargo-dist and the homebrew + formula patch step) is still the open question ADR-033 flagged for a second binary + generally; this ADR doesn't resolve it. +- The worktree and stack tool set `docs/rfc/agent-integration.md` Model C describes is still + planned, not built: this ADR ships the annotation tools only. + +## References + +- `docs/rfc/workon-review.md`: Crate layout and Comments decision rows, and the Agent-loop + bullet, updated alongside this ADR +- [ADR-039](039-review-annotations-substrate.md): the annotation store this crate's tools + read and write, and the publish-blocker reasoning this ADR carries forward +- [ADR-033](033-review-crate-workspace-placement.md): the `publish = false` / `dist = false` + scaffold posture this crate adopts, and the second-binary distribution gap it already + flagged +- `docs/rfc/agent-integration.md`: Model C, Phase 3, the worktree/stack tool set this crate + is shaped to grow into next diff --git a/docs/rfc/agent-integration.md b/docs/rfc/agent-integration.md index e3144f4..e01e89d 100644 --- a/docs/rfc/agent-integration.md +++ b/docs/rfc/agent-integration.md @@ -462,7 +462,7 @@ For high-concurrency scenarios (many parallel agents all calling `git workon new ### Phase 3: MCP Server (New Crate) -6. **`git-workon-mcp` crate** — Standalone binary implementing the MCP stdio protocol with five tools: `worktree_list`, `worktree_create`, `worktree_find`, `worktree_remove`, `worktree_create_from_pr`. Depends on `workon` (git-workon-lib). Tool responses use the WorktreeDescriptor JSON schema. Errors use the structured error protocol from Phase 2. +6. **`git-workon-mcp` crate** — Standalone binary implementing the MCP stdio protocol with five tools: `worktree_list`, `worktree_create`, `worktree_find`, `worktree_remove`, `worktree_create_from_pr`. Depends on `workon` (git-workon-lib). Tool responses use the WorktreeDescriptor JSON schema. Errors use the structured error protocol from Phase 2. Status (2026-09-03, ADR-040): the crate exists, reached via `git workon mcp`, and today serves `git-workon-annotations`'s eight comment/walkthrough tools (ADR-039); this worktree tool set is still the plan, not yet built. --- diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 777acad..dd94680 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -160,7 +160,7 @@ The remaining roadmap is resequenced around the tool being **the author's own ev - **Conflict resolution** *(stretch)*. Resolve merge/rebase conflicts in the SBS view. Large surface; may not make v1. -- **Agent loop** *(the eventual north star)*. **Design locked 2026-09-02 (ADR-039); the three open forks below are resolved, not just re-flagged.** One substrate serves both review comments and an integrated `/explain-diff`-style walkthrough: `AnnotationKind::{Comment, TourStop, Chapter}` in one sqlite table at `/workon-review/annotations.db`, anchored by content-hash context (target line + 3 lines each way, re-resolved per load: exact match, then a scored windowed scan, then whitespace-tolerant, else `Orphaned` — never silently wrong) rather than the originally-proposed `(changeset_id, path, side, lnum)` key. **Comment-store home:** its own crate, `git-workon-annotations` (`publish = false`, serde-free, no git2) — `git workon mcp` is the second consumer the "no separate core crate" rule was waiting on, so that condition is now met and the rule no longer applies to it. **MCP crate/transport:** `rmcp` 3.2 (minimal features, current-thread tokio), not hand-rolled JSON-RPC, served from its own `git-workon-mcp` crate (ADR-040) and reached from `git-workon` via the existing PATH-dispatch mechanism (not a built-in `Cmd::Mcp`, and not a `git-workon-review mcp` subcommand) — this is what lets the published `git-workon` binary depend on the feature without depending on the unpublished annotations crate. Landing as five stacked slices (crate scaffold → TUI read → TUI authoring → prose/walkthrough polish → MCP crate); each lands alone. — DONE (2026-09-02): four of five shipped — `annot-crate` (the store + resolver crate), `annot-read` (gutter markers, view/reply overlay, tour stepping), `annot-write` (the multi-line annotation editor, create/reply/resolve), `annot-prose` (walkthrough chapters wrapped into the summary panel, a `stop i/n` tour-progress indicator in the diff header, and `--tour ` to open straight into a walkthrough). `mcp-crate` lands next. +- **Agent loop** *(the eventual north star)*. **Design locked 2026-09-02 (ADR-039); the three open forks below are resolved, not just re-flagged.** One substrate serves both review comments and an integrated `/explain-diff`-style walkthrough: `AnnotationKind::{Comment, TourStop, Chapter}` in one sqlite table at `/workon-review/annotations.db`, anchored by content-hash context (target line + 3 lines each way, re-resolved per load: exact match, then a scored windowed scan, then whitespace-tolerant, else `Orphaned` — never silently wrong) rather than the originally-proposed `(changeset_id, path, side, lnum)` key. **Comment-store home:** its own crate, `git-workon-annotations` (`publish = false`, serde-free, no git2) — `git workon mcp` is the second consumer the "no separate core crate" rule was waiting on, so that condition is now met and the rule no longer applies to it. **MCP crate/transport:** `rmcp` 3.2 (minimal features, current-thread tokio), not hand-rolled JSON-RPC, served from its own `git-workon-mcp` crate (ADR-040) and reached from `git-workon` via the existing PATH-dispatch mechanism (not a built-in `Cmd::Mcp`, and not a `git-workon-review mcp` subcommand) — this is what lets the published `git-workon` binary depend on the feature without depending on the unpublished annotations crate. Landing as five stacked slices (crate scaffold → TUI read → TUI authoring → prose/walkthrough polish → MCP crate); each lands alone. — DONE (2026-09-03): all five shipped — `annot-crate` (the store + resolver crate), `annot-read` (gutter markers, view/reply overlay, tour stepping), `annot-write` (the multi-line annotation editor, create/reply/resolve), `annot-prose` (walkthrough chapters wrapped into the summary panel, a `stop i/n` tour-progress indicator in the diff header, and `--tour ` to open straight into a walkthrough), `mcp-crate` (`git-workon-mcp`'s eight tools — list/get/post/reply/update/resolve/delete/`walkthrough_put` — now its own crate per ADR-040, reached via `git workon mcp`'s PATH dispatch). No longer deferred behind the daily-driver work — it landed alongside it. A third consumer joined 2026-09-02: the user's /redline skill (section-by-section document walks) keeps its walk state in the store when `git workon mcp` is reachable — the stop manifest is a tour, closing a stop is a resolve plus a reply carrying the decision, standing rules are the chapter — which is what motivated the `annotation_update` tool. ## Orchestration notes diff --git a/git-workon-mcp/Cargo.toml b/git-workon-mcp/Cargo.toml new file mode 100644 index 0000000..8636022 --- /dev/null +++ b/git-workon-mcp/Cargo.toml @@ -0,0 +1,49 @@ +[package] +authors.workspace = true +categories = ["command-line-utilities", "development-tools"] +description = "MCP server exposing git-workon's review annotations to a coding agent" +edition.workspace = true +homepage.workspace = true +keywords = ["git", "review", "mcp", "workon"] +license.workspace = true +name = "git-workon-mcp" +repository.workspace = true +rust-version.workspace = true +version = "0.1.0" +include = [ + "src/**/*", + "Cargo.toml", + "LICENSE*", +] +# Not yet published: reached only via git-workon's external-subcommand PATH dispatch, and +# its deps (rmcp, tokio) are pinned to a fast-moving SDK. Follow the ADR-033 posture: +# flipping to publish is a deferred sub-decision, not an oversight to fix later. +publish = false + +[dependencies] +git-workon-annotations.workspace = true +miette.workspace = true +git2.workspace = true +serde_json.workspace = true + +rmcp = { version = "3.2", default-features = false, features = [ + "server", + "macros", + "transport-io", +] } +tokio = { version = "1", features = ["rt", "macros"] } +# Derive macros expand to literal `serde::`/`schemars::` paths, so rmcp's re-exports +# (`rmcp::serde`, `rmcp::schemars`) don't satisfy `#[derive(Deserialize, JsonSchema)]` +# without a `#[serde(crate = ...)]`/`#[schemars(crate = ...)]` on every type. Depending on +# these directly is simpler; the versions are already in Cargo.lock via rmcp, so this adds +# no new resolution, just a name for what's already there. +serde = { version = "1", features = ["derive"] } +schemars = { version = "1" } + +[package.metadata.dist] +# Redundant with publish = false today; load-bearing if the publish flip ever lands (see +# ADR-033's posture, adopted here) so cargo-dist doesn't silently start shipping it. +dist = false + +[dev-dependencies] +tempfile = "3" diff --git a/git-workon-mcp/src/main.rs b/git-workon-mcp/src/main.rs new file mode 100644 index 0000000..8ccc14d --- /dev/null +++ b/git-workon-mcp/src/main.rs @@ -0,0 +1,22 @@ +//! `git-workon-mcp`: MCP server for the git-workon suite (stdio transport), reached as +//! `git workon mcp` via `git-workon`'s external-subcommand PATH dispatch. This binary is +//! never a dependency of the published `git-workon` crate — see ADR-040's publish-blocker +//! reasoning (carried over from ADR-039, which first identified it). +//! +//! Tool routes live under [`tools`], grouped by domain; [`server::WorkonServer`] wires them +//! into the `ServerHandler` rmcp dispatches against. + +mod server; +mod tools; + +use rmcp::transport::io::stdio; +use rmcp::ServiceExt; + +use server::WorkonServer; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let service = WorkonServer::new().serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/git-workon-mcp/src/server.rs b/git-workon-mcp/src/server.rs new file mode 100644 index 0000000..9adc6d3 --- /dev/null +++ b/git-workon-mcp/src/server.rs @@ -0,0 +1,47 @@ +//! [`WorkonServer`]: the MCP `ServerHandler` for the whole suite. Tool routes are added by +//! domain module under [`crate::tools`] (today: `annotations`; future: worktrees, stack — +//! see `docs/rfc/agent-integration.md` Model C); this file only owns the router field and +//! `get_info`. + +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}; +use rmcp::{tool_handler, ServerHandler}; + +#[derive(Debug, Clone)] +pub struct WorkonServer { + pub(crate) tool_router: ToolRouter, +} + +impl WorkonServer { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for WorkonServer { + fn get_info(&self) -> ServerInfo { + // `ServerInfo` and `Implementation` are `#[non_exhaustive]` in rmcp 3.2, so they + // can't be built with a struct literal; mutate defaults instead. + let mut server_info = Implementation::from_build_env(); + // `Implementation::from_build_env()` reads `env!("CARGO_CRATE_NAME")` at the call + // site inside rmcp itself, so it would report "rmcp" here, not this binary — name + // it explicitly instead. + server_info.name = "git-workon-mcp".to_string(); + server_info.version = env!("CARGO_PKG_VERSION").to_string(); + + let mut info = ServerInfo::default(); + info.protocol_version = ProtocolVersion::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info.server_info = server_info; + info.instructions = Some( + "Read and write git-workon-review's annotation store: line comments, \ + replies, and explain-diff-style walkthroughs. All tools take an optional \ + `repo_path`, defaulting to discovery from the current directory." + .to_string(), + ); + info + } +} diff --git a/git-workon-mcp/src/tools/annotations.rs b/git-workon-mcp/src/tools/annotations.rs new file mode 100644 index 0000000..bf33419 --- /dev/null +++ b/git-workon-mcp/src/tools/annotations.rs @@ -0,0 +1,576 @@ +//! Serves [`workon_annotations::store::AnnotationStore`] over MCP so an agent can read and +//! write the same comment/walkthrough substrate the review TUI renders (ADR-039). +//! +//! Each tool call opens its own [`AnnotationStore`] and, where needed, its own +//! `git2::Repository`: neither the sqlite connection nor a `Repository` is `Sync`, and rmcp +//! dispatches tool calls concurrently, so nothing here is held across calls. + +use std::path::Path; + +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router, ErrorData as McpError}; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::{json, Value}; + +use workon_annotations::store::{AnnotationStore, TourStop, Walkthrough}; +use workon_annotations::{Anchor, AnnotationKind, ChangesetKey, NewAnnotation, Status}; + +use crate::server::WorkonServer; + +#[tool_router(router = tool_router, vis = "pub(crate)")] +impl WorkonServer { + #[tool( + description = "List annotations for a changeset, optionally filtered to one file \ + path. Each entry's anchor is re-resolved against current content and reports how it \ + resolved (exact / shifted / orphaned)." + )] + async fn annotation_list( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let store = open_store(&repo)?; + let key = ChangesetKey::new(args.changeset, args.uncommitted); + + let annotations = match &args.path { + Some(path) => store.by_path(&key, path).map_err(store_err)?, + None => store.by_changeset(&key).map_err(store_err)?, + }; + + let out: Vec = annotations + .into_iter() + .map(|annotation| { + let resolution = annotation.anchor.as_ref().map(|anchor| { + match read_lines(&repo, &annotation.changeset, &anchor.path) { + Ok(lines) => { + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + workon_annotations::anchor::resolve(anchor, &refs) + } + Err(_) => workon_annotations::anchor::Resolution { + lineno: None, + anchoring: workon_annotations::Anchoring::Orphaned, + }, + } + }); + annotation_to_json(&annotation, resolution) + }) + .collect(); + + to_json_string(&out) + } + + #[tool(description = "Fetch one annotation by uid.")] + async fn annotation_get( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let store = open_store(&repo)?; + let annotation = get_or_not_found(&store, &args.uid)?; + to_json_string(&annotation_to_json(&annotation, None)) + } + + #[tool( + description = "Create a comment, tour stop, or chapter anchored to a file and \ + line. The server reads the target line and 3 lines of context each way itself, from \ + the worktree for the uncommitted changeset or from the changeset branch's tip tree \ + for a committed one." + )] + async fn annotation_post( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let store = open_store(&repo)?; + let key = ChangesetKey::new(args.changeset, args.uncommitted); + let new_side = parse_side(&args.side)?; + let lines = read_lines(&repo, &key, &args.path)?; + let anchor = build_anchor(&lines, &args.path, new_side, args.line)?; + let kind = parse_kind(args.kind.as_deref())?; + + let annotation = store + .insert(NewAnnotation { + kind, + changeset: key, + anchor: Some(anchor), + body: args.body, + author: args.author, + tour: args.tour, + seq: args.seq, + }) + .map_err(store_err)?; + to_json_string(&annotation_to_json(&annotation, None)) + } + + #[tool( + description = "Reply to an existing annotation. A reply has no anchor of its own \ + — it inherits the parent's location." + )] + async fn annotation_reply( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let store = open_store(&repo)?; + let annotation = store + .reply(&args.parent_uid, &args.body, &args.author) + .map_err(store_err)?; + to_json_string(&annotation_to_json(&annotation, None)) + } + + #[tool( + description = "Set an annotation's status. `resolved: true` (the default) \ + resolves it; `resolved: false` reopens it." + )] + async fn annotation_resolve( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let store = open_store(&repo)?; + let status = if args.resolved { + Status::Resolved + } else { + Status::Open + }; + store.set_status(&args.uid, status).map_err(store_err)?; + let annotation = get_or_not_found(&store, &args.uid)?; + to_json_string(&annotation_to_json(&annotation, None)) + } + + #[tool( + description = "Replace an annotation's body text (e.g. revising a walkthrough stop or \ + a redline note). The uid stays stable; updated_at moves." + )] + async fn annotation_update( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let store = open_store(&repo)?; + store + .update_body(&args.uid, &args.body) + .map_err(store_err)?; + let annotation = get_or_not_found(&store, &args.uid)?; + to_json_string(&annotation_to_json(&annotation, None)) + } + + #[tool(description = "Delete an annotation and (transitively) every reply to it.")] + async fn annotation_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let store = open_store(&repo)?; + store.delete(&args.uid).map_err(store_err)?; + Ok(json!({ "deleted": args.uid }).to_string()) + } + + #[tool( + description = "Write a whole walkthrough — an optional per-changeset chapter plus \ + ordered tour stops — in one transaction, so the TUI's watcher never observes a \ + half-authored tour." + )] + async fn walkthrough_put( + &self, + Parameters(args): Parameters, + ) -> Result { + let repo = discover_repo(args.repo_path.as_deref())?; + let key = ChangesetKey::new(args.changeset, args.uncommitted); + + let mut stops = Vec::with_capacity(args.stops.len()); + for stop in args.stops { + let new_side = parse_side(&stop.side)?; + let lines = read_lines(&repo, &key, &stop.path)?; + let anchor = build_anchor(&lines, &stop.path, new_side, stop.line)?; + stops.push(TourStop { + anchor, + body: stop.body, + author: stop.author, + seq: stop.seq, + }); + } + + let store = open_store(&repo)?; + store + .put_walkthrough(Walkthrough { + changeset: key, + tour: args.tour, + chapter: args.chapter, + chapter_author: args.chapter_author, + stops, + }) + .map_err(store_err)?; + Ok(json!({ "ok": true }).to_string()) + } +} + +// --- Tool argument shapes ------------------------------------------------------------- +// +// Every args struct carries an optional `repo_path`; `Repository::discover` resolves the +// current directory when it's absent, matching how `git` subcommands find their repo. + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct ListArgs { + /// Repository path to resolve from; defaults to discovering from the current directory. + repo_path: Option, + /// Branch name identifying the changeset. + changeset: String, + /// Whether `changeset` names its uncommitted layer rather than its committed tip. + #[serde(default)] + uncommitted: bool, + /// Restrict to annotations anchored to this file path. + path: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct GetArgs { + repo_path: Option, + uid: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct PostArgs { + repo_path: Option, + changeset: String, + #[serde(default)] + uncommitted: bool, + /// File path the annotation anchors to, relative to the repository root. + path: String, + /// Which side of the diff to anchor: `"new"` or `"old"`. + side: String, + /// 1-based line number on that side. + line: u32, + body: String, + author: String, + /// `"comment"` (default), `"tour_stop"`, or `"chapter"`. + kind: Option, + /// Tour name, required for `kind: "tour_stop"`. + tour: Option, + /// Order within the tour, required for `kind: "tour_stop"`. + seq: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct ReplyArgs { + repo_path: Option, + parent_uid: String, + body: String, + author: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct ResolveArgs { + repo_path: Option, + uid: String, + #[serde(default = "default_true")] + resolved: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct UpdateArgs { + repo_path: Option, + uid: String, + body: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct DeleteArgs { + repo_path: Option, + uid: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct StopArgs { + path: String, + side: String, + line: u32, + body: String, + author: String, + seq: i64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct WalkthroughPutArgs { + repo_path: Option, + changeset: String, + #[serde(default)] + uncommitted: bool, + tour: String, + chapter: Option, + chapter_author: Option, + #[serde(default)] + stops: Vec, +} + +// --- Repo/store plumbing -------------------------------------------------------------- + +fn discover_repo(repo_path: Option<&str>) -> Result { + git2::Repository::discover(repo_path.unwrap_or(".")) + .map_err(|source| mcp_err("workon::annotations::mcp::repo_discover_failed", source)) +} + +fn open_store(repo: &git2::Repository) -> Result { + AnnotationStore::open(repo.commondir()).map_err(store_err) +} + +fn get_or_not_found( + store: &AnnotationStore, + uid: &str, +) -> Result { + store.get(uid).map_err(store_err)?.ok_or_else(|| { + mcp_err( + "workon::annotations::not_found", + format!("no annotation with uid '{uid}'"), + ) + }) +} + +/// Read `path`'s lines for `changeset`: the worktree for the uncommitted layer, or the +/// blob at `changeset`'s branch tip for a committed one. No trailing empty line for a +/// file ending in `\n` (the common case). +fn read_lines( + repo: &git2::Repository, + changeset: &ChangesetKey, + path: &str, +) -> Result, McpError> { + let content = if changeset.uncommitted() { + let workdir = repo.workdir().ok_or_else(|| { + mcp_err( + "workon::annotations::mcp::no_workdir", + "repository has no working directory (bare repo)", + ) + })?; + std::fs::read_to_string(workdir.join(path)).map_err(|source| { + mcp_err( + "workon::annotations::mcp::content_read_failed", + format!("reading '{path}' from the worktree: {source}"), + ) + })? + } else { + let object = repo.revparse_single(changeset.name()).map_err(|source| { + mcp_err( + "workon::annotations::mcp::revparse_failed", + format!("resolving changeset '{}': {source}", changeset.name()), + ) + })?; + let commit = object.peel_to_commit().map_err(|source| { + mcp_err( + "workon::annotations::mcp::revparse_failed", + format!("'{}' does not name a commit: {source}", changeset.name()), + ) + })?; + let tree = commit + .tree() + .map_err(|source| mcp_err("workon::annotations::mcp::revparse_failed", source))?; + let entry = tree.get_path(Path::new(path)).map_err(|source| { + mcp_err( + "workon::annotations::mcp::path_not_found", + format!( + "'{path}' not found in '{}'s tree: {source}", + changeset.name() + ), + ) + })?; + let blob = repo.find_blob(entry.id()).map_err(|source| { + mcp_err( + "workon::annotations::mcp::content_read_failed", + format!("reading blob for '{path}': {source}"), + ) + })?; + String::from_utf8_lossy(blob.content()).into_owned() + }; + Ok(split_lines(&content)) +} + +fn split_lines(content: &str) -> Vec { + let mut lines: Vec = content.split('\n').map(str::to_string).collect(); + if lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + lines +} + +/// Build an anchor for `line` (1-based) against `lines`, capturing up to 3 lines of +/// context each way. +fn build_anchor( + lines: &[String], + path: &str, + new_side: bool, + line: u32, +) -> Result { + if line == 0 { + return Err(mcp_err( + "workon::annotations::mcp::invalid_line", + "line numbers are 1-based", + )); + } + let idx = (line - 1) as usize; + let target = lines + .get(idx) + .ok_or_else(|| { + mcp_err( + "workon::annotations::mcp::line_out_of_range", + format!( + "line {line} is out of range ({} lines in '{path}')", + lines.len() + ), + ) + })? + .clone(); + let before = lines[idx.saturating_sub(3)..idx].to_vec(); + let after_end = (idx + 1 + 3).min(lines.len()); + let after = lines[idx + 1..after_end].to_vec(); + + Ok(Anchor { + path: path.to_string(), + new_side, + lineno: line, + end_lineno: line, + target, + before, + after, + }) +} + +fn parse_side(side: &str) -> Result { + match side { + "new" => Ok(true), + "old" => Ok(false), + other => Err(mcp_err( + "workon::annotations::mcp::invalid_side", + format!("side must be \"new\" or \"old\", got \"{other}\""), + )), + } +} + +fn parse_kind(kind: Option<&str>) -> Result { + match kind.unwrap_or("comment") { + "comment" => Ok(AnnotationKind::Comment), + "tour_stop" => Ok(AnnotationKind::TourStop), + "chapter" => Ok(AnnotationKind::Chapter), + other => Err(mcp_err( + "workon::annotations::mcp::invalid_kind", + format!("kind must be \"comment\", \"tour_stop\", or \"chapter\", got \"{other}\""), + )), + } +} + +// --- Error and JSON plumbing ------------------------------------------------------------ + +/// Wrap a store error, carrying its `workon::annotations::*` diagnostic code (ADR-021 +/// style) into the tool error message rather than losing it to a generic string. +fn store_err(err: workon_annotations::AnnotationsError) -> McpError { + use miette::Diagnostic; + let code = err + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "workon::annotations::unknown".to_string()); + McpError::internal_error(format!("{code}: {err}"), None) +} + +/// Build a tool error carrying an explicit `workon::annotations::mcp::*` code — this +/// binary's own errors (repo discovery, content capture, bad arguments) follow the same +/// code-prefixed-message convention as the store's, even though they aren't +/// `AnnotationsError` values. +fn mcp_err(code: &str, message: impl std::fmt::Display) -> McpError { + McpError::invalid_params(format!("{code}: {message}"), None) +} + +fn to_json_string(value: &T) -> Result { + serde_json::to_string_pretty(value) + .map_err(|source| mcp_err("workon::annotations::mcp::encode_failed", source)) +} + +fn annotation_to_json( + annotation: &workon_annotations::Annotation, + resolution: Option, +) -> Value { + let anchor = annotation.anchor.as_ref().map(|anchor| { + json!({ + "path": anchor.path, + "side": if anchor.new_side { "new" } else { "old" }, + "lineno": anchor.lineno, + "endLineno": anchor.end_lineno, + "target": anchor.target, + "before": anchor.before, + "after": anchor.after, + }) + }); + + let mut value = json!({ + "uid": annotation.uid, + "kind": kind_str(annotation.kind), + "status": status_str(annotation.status), + "parentUid": annotation.parent_uid, + "changeset": { + "name": annotation.changeset.name(), + "uncommitted": annotation.changeset.uncommitted(), + }, + "anchor": anchor, + "body": annotation.body, + "author": annotation.author, + "tour": annotation.tour, + "seq": annotation.seq, + "createdAt": annotation.created_at, + "updatedAt": annotation.updated_at, + }); + + if let Some(resolution) = resolution { + value["anchoring"] = json!(anchoring_str(resolution.anchoring)); + value["resolvedLineno"] = json!(resolution.lineno); + } + + value +} + +fn kind_str(kind: AnnotationKind) -> &'static str { + match kind { + AnnotationKind::Comment => "comment", + AnnotationKind::TourStop => "tour_stop", + AnnotationKind::Chapter => "chapter", + } +} + +fn status_str(status: Status) -> &'static str { + match status { + Status::Open => "open", + Status::Resolved => "resolved", + } +} + +fn anchoring_str(anchoring: workon_annotations::Anchoring) -> String { + match anchoring { + workon_annotations::Anchoring::Exact => "exact".to_string(), + workon_annotations::Anchoring::Shifted { from } => format!("shifted(from={from})"), + workon_annotations::Anchoring::Orphaned => "orphaned".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Guards rmcp's silent-empty-router issue (upstream #1174): if `#[tool_router]` + /// silently drops every route, this catches it before the server ships with zero + /// tools. + #[test] + fn tool_router_serves_exactly_eight_tools() { + let router = WorkonServer::tool_router(); + assert_eq!(router.list_all().len(), 8); + } +} diff --git a/git-workon-mcp/src/tools/mod.rs b/git-workon-mcp/src/tools/mod.rs new file mode 100644 index 0000000..b439956 --- /dev/null +++ b/git-workon-mcp/src/tools/mod.rs @@ -0,0 +1,6 @@ +//! Tool routes grouped by domain, each adding its own routes to [`crate::server::WorkonServer`]. +//! +//! Today: [`annotations`], the review comment/walkthrough store (ADR-039). Future: worktree +//! and stack tools — see `docs/rfc/agent-integration.md` Model C. + +pub mod annotations; diff --git a/git-workon-mcp/tests/stdio.rs b/git-workon-mcp/tests/stdio.rs new file mode 100644 index 0000000..9171ced --- /dev/null +++ b/git-workon-mcp/tests/stdio.rs @@ -0,0 +1,198 @@ +//! Drives `git-workon-mcp` as a real child process over its stdio transport: the MCP +//! handshake, `tools/list`, and a `tools/call` round-trip (post an annotation, then fetch +//! it back), asserted against the sqlite file the binary wrote to. +//! +//! Newline-delimited JSON-RPC, no `Content-Length` framing (confirmed against rmcp's own +//! `transport::io::stdio` tests) — a raw `std::process::Command` plus `BufRead::read_line` +//! is enough; no async runtime needed on the test side. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +use serde_json::{json, Value}; + +struct Server { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Server { + fn spawn() -> Self { + let bin = env!("CARGO_BIN_EXE_git-workon-mcp"); + let mut child = Command::new(bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn git-workon-mcp"); + let stdin = child.stdin.take().expect("child stdin"); + let stdout = BufReader::new(child.stdout.take().expect("child stdout")); + Self { + child, + stdin, + stdout, + next_id: 1, + } + } + + fn send_notification(&mut self, method: &str) { + let message = json!({ "jsonrpc": "2.0", "method": method }); + self.write_line(&message); + } + + /// Send a request and return its `result` field (panics on a JSON-RPC error response). + fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let message = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + self.write_line(&message); + + let mut line = String::new(); + self.stdout + .read_line(&mut line) + .expect("read response line"); + assert!(!line.is_empty(), "server closed stdout without a response"); + let response: Value = serde_json::from_str(&line).expect("response is valid JSON"); + assert_eq!(response["id"], json!(id), "response id: {response}"); + if let Some(error) = response.get("error") { + panic!("{method} returned a JSON-RPC error: {error}"); + } + response["result"].clone() + } + + fn write_line(&mut self, message: &Value) { + let mut line = serde_json::to_string(message).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .expect("write request"); + self.stdin.flush().expect("flush request"); + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn init_repo() -> (tempfile::TempDir, git2::Repository) { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = git2::Repository::init(dir.path()).expect("init repo"); + std::fs::write( + dir.path().join("hello.txt"), + "one\ntwo\nthree\nfour\nfive\n", + ) + .expect("write fixture file"); + (dir, repo) +} + +#[test] +fn initialize_then_list_tools() { + let mut server = Server::spawn(); + + let init = server.call( + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "mcp-stdio-test", "version": "0.0.0" }, + }), + ); + assert_eq!(init["serverInfo"]["name"], json!("git-workon-mcp")); + server.send_notification("notifications/initialized"); + + let tools = server.call("tools/list", json!({})); + let names: Vec<&str> = tools["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|tool| tool["name"].as_str().expect("tool name")) + .collect(); + + let mut sorted = names.clone(); + sorted.sort_unstable(); + let mut expected = vec![ + "annotation_list", + "annotation_get", + "annotation_post", + "annotation_reply", + "annotation_resolve", + "annotation_update", + "annotation_delete", + "walkthrough_put", + ]; + expected.sort_unstable(); + assert_eq!(sorted, expected, "unexpected tool set: {names:?}"); +} + +#[test] +fn post_then_get_round_trips_through_the_store() { + let (dir, _repo) = init_repo(); + let mut server = Server::spawn(); + + server.call( + "initialize", + json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "mcp-stdio-test", "version": "0.0.0" }, + }), + ); + server.send_notification("notifications/initialized"); + + let repo_path = dir.path().to_str().expect("utf8 path").to_string(); + + let post_result = server.call( + "tools/call", + json!({ + "name": "annotation_post", + "arguments": { + "repoPath": repo_path, + "changeset": "main", + "uncommitted": true, + "path": "hello.txt", + "side": "new", + "line": 3, + "body": "why three?", + "author": "agent", + }, + }), + ); + let posted: Value = + serde_json::from_str(text_content(&post_result)).expect("post result is JSON"); + assert_eq!(posted["body"], json!("why three?")); + assert_eq!(posted["anchor"]["target"], json!("three")); + assert_eq!(posted["anchor"]["before"], json!(["one", "two"])); + assert_eq!(posted["anchor"]["after"], json!(["four", "five"])); + let uid = posted["uid"].as_str().expect("uid").to_string(); + + let get_result = server.call( + "tools/call", + json!({ + "name": "annotation_get", + "arguments": { "repoPath": repo_path, "uid": uid }, + }), + ); + let fetched: Value = + serde_json::from_str(text_content(&get_result)).expect("get result is JSON"); + assert_eq!(fetched["uid"], json!(uid)); + assert_eq!(fetched["author"], json!("agent")); + assert_eq!(fetched["status"], json!("open")); +} + +/// Pull the text out of a `tools/call` result's `content` array — rmcp wraps a `String` +/// tool return as a single text content block. +fn text_content(result: &Value) -> &str { + result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("expected text content in {result}")) +}