diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 1cf8d76..fa809c3 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -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. @@ -279,8 +279,8 @@ pub fn tools_list_payload() -> anyhow::Result { /// 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) } @@ -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() { @@ -300,7 +300,7 @@ where } let response = match serde_json::from_str::(&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, @@ -320,6 +320,14 @@ where } fn dispatch_json_rpc(request: serde_json::Value) -> Option { + 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 { if is_notification(&request) { return None; } @@ -358,7 +366,7 @@ fn dispatch_json_rpc(request: serde_json::Value) -> Option { .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, @@ -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( @@ -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)] diff --git a/src/mcp/tools/ci.rs b/src/mcp/tools/ci.rs index 4d2c31d..684543a 100644 --- a/src/mcp/tools/ci.rs +++ b/src/mcp/tools/ci.rs @@ -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 { - todo!("ci_status: implement in Phase 3 (#293)") + Err(super::not_implemented("ci_status")) } diff --git a/src/mcp/tools/health.rs b/src/mcp/tools/health.rs index 28e914c..419dbec 100644 --- a/src/mcp/tools/health.rs +++ b/src/mcp/tools/health.rs @@ -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 { - todo!("health_check: implement in Phase 3 (#293)") + Err(super::not_implemented("health_check")) } diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 7d49127..390ea1a 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -1,18 +1,19 @@ //! 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`. -//! 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; @@ -20,3 +21,45 @@ 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; + +/// Return the executable handler for a registered MCP tool name. +#[must_use] +pub fn handler_for(name: &str) -> Option { + 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 { + 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)") +} diff --git a/src/mcp/tools/pr.rs b/src/mcp/tools/pr.rs index 6452e44..eba264b 100644 --- a/src/mcp/tools/pr.rs +++ b/src/mcp/tools/pr.rs @@ -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 { - todo!("pr_status: implement in Phase 3 (#293)") + Err(super::not_implemented("pr_status")) } diff --git a/src/mcp/tools/reviews.rs b/src/mcp/tools/reviews.rs index 7abf140..cbfbee6 100644 --- a/src/mcp/tools/reviews.rs +++ b/src/mcp/tools/reviews.rs @@ -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 { - todo!("reviews: implement in Phase 3 (#293)") + Err(super::not_implemented("reviews")) } diff --git a/src/mcp/tools/smartlog.rs b/src/mcp/tools/smartlog.rs index f81e4c1..794d52c 100644 --- a/src/mcp/tools/smartlog.rs +++ b/src/mcp/tools/smartlog.rs @@ -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 { - todo!("smartlog: implement in Phase 3 (#293)") + Err(super::not_implemented("smartlog")) } diff --git a/src/mcp/tools/sync.rs b/src/mcp/tools/sync.rs index 9aed6f6..3331290 100644 --- a/src/mcp/tools/sync.rs +++ b/src/mcp/tools/sync.rs @@ -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 { - todo!("sync: implement in Phase 3 (#293)") + Err(super::not_implemented("sync")) } diff --git a/src/mcp/tools/worktree.rs b/src/mcp/tools/worktree.rs index e3f2b4f..6a9906d 100644 --- a/src/mcp/tools/worktree.rs +++ b/src/mcp/tools/worktree.rs @@ -18,7 +18,7 @@ use crate::mcp::McpContext; pub fn list(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result { // 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. @@ -28,7 +28,7 @@ pub fn list(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result anyhow::Result { // 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. @@ -38,7 +38,7 @@ pub fn start(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result anyhow::Result { // 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. @@ -49,5 +49,5 @@ pub fn status(_ctx: &McpContext, _input: serde_json::Value) -> anyhow::Result anyhow::Result { // 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")) }