Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
554 changes: 554 additions & 0 deletions docs/mcp/spec.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod execlog;
mod git;
mod github;
mod gitlab;
mod mcp;
mod oplog;
mod output;
mod tracker;
Expand Down
196 changes: 196 additions & 0 deletions src/mcp/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! MCP (Model Context Protocol) server implementation for git-parsec.
//!
//! This module exposes parsec's worktree-native git workflow as an MCP server
//! over stdio JSON-RPC 2.0, allowing AI agents (Claude Desktop, Cursor, etc.)
//! to manage worktrees programmatically.
//!
//! ## Architecture
//!
//! ```text
//! parsec mcp serve
//! └─ McpServer (stdio JSON-RPC loop)
//! └─ ToolRegistry
//! ├─ worktree_list → tools::worktree::list
//! ├─ worktree_start → tools::worktree::start
//! ├─ worktree_status → tools::worktree::status
//! ├─ worktree_ship → tools::worktree::ship
//! ├─ smartlog → tools::smartlog::run
//! ├─ ci_status → tools::ci::status
//! ├─ pr_status → tools::pr::status
//! ├─ health_check → tools::health::check
//! ├─ reviews → tools::reviews::list
//! └─ sync → tools::sync::run
//! ```
//!
//! See `docs/mcp/spec.md` for the full tool catalogue and JSON schemas.
//!
//! ## Phases
//!
//! - **Phase 1** (this): module skeleton + tool registry shape.
//! - **Phase 2** (#293): stdio JSON-RPC echo server (`parsec mcp serve`).
//! - **Phase 3** (#293): wire real implementations to registered tools.

// Phase 1 skeleton: items are defined but the JSON-RPC dispatcher (Phase 2)
// has not been wired yet. Suppress dead_code until Phase 2 lands.
#![allow(dead_code)]

pub mod tools;

/// Context passed to every MCP tool handler.
///
/// Carries repository path, auth tokens (delegated by the caller), and
/// runtime flags like `dry_run`. Tools must not read auth from environment
/// directly; they must use this context.
#[derive(Debug, Clone)]
pub struct McpContext {
/// Absolute path to the git repository root.
pub repo_path: std::path::PathBuf,

/// GitHub Personal Access Token delegated by the MCP caller.
/// `None` when the tool does not require GitHub API access.
pub github_token: Option<String>,

/// When `true`, all mutating operations are previewed without
/// side effects. Tools must check this before any state change.
pub dry_run: bool,
}

impl McpContext {
/// Create a context from the current working directory.
pub fn from_cwd(dry_run: bool) -> anyhow::Result<Self> {
let repo_path = std::env::current_dir()?;
Ok(Self {
repo_path,
github_token: None,
dry_run,
})
}

/// Attach a GitHub PAT to the context.
#[must_use]
pub fn with_github_token(mut self, token: impl Into<String>) -> Self {
self.github_token = Some(token.into());
self
}
}

/// A registered MCP tool: name, description, and a handler stub.
///
/// In Phase 2 this will become a full `async fn` trait with JSON-Schema
/// introspection; for now it carries the metadata that `tools/list` needs.
pub struct ToolDef {
/// Machine-readable tool name (snake_case, matches spec).
pub name: &'static str,
/// Human-readable description returned in `tools/list` responses.
pub description: &'static str,
}

/// All tools exposed by the parsec MCP server.
///
/// This slice is the single source of truth for `tools/list` responses.
/// Add new tools here **and** implement them in `src/mcp/tools/`.
pub const TOOLS: &[ToolDef] = &[
ToolDef {
name: "worktree_list",
description: "List all active parsec worktrees with ticket, branch, PR, and CI status.",
},
ToolDef {
name: "worktree_start",
description: "Create an isolated git worktree for a ticket.",
},
ToolDef {
name: "worktree_status",
description:
"Show detailed status of a worktree: uncommitted changes, ahead/behind, PR, CI.",
},
ToolDef {
name: "worktree_ship",
description:
"Push the worktree branch to origin, create/update a GitHub PR, and optionally clean up.",
},
ToolDef {
name: "smartlog",
description:
"Render the smartlog DAG annotated with worktree branches, PR state, and CI status.",
},
ToolDef {
name: "ci_status",
description: "Fetch GitHub Actions CI check-run status for a worktree branch.",
},
ToolDef {
name: "pr_status",
description:
"Return GitHub PR state, review approvals, merge readiness, and review comments.",
},
ToolDef {
name: "health_check",
description:
"Run worktree health diagnostics: lock files, uncommitted changes, stale branches.",
},
ToolDef {
name: "reviews",
description:
"List PRs where the user is a requested reviewer or their own PRs awaiting review.",
},
ToolDef {
name: "sync",
description: "Rebase or merge-update stale worktrees against the current base branch.",
},
];

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn tools_list_is_non_empty() {
assert!(!TOOLS.is_empty(), "TOOLS registry must not be empty");
}

#[test]
fn tool_names_are_snake_case_and_unique() {
let mut seen = std::collections::HashSet::new();
for tool in TOOLS {
// snake_case: only lowercase letters, digits, underscores
assert!(
tool.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
"Tool name '{}' is not snake_case",
tool.name
);
assert!(
seen.insert(tool.name),
"Duplicate tool name: '{}'",
tool.name
);
}
}

#[test]
fn all_tools_have_descriptions() {
for tool in TOOLS {
assert!(
!tool.description.is_empty(),
"Tool '{}' has an empty description",
tool.name
);
}
}

#[test]
fn mcp_context_from_cwd() {
let ctx = McpContext::from_cwd(false).expect("should build context from cwd");
assert!(!ctx.dry_run);
assert!(ctx.github_token.is_none());
}

#[test]
fn mcp_context_with_token() {
let ctx = McpContext::from_cwd(true)
.unwrap()
.with_github_token("ghp_test");
assert!(ctx.dry_run);
assert_eq!(ctx.github_token.as_deref(), Some("ghp_test"));
}
}
9 changes: 9 additions & 0 deletions src/mcp/tools/ci.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! MCP tool stub for `ci_status`. Wired in Phase 3 (#293).

use crate::mcp::McpContext;

/// `ci_status` — fetch GitHub Actions check-run results for a worktree branch.
#[allow(dead_code)]
pub fn status(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
todo!("ci_status: implement in Phase 3 (#293)")
}
9 changes: 9 additions & 0 deletions src/mcp/tools/health.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! MCP tool stub for `health_check`. Wired in Phase 3 (#293).

use crate::mcp::McpContext;

/// `health_check` — run worktree health diagnostics.
#[allow(dead_code)]
pub fn check(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
todo!("health_check: implement in Phase 3 (#293)")
}
22 changes: 22 additions & 0 deletions src/mcp/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! MCP tool handler modules.
//!
//! Each sub-module corresponds to one or more tools in the catalogue defined
//! in `docs/mcp/spec.md`. Handlers are stubs in Phase 1; they will be wired
//! to real implementations in Phase 3 (issue #293).
//!
//! ## Adding a new tool
//!
//! 1. Add a `ToolDef` entry to `crate::mcp::TOOLS`.
//! 2. Create (or extend) a sub-module here.
//! 3. Implement `pub fn handle(ctx: &McpContext, input: serde_json::Value) -> anyhow::Result<serde_json::Value>`.
//! 4. Register the handler in `McpServer::dispatch` (Phase 2).

// Phase 1: modules are declared but contain only stub signatures.
// Implementations land in Phase 3 when the JSON-RPC server is wired up.
pub mod ci;
pub mod health;
pub mod pr;
pub mod reviews;
pub mod smartlog;
pub mod sync;
pub mod worktree;
9 changes: 9 additions & 0 deletions src/mcp/tools/pr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! MCP tool stub for `pr_status`. Wired in Phase 3 (#293).

use crate::mcp::McpContext;

/// `pr_status` — GitHub PR state, review approvals, and merge readiness.
#[allow(dead_code)]
pub fn status(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
todo!("pr_status: implement in Phase 3 (#293)")
}
9 changes: 9 additions & 0 deletions src/mcp/tools/reviews.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! MCP tool stub for `reviews`. Wired in Phase 3 (#293).

use crate::mcp::McpContext;

/// `reviews` — list incoming and outgoing review requests.
#[allow(dead_code)]
pub fn list(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
todo!("reviews: implement in Phase 3 (#293)")
}
12 changes: 12 additions & 0 deletions src/mcp/tools/smartlog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//! MCP tool stub for `smartlog`.
//!
//! Phase 1 stub — see `docs/mcp/spec.md` for the full schema.
//! Wired in Phase 3 (#293).

use crate::mcp::McpContext;

/// `smartlog` — render the commit DAG with PR/CI overlays.
#[allow(dead_code)]
pub fn run(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
todo!("smartlog: implement in Phase 3 (#293)")
}
9 changes: 9 additions & 0 deletions src/mcp/tools/sync.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! MCP tool stub for `sync`. Wired in Phase 3 (#293).

use crate::mcp::McpContext;

/// `sync` — rebase/merge stale worktrees against base branch.
#[allow(dead_code)]
pub fn run(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
todo!("sync: implement in Phase 3 (#293)")
}
53 changes: 53 additions & 0 deletions src/mcp/tools/worktree.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//! MCP tool stubs for worktree operations.
//!
//! Tools: `worktree_list`, `worktree_start`, `worktree_status`, `worktree_ship`.
//!
//! These are Phase 1 stubs — signatures and `todo!()` bodies.
//! Real implementations land in Phase 3 (issue #293).
//!
//! The `#[allow(dead_code)]` attributes are intentional: these handlers
//! will be called from the JSON-RPC dispatcher added in Phase 2 (#293).

use crate::mcp::McpContext;

/// `worktree_list` — list all parsec-managed worktrees.
///
/// # Errors
/// Returns an error if the git repository cannot be read.
#[allow(dead_code)]
pub fn list(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
// Phase 3: call crate::worktree::manager to enumerate worktrees,
// optionally fetch PR/CI status via crate::github.
todo!("worktree_list: implement in Phase 3 (#293)")
}

/// `worktree_start` — create a new worktree for a ticket.
///
/// # Errors
/// Returns an error if the worktree already exists or the ticket is invalid.
#[allow(dead_code)]
pub fn start(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
// Phase 3: call crate::worktree::lifecycle::create.
todo!("worktree_start: implement in Phase 3 (#293)")
}

/// `worktree_status` — detailed status for a single worktree.
///
/// # Errors
/// Returns an error if the ticket has no associated worktree.
#[allow(dead_code)]
pub fn status(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
// Phase 3: combine git2 status + github PR/CI queries.
todo!("worktree_status: implement in Phase 3 (#293)")
}

/// `worktree_ship` — push branch, open/update PR, optionally clean up.
///
/// # Errors
/// Returns an error if the worktree is dirty or the push fails.
#[allow(dead_code)]
pub fn ship(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serde_json::Value> {
// Phase 3: call crate::cli::commands::ship internals.
// Respect ctx.dry_run before any side effects.
todo!("worktree_ship: implement in Phase 3 (#293)")
}
Loading