Skip to main content

git_paw/mcp/
mod.rs

1//! Read-only Model Context Protocol (MCP) server for `git paw mcp`.
2//!
3//! Exposes this repository's deterministic read-only state — coordination
4//! intents/conflicts, governance docs, specs and tasks, session status and
5//! learnings, agent skills, and git context — over the standard MCP protocol
6//! on stdio, so any MCP-aware client (Claude Desktop, Cursor, `ChatGPT`
7//! Desktop, Windsurf, VS Code MCP) can query it.
8//!
9//! # Module layout & dependency rule (design D2)
10//!
11//! ```text
12//! mcp/
13//! ├── mod.rs     entry: cmd_mcp(), RepoContext + repo resolution
14//! ├── server.rs  stdio transport setup, tool registry wiring, lifecycle
15//! ├── tools/     MCP tool definitions (one file per category)
16//! └── query/     data-layer reads (no MCP types here)
17//! ```
18//!
19//! The dependency direction is strict and one-way:
20//!
21//! - `query` knows **nothing** about MCP — it returns plain Rust / serde
22//!   types built from broker HTTP state, files on disk, and git output.
23//! - `tools` knows about MCP **and** `query`, but not about `server`.
24//! - `server` only wires `tools` onto a transport.
25//!
26//! This keeps the future v2.0.0 HTTP transport additive: drop in a new
27//! `server.rs` variant and reuse `tools` + `query` unchanged.
28//!
29//! # Guardrails
30//!
31//! - **No agent CLI is ever spawned** as an inference backend. Every tool
32//!   result is derived from deterministic data sources only.
33//! - **stdout is reserved** for JSON-RPC frames. All logging goes to stderr
34//!   (see [`crate::mcp::server`]); there are no `print!`/`println!` calls in
35//!   this module tree (enforced by a unit test in this file).
36
37pub mod logging;
38pub mod query;
39pub mod server;
40pub mod tools;
41
42use std::path::{Path, PathBuf};
43
44use crate::error::PawError;
45use crate::session::{self, Session, SessionStatus};
46
47/// Resolved context for a single `git paw mcp` invocation, constructed once
48/// at startup and shared (read-only) by every tool.
49#[derive(Debug, Clone)]
50pub struct RepoContext {
51    /// Absolute repository root (a worktree root resolves to its own root,
52    /// not the main repository — see [`resolve_repo`]).
53    pub root: PathBuf,
54    /// `<root>/.git-paw/` when it exists, else `None` (cold / pure-manual
55    /// repo).
56    pub git_paw_dir: Option<PathBuf>,
57    /// Broker base URL (`http://host:port`) derived from the active session
58    /// receipt, or `None` when no session is active. Liveness is **not**
59    /// probed here — query helpers attempt the HTTP call and degrade to
60    /// empty results on failure.
61    pub broker_url: Option<String>,
62    /// Effective server identity advertised in the `initialize` handshake's
63    /// `serverInfo.name`. Resolved once at construction from the loaded
64    /// config's `[mcp].name` (defaulting to `"git-paw"`) so the server handler
65    /// never re-loads config per `get_info()` call.
66    pub server_name: String,
67}
68
69impl RepoContext {
70    /// Builds a [`RepoContext`] from a resolved repository root.
71    ///
72    /// Reads the active session receipt (if any) to populate
73    /// [`RepoContext::broker_url`]; a missing or stopped session simply
74    /// leaves it `None`. Resolves the advertised server identity from the
75    /// merged config's `[mcp].name`, defaulting to `"git-paw"` when unset (or
76    /// when the config cannot be loaded).
77    #[must_use]
78    pub fn for_root(root: PathBuf) -> Self {
79        let git_paw_dir = {
80            let candidate = root.join(".git-paw");
81            candidate.is_dir().then_some(candidate)
82        };
83        let broker_url = session::find_session_for_repo(&root)
84            .ok()
85            .flatten()
86            .as_ref()
87            .and_then(broker_url_from_session);
88        let server_name = crate::config::load_config(&root, None)
89            .map_or_else(|_| "git-paw".to_string(), |cfg| cfg.mcp_server_name());
90        Self {
91            root,
92            git_paw_dir,
93            broker_url,
94            server_name,
95        }
96    }
97}
98
99/// Derives the broker base URL from a session receipt, or `None` when the
100/// session carries no broker port (broker disabled, or session not active).
101fn broker_url_from_session(session: &Session) -> Option<String> {
102    // A stopped session's broker is gone; a paused session has stopped its
103    // broker too. Only an active session can have a reachable broker.
104    if session.status != SessionStatus::Active {
105        return None;
106    }
107    let port = session.broker_port?;
108    let bind = session.broker_bind.as_deref().unwrap_or("127.0.0.1");
109    // 0.0.0.0 (listen-on-all) is not a connectable address from a client.
110    let host = if bind == "0.0.0.0" || bind.is_empty() {
111        "127.0.0.1"
112    } else {
113        bind
114    };
115    Some(format!("http://{host}:{port}"))
116}
117
118/// Resolves the target repository root per design D3.
119///
120/// Precedence:
121/// 1. `--repo <path>` if provided — canonicalized, then required to be a git
122///    repository (errors clearly with the path otherwise).
123/// 2. Otherwise the nearest ancestor of the current directory containing a
124///    `.git` entry. Worktrees resolve to their **own** root (git
125///    `rev-parse --show-toplevel` returns the worktree root).
126///
127/// Returns a clear, human-readable [`PawError::McpError`] when no repository
128/// can be resolved — the server never silently serves nothing.
129pub fn resolve_repo(repo_flag: Option<&Path>) -> Result<PathBuf, PawError> {
130    if let Some(flag) = repo_flag {
131        let canonical = flag.canonicalize().map_err(|e| {
132            PawError::McpError(format!(
133                "--repo path {} could not be opened: {e}. Pass an existing repository path.",
134                flag.display()
135            ))
136        })?;
137        crate::git::validate_repo(&canonical).map_err(|_| {
138            PawError::McpError(format!(
139                "--repo path {} is not a git repository. Point --repo at a directory inside a git repo.",
140                canonical.display()
141            ))
142        })
143    } else {
144        let cwd = std::env::current_dir()
145            .map_err(|e| PawError::McpError(format!("cannot read current directory: {e}")))?;
146        crate::git::validate_repo(&cwd).map_err(|_| {
147            PawError::McpError(
148                "no git repository found in the current directory or any parent. \
149                 Run `git paw mcp` from inside a git repository, or pass \
150                 `--repo <path>` (required for clients like Claude Desktop that \
151                 spawn from a fixed directory)."
152                    .to_string(),
153            )
154        })
155    }
156}
157
158/// Entry point for the `git paw mcp` subcommand.
159///
160/// Resolves the repository, builds the [`RepoContext`], initializes stderr
161/// logging, and runs the stdio MCP server until the client closes stdin.
162pub fn cmd_mcp(repo_flag: Option<&Path>, log_file: Option<&Path>) -> Result<(), PawError> {
163    let root = resolve_repo(repo_flag)?;
164    let context = RepoContext::for_root(root);
165    server::run(context, log_file)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::process::Command;
172
173    /// Initializes a throwaway git repo at `dir`.
174    fn git_init(dir: &Path) {
175        for args in [
176            vec!["init", "-q"],
177            vec!["config", "user.email", "t@example.com"],
178            vec!["config", "user.name", "Test"],
179        ] {
180            let ok = Command::new("git")
181                .current_dir(dir)
182                .args(&args)
183                .status()
184                .expect("git runs")
185                .success();
186            assert!(ok, "git {args:?} failed");
187        }
188    }
189
190    #[test]
191    fn resolve_repo_with_valid_repo_path_returns_root() {
192        let tmp = tempfile::tempdir().unwrap();
193        let repo = tmp.path().join("proj");
194        std::fs::create_dir(&repo).unwrap();
195        git_init(&repo);
196
197        let resolved = resolve_repo(Some(&repo)).expect("valid repo resolves");
198        assert_eq!(
199            resolved.canonicalize().unwrap(),
200            repo.canonicalize().unwrap()
201        );
202    }
203
204    #[test]
205    fn resolve_repo_with_non_git_path_errors_with_path() {
206        let tmp = tempfile::tempdir().unwrap();
207        let not_repo = tmp.path().join("plain");
208        std::fs::create_dir(&not_repo).unwrap();
209
210        let err = resolve_repo(Some(&not_repo)).expect_err("non-git path must error");
211        let msg = err.to_string();
212        assert!(msg.contains("not a git repository"), "got: {msg}");
213    }
214
215    #[test]
216    fn resolve_repo_with_nonexistent_path_errors() {
217        let err = resolve_repo(Some(Path::new("/no/such/path/at/all")))
218            .expect_err("nonexistent path must error");
219        assert!(
220            err.to_string().contains("could not be opened"),
221            "got: {err}"
222        );
223    }
224
225    #[test]
226    fn resolve_repo_from_subdir_finds_enclosing_repo() {
227        let tmp = tempfile::tempdir().unwrap();
228        let repo = tmp.path().join("proj");
229        std::fs::create_dir(&repo).unwrap();
230        git_init(&repo);
231        let sub = repo.join("a").join("b");
232        std::fs::create_dir_all(&sub).unwrap();
233
234        // resolve_repo(Some(subdir)) exercises the same validate_repo path the
235        // CWD branch uses, without mutating the process-wide current dir.
236        let resolved = resolve_repo(Some(&sub)).expect("subdir resolves to enclosing repo");
237        assert_eq!(
238            resolved.canonicalize().unwrap(),
239            repo.canonicalize().unwrap()
240        );
241    }
242
243    #[test]
244    fn resolve_repo_worktree_resolves_to_worktree_root() {
245        let tmp = tempfile::tempdir().unwrap();
246        let main = tmp.path().join("main");
247        std::fs::create_dir(&main).unwrap();
248        git_init(&main);
249        // Need a commit before adding a worktree.
250        std::fs::write(main.join("README.md"), "hi").unwrap();
251        for args in [vec!["add", "."], vec!["commit", "-q", "-m", "init"]] {
252            assert!(
253                Command::new("git")
254                    .current_dir(&main)
255                    .args(&args)
256                    .status()
257                    .unwrap()
258                    .success()
259            );
260        }
261        let wt = tmp.path().join("wt");
262        assert!(
263            Command::new("git")
264                .current_dir(&main)
265                .args([
266                    "worktree",
267                    "add",
268                    "-q",
269                    wt.to_str().unwrap(),
270                    "-b",
271                    "feat/x"
272                ])
273                .status()
274                .unwrap()
275                .success(),
276            "worktree add failed"
277        );
278
279        let resolved = resolve_repo(Some(&wt)).expect("worktree resolves");
280        assert_eq!(
281            resolved.canonicalize().unwrap(),
282            wt.canonicalize().unwrap(),
283            "worktree must resolve to its own root, not the main repo"
284        );
285    }
286
287    #[test]
288    fn for_root_without_git_paw_dir_yields_none() {
289        let tmp = tempfile::tempdir().unwrap();
290        let repo = tmp.path().join("proj");
291        std::fs::create_dir(&repo).unwrap();
292        git_init(&repo);
293
294        let ctx = RepoContext::for_root(repo.canonicalize().unwrap());
295        assert!(ctx.git_paw_dir.is_none());
296        assert!(ctx.broker_url.is_none());
297    }
298
299    #[test]
300    fn for_root_with_git_paw_dir_is_some() {
301        let tmp = tempfile::tempdir().unwrap();
302        let repo = tmp.path().join("proj");
303        std::fs::create_dir(&repo).unwrap();
304        git_init(&repo);
305        std::fs::create_dir(repo.join(".git-paw")).unwrap();
306
307        let ctx = RepoContext::for_root(repo.canonicalize().unwrap());
308        assert!(ctx.git_paw_dir.is_some());
309    }
310
311    /// Lint (design risk: stdout pollution kills the MCP protocol): assert no
312    /// `print!`/`println!` invocations exist anywhere under `src/mcp/`. Only
313    /// `eprint!`/`eprintln!`/`tracing` (all stderr) are permitted.
314    #[test]
315    fn no_stdout_macros_under_src_mcp() {
316        let mcp_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
317            .join("src")
318            .join("mcp");
319        let mut offenders = Vec::new();
320        visit_rs_files(&mcp_dir, &mut |path, contents| {
321            for (lineno, line) in contents.lines().enumerate() {
322                // Skip comment lines (the doc-comment naming the macros).
323                let trimmed = line.trim_start();
324                if trimmed.starts_with("//") || trimmed.starts_with('*') {
325                    continue;
326                }
327                // Flag genuine macro *calls* (`println!(`), not the literal text
328                // appearing inside a string (e.g. this detector itself, where it
329                // is preceded by a `"`).
330                if is_macro_call(line, "println!(") || is_macro_call(line, "print!(") {
331                    offenders.push(format!("{}:{}", path.display(), lineno + 1));
332                }
333            }
334        });
335        assert!(
336            offenders.is_empty(),
337            "stdout macros found under src/mcp/ (stdout is reserved for JSON-RPC): {offenders:?}"
338        );
339    }
340
341    /// Returns true if `needle` (a macro-call prefix like `println!(`) appears
342    /// in `line` as a real invocation — i.e. not immediately preceded by a `"`
343    /// (which would mean it is inside a string literal, like in this detector).
344    fn is_macro_call(line: &str, needle: &str) -> bool {
345        let mut from = 0;
346        while let Some(rel) = line[from..].find(needle) {
347            let idx = from + rel;
348            let prev = line[..idx].chars().next_back();
349            if prev != Some('"') {
350                return true;
351            }
352            from = idx + needle.len();
353        }
354        false
355    }
356
357    fn visit_rs_files(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
358        let Ok(entries) = std::fs::read_dir(dir) else {
359            return;
360        };
361        for entry in entries.flatten() {
362            let path = entry.path();
363            if path.is_dir() {
364                visit_rs_files(&path, f);
365            } else if path.extension().is_some_and(|e| e == "rs")
366                && let Ok(contents) = std::fs::read_to_string(&path)
367            {
368                f(&path, &contents);
369            }
370        }
371    }
372}