Skip to content

Commit 58fe4a1

Browse files
erishforGclaude
andauthored
feat(mcp): Phase 4 wire readonly worktree tools (#360)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 495029f commit 58fe4a1

2 files changed

Lines changed: 116 additions & 15 deletions

File tree

src/mcp/tools/worktree.rs

Lines changed: 115 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,44 @@
22
//!
33
//! Tools: `worktree_list`, `worktree_start`, `worktree_status`, `worktree_ship`.
44
//!
5-
//! These are Phase 1 stubs — signatures and `todo!()` bodies.
6-
//! Real implementations land in Phase 3 (issue #293).
7-
//!
8-
//! The `#[allow(dead_code)]` attributes are intentional: these handlers
9-
//! will be called from the JSON-RPC dispatcher added in Phase 2 (#293).
5+
//! `worktree_list` and `worktree_status` are wired as read-only Phase 4
6+
//! handlers. Mutating operations remain stubs until later #293 phases.
107
8+
use crate::config::ParsecConfig;
119
use crate::mcp::McpContext;
10+
use crate::worktree::WorktreeManager;
11+
use anyhow::{Context, Result};
12+
use serde_json::json;
13+
use std::path::PathBuf;
14+
15+
fn repo_path(ctx: &McpContext, input: &serde_json::Value) -> PathBuf {
16+
input
17+
.get("repo")
18+
.and_then(serde_json::Value::as_str)
19+
.map(PathBuf::from)
20+
.unwrap_or_else(|| ctx.repo_path.clone())
21+
}
22+
23+
fn manager(ctx: &McpContext, input: &serde_json::Value) -> Result<WorktreeManager> {
24+
let config = ParsecConfig::load().context("failed to load parsec config")?;
25+
WorktreeManager::new(&repo_path(ctx, input), &config)
26+
}
1227

1328
/// `worktree_list` — list all parsec-managed worktrees.
1429
///
1530
/// # Errors
1631
/// Returns an error if the git repository cannot be read.
17-
#[allow(dead_code)]
18-
pub fn list(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
19-
// Phase 3: call crate::worktree::manager to enumerate worktrees,
20-
// optionally fetch PR/CI status via crate::github.
21-
Err(super::not_implemented("worktree_list"))
32+
pub fn list(ctx: &McpContext, input: serde_json::Value) -> Result<serde_json::Value> {
33+
let manager = manager(ctx, &input)?;
34+
let worktrees = manager.list()?;
35+
36+
Ok(json!({
37+
"repo": manager.repo_root(),
38+
"count": worktrees.len(),
39+
"worktrees": worktrees,
40+
"pr_overlay": false,
41+
"ci_overlay": false,
42+
}))
2243
}
2344

2445
/// `worktree_start` — create a new worktree for a ticket.
@@ -35,10 +56,23 @@ pub fn start(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<ser
3556
///
3657
/// # Errors
3758
/// Returns an error if the ticket has no associated worktree.
38-
#[allow(dead_code)]
39-
pub fn status(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
40-
// Phase 3: combine git2 status + github PR/CI queries.
41-
Err(super::not_implemented("worktree_status"))
59+
pub fn status(ctx: &McpContext, input: serde_json::Value) -> Result<serde_json::Value> {
60+
let ticket = input
61+
.get("ticket")
62+
.and_then(serde_json::Value::as_str)
63+
.filter(|ticket| !ticket.is_empty())
64+
.context("worktree_status requires a non-empty ticket")?;
65+
crate::worktree::validate_ticket_id(ticket)?;
66+
67+
let manager = manager(ctx, &input)?;
68+
let workspace = manager.get(ticket)?;
69+
70+
Ok(json!({
71+
"repo": manager.repo_root(),
72+
"workspace": workspace,
73+
"pr_overlay": null,
74+
"ci_overlay": null,
75+
}))
4276
}
4377

4478
/// `worktree_ship` — push branch, open/update PR, optionally clean up.
@@ -51,3 +85,70 @@ pub fn ship(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serd
5185
// Respect ctx.dry_run before any side effects.
5286
Err(super::not_implemented("worktree_ship"))
5387
}
88+
89+
#[cfg(test)]
90+
mod tests {
91+
use super::*;
92+
use crate::worktree::{ParsecState, Workspace, WorkspaceStatus};
93+
use chrono::TimeZone;
94+
95+
fn fixture_repo() -> (tempfile::TempDir, McpContext) {
96+
let dir = tempfile::tempdir().expect("temp repo");
97+
crate::git::run(dir.path(), &["init"]).expect("git init");
98+
99+
let mut state = ParsecState::default();
100+
state.add_workspace(Workspace {
101+
ticket: "ABC-123".to_owned(),
102+
path: dir.path().join("../git-parsec.ABC-123"),
103+
branch: "feat/ABC-123".to_owned(),
104+
base_branch: "main".to_owned(),
105+
created_at: chrono::Utc.with_ymd_and_hms(2026, 6, 17, 0, 0, 0).unwrap(),
106+
ticket_title: Some("Wire MCP worktree status".to_owned()),
107+
status: WorkspaceStatus::Active,
108+
parent_ticket: None,
109+
});
110+
state.save(dir.path()).expect("state save");
111+
112+
let ctx = McpContext {
113+
repo_path: dir.path().to_path_buf(),
114+
github_token: None,
115+
dry_run: false,
116+
};
117+
118+
(dir, ctx)
119+
}
120+
121+
#[test]
122+
fn worktree_list_returns_registered_workspaces() {
123+
let (_dir, ctx) = fixture_repo();
124+
125+
let payload = list(&ctx, json!({})).expect("worktree list");
126+
127+
assert_eq!(payload["count"], 1);
128+
assert_eq!(payload["worktrees"][0]["ticket"], "ABC-123");
129+
assert_eq!(payload["worktrees"][0]["branch"], "feat/ABC-123");
130+
assert_eq!(payload["pr_overlay"], false);
131+
assert_eq!(payload["ci_overlay"], false);
132+
}
133+
134+
#[test]
135+
fn worktree_status_returns_matching_workspace() {
136+
let (_dir, ctx) = fixture_repo();
137+
138+
let payload = status(&ctx, json!({"ticket": "ABC-123"})).expect("worktree status payload");
139+
140+
assert_eq!(payload["workspace"]["ticket"], "ABC-123");
141+
assert_eq!(payload["workspace"]["base_branch"], "main");
142+
assert!(payload["pr_overlay"].is_null());
143+
assert!(payload["ci_overlay"].is_null());
144+
}
145+
146+
#[test]
147+
fn worktree_status_requires_ticket() {
148+
let (_dir, ctx) = fixture_repo();
149+
150+
let err = status(&ctx, json!({})).expect_err("missing ticket should fail");
151+
152+
assert!(err.to_string().contains("requires a non-empty ticket"));
153+
}
154+
}

tests/mcp/fixtures/stdio_smoke.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{"name":"initialize","request":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":1},{"pointer":"/result/protocolVersion","equals":"2025-03-26"},{"pointer":"/result/serverInfo/name","equals":"git-parsec"},{"pointer":"/result/capabilities/tools","kind":"object"}]}
33
{"name":"initialized-notification","request":{"jsonrpc":"2.0","method":"notifications/initialized","params":{}},"no_response":true}
44
{"name":"tools-list","request":{"jsonrpc":"2.0","id":"tools","method":"tools/list"},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"tools"},{"pointer":"/result/tools","min_len":10},{"pointer":"/result/tools","contains_tool":"worktree_list"},{"pointer":"/result/tools","contains_tool":"worktree_ship"},{"pointer":"/result/tools","contains_tool":"smartlog"},{"pointer":"/result/tools","contains_tool":"ci_status"},{"pointer":"/result/tools","contains_tool":"reviews"}]}
5-
{"name":"tools-call-unwired","request":{"jsonrpc":"2.0","id":"call","method":"tools/call","params":{"name":"worktree_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"call"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"worktree_status"}]}
5+
{"name":"tools-call-missing-worktree","request":{"jsonrpc":"2.0","id":"call","method":"tools/call","params":{"name":"worktree_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"call"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"}]}
66
{"name":"tools-call-auth-required","request":{"jsonrpc":"2.0","id":"auth","method":"tools/call","params":{"name":"pr_status","arguments":{"ticket":"ABC-123"}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"auth"},{"pointer":"/result/isError","equals":true},{"pointer":"/result/content","min_len":1},{"pointer":"/result/content/0/type","equals":"text"},{"pointer":"/result/content/0/text","contains_text":"AUTH_REQUIRED"},{"pointer":"/result/content/0/text","contains_text":"pull_request:read"}]}
77
{"name":"tools-call-unknown-tool","request":{"jsonrpc":"2.0","id":"bad-tool","method":"tools/call","params":{"name":"nope","arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"bad-tool"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"}]}
88
{"name":"tools-call-missing-name","request":{"jsonrpc":"2.0","id":"missing-name","method":"tools/call","params":{"arguments":{}}},"assertions":[{"pointer":"/jsonrpc","equals":"2.0"},{"pointer":"/id","equals":"missing-name"},{"pointer":"/error/code","equals":-32602},{"pointer":"/error/message","equals":"Invalid params"},{"pointer":"/error/data","contains_text":"params.name"}]}

0 commit comments

Comments
 (0)