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
66 changes: 46 additions & 20 deletions src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
//!
//! - **Phase 1**: module skeleton + tool registry shape.
//! - **Phase 2** (this, #293): stdio JSON-RPC skeleton (`parsec mcp serve`).
//! - **Phase 3** (#293): wire real implementations to registered tools.
//! - **Phase 3** (#293): replace structured stubs with real tool implementations.

// 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.
Expand Down Expand Up @@ -279,8 +279,8 @@ pub fn tools_list_payload() -> anyhow::Result<serde_json::Value> {
/// Serve newline-delimited JSON-RPC 2.0 messages over stdio.
///
/// This Phase 2 skeleton establishes the transport boundary used by MCP
/// clients. It supports `initialize`, `tools/list`, and an explicit `echo`
/// method for smoke tests; real tool dispatch lands in the next phase.
/// clients. It supports `initialize`, `tools/list`, `tools/call`, and an
/// explicit `echo` method for smoke tests.
pub fn serve_stdio(dry_run: bool) -> anyhow::Result<()> {
serve(std::io::stdin().lock(), std::io::stdout().lock(), dry_run)
}
Expand All @@ -290,7 +290,7 @@ where
R: BufRead,
W: Write,
{
let _ctx = McpContext::from_cwd(dry_run)?;
let ctx = McpContext::from_cwd(dry_run)?;
let mut writer = writer;

for line in reader.lines() {
Expand All @@ -300,7 +300,7 @@ where
}

let response = match serde_json::from_str::<serde_json::Value>(&line) {
Ok(request) => dispatch_json_rpc(request),
Ok(request) => dispatch_json_rpc_with_context(request, &ctx),
Err(err) => Some(json_rpc_error(
serde_json::Value::Null,
-32700,
Expand All @@ -320,6 +320,14 @@ where
}

fn dispatch_json_rpc(request: serde_json::Value) -> Option<serde_json::Value> {
let ctx = McpContext::from_cwd(false).ok()?;
dispatch_json_rpc_with_context(request, &ctx)
}

fn dispatch_json_rpc_with_context(
request: serde_json::Value,
ctx: &McpContext,
) -> Option<serde_json::Value> {
if is_notification(&request) {
return None;
}
Expand Down Expand Up @@ -358,7 +366,7 @@ fn dispatch_json_rpc(request: serde_json::Value) -> Option<serde_json::Value> {
.cloned()
.unwrap_or(serde_json::Value::Null),
)),
"tools/call" => Some(handle_tools_call(id, request.get("params"))),
"tools/call" => Some(handle_tools_call(id, request.get("params"), ctx)),
"" => Some(json_rpc_error(
id,
-32600,
Expand Down Expand Up @@ -403,6 +411,7 @@ fn json_rpc_error(
fn handle_tools_call(
id: serde_json::Value,
params: Option<&serde_json::Value>,
ctx: &McpContext,
) -> serde_json::Value {
let Some(params) = params.and_then(serde_json::Value::as_object) else {
return json_rpc_error(
Expand Down Expand Up @@ -440,20 +449,37 @@ fn handle_tools_call(
);
}

json_rpc_result(
id,
serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::json!({
"code": "not_implemented",
"message": "tools/call dispatch is planned for the next MCP phase",
"tool": name,
}).to_string(),
}],
"isError": true,
}),
)
let arguments = params
.get("arguments")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));

match tools::dispatch(name, ctx, arguments) {
Ok(payload) => json_rpc_result(id, mcp_content_envelope(payload, false)),
Err(err) => json_rpc_result(
id,
mcp_content_envelope(
serde_json::json!({
"error": {
"code": "tool_error",
"message": err.to_string(),
"tool": name,
}
}),
true,
),
),
}
}

fn mcp_content_envelope(payload: serde_json::Value, is_error: bool) -> serde_json::Value {
serde_json::json!({
"content": [{
"type": "text",
"text": payload.to_string(),
}],
"isError": is_error,
})
}

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/tools/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ 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)")
Err(super::not_implemented("ci_status"))
}
2 changes: 1 addition & 1 deletion src/mcp/tools/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ 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)")
Err(super::not_implemented("health_check"))
}
53 changes: 48 additions & 5 deletions src/mcp/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,65 @@
//! 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).
//! in `docs/mcp/spec.md`. Handlers are intentionally thin while the MCP
//! transport matures; concrete CLI integrations land behind this dispatch
//! boundary in later phases (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).
//! 4. Register the handler in [`dispatch`].

use crate::mcp::McpContext;

// 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;

/// Shared MCP tool handler signature.
pub type ToolHandler = fn(&McpContext, serde_json::Value) -> anyhow::Result<serde_json::Value>;

/// Return the executable handler for a registered MCP tool name.
#[must_use]
pub fn handler_for(name: &str) -> Option<ToolHandler> {
match name {
"worktree_list" => Some(worktree::list),
"worktree_start" => Some(worktree::start),
"worktree_status" => Some(worktree::status),
"worktree_ship" => Some(worktree::ship),
"smartlog" => Some(smartlog::run),
"ci_status" => Some(ci::status),
"pr_status" => Some(pr::status),
"health_check" => Some(health::check),
"reviews" => Some(reviews::list),
"sync" => Some(sync::run),
_ => None,
}
}

/// Dispatch an MCP tool call to its module-level handler.
///
/// # Errors
/// Returns the handler error when the tool is registered but its implementation
/// cannot complete. Unknown tools return an explicit error for defensive use.
pub fn dispatch(
name: &str,
ctx: &McpContext,
input: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let Some(handler) = handler_for(name) else {
anyhow::bail!("unknown MCP tool '{name}'");
};

handler(ctx, input)
}

pub(crate) fn not_implemented(tool: &str) -> anyhow::Error {
anyhow::anyhow!("{tool}: implementation is planned for a later MCP phase (#293)")
}
2 changes: 1 addition & 1 deletion src/mcp/tools/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ 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)")
Err(super::not_implemented("pr_status"))
}
2 changes: 1 addition & 1 deletion src/mcp/tools/reviews.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ 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)")
Err(super::not_implemented("reviews"))
}
2 changes: 1 addition & 1 deletion src/mcp/tools/smartlog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ 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)")
Err(super::not_implemented("smartlog"))
}
2 changes: 1 addition & 1 deletion src/mcp/tools/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ 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)")
Err(super::not_implemented("sync"))
}
8 changes: 4 additions & 4 deletions src/mcp/tools/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::mcp::McpContext;
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)")
Err(super::not_implemented("worktree_list"))
}

/// `worktree_start` — create a new worktree for a ticket.
Expand All @@ -28,7 +28,7 @@ pub fn list(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<serd
#[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)")
Err(super::not_implemented("worktree_start"))
}

/// `worktree_status` — detailed status for a single worktree.
Expand All @@ -38,7 +38,7 @@ pub fn start(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<ser
#[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)")
Err(super::not_implemented("worktree_status"))
}

/// `worktree_ship` — push branch, open/update PR, optionally clean up.
Expand All @@ -49,5 +49,5 @@ pub fn status(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result<se
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)")
Err(super::not_implemented("worktree_ship"))
}
Loading