Skip to main content

git_paw/mcp/tools/
source.rs

1//! Source-browsing tools: `list_files`, `read_file`, `search_code`.
2//!
3//! These let an MCP client explore the repository's local working tree
4//! (tracked plus untracked-but-not-ignored files; gitignored paths excluded)
5//! and trace logic across files: `list_files` to enumerate, `search_code` to
6//! find a symbol, `read_file` to read it. `read_file` is confined to the
7//! repository root and refuses gitignored paths; a refused read returns null
8//! content with a `message`, not a transport error.
9
10use rmcp::handler::server::wrapper::{Json, Parameters};
11use rmcp::{ErrorData, schemars, tool, tool_router};
12use serde::{Deserialize, Serialize};
13
14use crate::error::PawError;
15use crate::mcp::query;
16use crate::mcp::query::source::CodeMatch;
17use crate::mcp::server::GitPawMcpServer;
18
19/// Maps an internal error to an MCP protocol error. Takes the error by value
20/// so it composes directly with `Result::map_err`.
21#[allow(clippy::needless_pass_by_value)]
22fn to_err(e: PawError) -> ErrorData {
23    ErrorData::internal_error(e.to_string(), None)
24}
25
26/// Parameters for [`GitPawMcpServer::list_files`].
27#[derive(Debug, Deserialize, schemars::JsonSchema)]
28pub struct ListFilesParams {
29    /// Optional subpath (relative to the repository root) to scope the listing
30    /// to. Omit to list the whole working tree.
31    #[serde(default)]
32    pub subpath: Option<String>,
33}
34
35/// Parameters for [`GitPawMcpServer::read_file`].
36#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct ReadFileParams {
38    /// File path relative to the repository root (e.g. `src/main.rs`). Paths
39    /// escaping the root or naming a gitignored file are refused.
40    pub path: String,
41}
42
43/// Parameters for [`GitPawMcpServer::search_code`].
44#[derive(Debug, Deserialize, schemars::JsonSchema)]
45pub struct SearchCodeParams {
46    /// String to search for across the working tree's file contents.
47    pub query: String,
48    /// Optional subpath (relative to the repository root) to scope the search
49    /// to. Omit to search the whole working tree.
50    #[serde(default)]
51    pub subpath: Option<String>,
52}
53
54/// Response for `list_files`.
55#[derive(Serialize, schemars::JsonSchema)]
56pub struct FilesListResponse {
57    /// Working-tree files (tracked plus untracked-not-ignored), relative to
58    /// the repository root. Empty when not a git repository.
59    pub files: Vec<String>,
60}
61
62/// Response for `read_file`.
63#[derive(Serialize, schemars::JsonSchema)]
64pub struct ReadFileResponse {
65    /// File content from the local working tree, or null when refused or
66    /// absent.
67    pub content: Option<String>,
68    /// Human-readable note when `content` is null (refused traversal, a
69    /// gitignored path, or a missing file). Absent when content is present.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub message: Option<String>,
72}
73
74/// Response for `search_code`.
75#[derive(Serialize, schemars::JsonSchema)]
76pub struct SearchResponse {
77    /// Matches, each with path, 1-based line number, and the matching line.
78    pub matches: Vec<CodeMatch>,
79    /// Whether the result was truncated at the internal match cap.
80    pub truncated: bool,
81}
82
83#[tool_router(router = source_router, vis = "pub(crate)")]
84impl GitPawMcpServer {
85    /// `list_files` — working-tree files, gitignored paths excluded.
86    #[tool(
87        description = "List the repository's working-tree files (tracked plus \
88                       untracked-but-not-ignored), optionally scoped to a subpath. Gitignored \
89                       paths (build artifacts, secrets) are excluded. Paths are relative to the \
90                       repository root. Empty when not a git repository."
91    )]
92    pub(crate) fn list_files(
93        &self,
94        Parameters(p): Parameters<ListFilesParams>,
95    ) -> Json<FilesListResponse> {
96        Json(FilesListResponse {
97            files: query::source::list_files(&self.ctx.root, p.subpath.as_deref()),
98        })
99    }
100
101    /// `read_file` — one file's content from the local working tree.
102    #[tool(
103        description = "Return one file's content from the local working tree, by path relative to \
104                       the repository root. Confined to the repository root: a path escaping it \
105                       (e.g. \"../\", an absolute path) is refused with null content and a message, \
106                       not a read outside the root. Gitignored paths are also refused. Null content \
107                       with a message when the file is absent."
108    )]
109    pub(crate) fn read_file(
110        &self,
111        Parameters(p): Parameters<ReadFileParams>,
112    ) -> Result<Json<ReadFileResponse>, ErrorData> {
113        let outcome = query::source::read_file(&self.ctx.root, &p.path).map_err(to_err)?;
114        Ok(Json(ReadFileResponse {
115            content: outcome.content,
116            message: outcome.message,
117        }))
118    }
119
120    /// `search_code` — search file contents across the working tree.
121    #[tool(
122        description = "Search file contents across the repository's working tree (tracked plus \
123                       untracked-but-not-ignored, binaries skipped), optionally scoped to a \
124                       subpath. Returns matches as { path, line_number, line }, capped with a \
125                       `truncated` flag. Empty when there are no matches or not a git repository."
126    )]
127    pub(crate) fn search_code(
128        &self,
129        Parameters(p): Parameters<SearchCodeParams>,
130    ) -> Json<SearchResponse> {
131        let (matches, truncated) =
132            query::source::search_code(&self.ctx.root, &p.query, p.subpath.as_deref());
133        Json(SearchResponse { matches, truncated })
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use crate::mcp::RepoContext;
140    use crate::mcp::server::GitPawMcpServer;
141    use rmcp::handler::server::wrapper::Parameters;
142    use std::path::Path;
143    use std::process::Command;
144
145    fn server_for(root: std::path::PathBuf) -> GitPawMcpServer {
146        GitPawMcpServer::new(RepoContext {
147            root,
148            git_paw_dir: None,
149            broker_url: None,
150            server_name: "git-paw".to_string(),
151        })
152    }
153
154    fn git_run(dir: &Path, args: &[&str]) {
155        assert!(
156            Command::new("git")
157                .current_dir(dir)
158                .args(args)
159                .status()
160                .unwrap()
161                .success(),
162            "git {args:?} failed"
163        );
164    }
165
166    fn fixture() -> tempfile::TempDir {
167        let tmp = tempfile::tempdir().unwrap();
168        let dir = tmp.path();
169        for args in [
170            vec!["init", "-q", "-b", "main"],
171            vec!["config", "user.email", "t@example.com"],
172            vec!["config", "user.name", "Test"],
173        ] {
174            git_run(dir, &args);
175        }
176        std::fs::create_dir_all(dir.join("src")).unwrap();
177        std::fs::write(
178            dir.join("src/main.rs"),
179            "fn main() {\n    register_watch_target_http();\n}\n",
180        )
181        .unwrap();
182        std::fs::write(dir.join(".gitignore"), "target/\n").unwrap();
183        git_run(dir, &["add", "src/main.rs", ".gitignore"]);
184        git_run(dir, &["commit", "-q", "-m", "first"]);
185        std::fs::create_dir_all(dir.join("target/debug")).unwrap();
186        std::fs::write(dir.join("target/debug/foo"), "build artifact\n").unwrap();
187        tmp
188    }
189
190    #[test]
191    fn list_files_happy_path() {
192        let tmp = fixture();
193        let server = server_for(tmp.path().canonicalize().unwrap());
194        let resp = server.list_files(Parameters(super::ListFilesParams { subpath: None }));
195        assert!(resp.0.files.iter().any(|f| f == "src/main.rs"));
196        assert!(!resp.0.files.iter().any(|f| f.starts_with("target/")));
197    }
198
199    #[test]
200    fn list_files_empty_when_not_git() {
201        let tmp = tempfile::tempdir().unwrap();
202        let server = server_for(tmp.path().canonicalize().unwrap());
203        let resp = server.list_files(Parameters(super::ListFilesParams { subpath: None }));
204        assert!(resp.0.files.is_empty());
205    }
206
207    #[test]
208    fn read_file_happy_path() {
209        let tmp = fixture();
210        let server = server_for(tmp.path().canonicalize().unwrap());
211        let resp = server
212            .read_file(Parameters(super::ReadFileParams {
213                path: "src/main.rs".to_string(),
214            }))
215            .unwrap();
216        assert!(
217            resp.0
218                .content
219                .unwrap()
220                .contains("register_watch_target_http")
221        );
222        assert!(resp.0.message.is_none());
223    }
224
225    #[test]
226    fn read_file_traversal_refused_not_transport_error() {
227        let tmp = fixture();
228        let parent = tmp.path().parent().unwrap();
229        std::fs::write(parent.join("paw-tool-secret.txt"), "TOPSECRET").unwrap();
230        let server = server_for(tmp.path().canonicalize().unwrap());
231        let resp = server
232            .read_file(Parameters(super::ReadFileParams {
233                path: "../paw-tool-secret.txt".to_string(),
234            }))
235            .expect("traversal refusal is not a transport error");
236        assert!(resp.0.content.is_none());
237        assert!(resp.0.message.is_some());
238    }
239
240    #[test]
241    fn read_file_gitignored_refused_not_transport_error() {
242        let tmp = fixture();
243        let server = server_for(tmp.path().canonicalize().unwrap());
244        let resp = server
245            .read_file(Parameters(super::ReadFileParams {
246                path: "target/debug/foo".to_string(),
247            }))
248            .expect("gitignored refusal is not a transport error");
249        assert!(resp.0.content.is_none());
250        assert!(resp.0.message.is_some());
251    }
252
253    #[test]
254    fn search_code_happy_path() {
255        let tmp = fixture();
256        let server = server_for(tmp.path().canonicalize().unwrap());
257        let resp = server.search_code(Parameters(super::SearchCodeParams {
258            query: "register_watch_target_http".to_string(),
259            subpath: None,
260        }));
261        assert_eq!(resp.0.matches.len(), 1);
262        assert_eq!(resp.0.matches[0].path, "src/main.rs");
263        assert!(!resp.0.truncated);
264    }
265
266    #[test]
267    fn search_code_empty_when_no_match() {
268        let tmp = fixture();
269        let server = server_for(tmp.path().canonicalize().unwrap());
270        let resp = server.search_code(Parameters(super::SearchCodeParams {
271            query: "a-string-that-appears-nowhere".to_string(),
272            subpath: None,
273        }));
274        assert!(resp.0.matches.is_empty());
275        assert!(!resp.0.truncated);
276    }
277}