1use 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#[allow(clippy::needless_pass_by_value)]
22fn to_err(e: PawError) -> ErrorData {
23 ErrorData::internal_error(e.to_string(), None)
24}
25
26#[derive(Debug, Deserialize, schemars::JsonSchema)]
28pub struct ListFilesParams {
29 #[serde(default)]
32 pub subpath: Option<String>,
33}
34
35#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct ReadFileParams {
38 pub path: String,
41}
42
43#[derive(Debug, Deserialize, schemars::JsonSchema)]
45pub struct SearchCodeParams {
46 pub query: String,
48 #[serde(default)]
51 pub subpath: Option<String>,
52}
53
54#[derive(Serialize, schemars::JsonSchema)]
56pub struct FilesListResponse {
57 pub files: Vec<String>,
60}
61
62#[derive(Serialize, schemars::JsonSchema)]
64pub struct ReadFileResponse {
65 pub content: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
71 pub message: Option<String>,
72}
73
74#[derive(Serialize, schemars::JsonSchema)]
76pub struct SearchResponse {
77 pub matches: Vec<CodeMatch>,
79 pub truncated: bool,
81}
82
83#[tool_router(router = source_router, vis = "pub(crate)")]
84impl GitPawMcpServer {
85 #[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 #[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 #[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}