use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::{ErrorData, schemars, tool, tool_router};
use serde::{Deserialize, Serialize};
use crate::error::PawError;
use crate::mcp::query;
use crate::mcp::query::source::CodeMatch;
use crate::mcp::server::GitPawMcpServer;
#[allow(clippy::needless_pass_by_value)]
fn to_err(e: PawError) -> ErrorData {
ErrorData::internal_error(e.to_string(), None)
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListFilesParams {
#[serde(default)]
pub subpath: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ReadFileParams {
pub path: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchCodeParams {
pub query: String,
#[serde(default)]
pub subpath: Option<String>,
}
#[derive(Serialize, schemars::JsonSchema)]
pub struct FilesListResponse {
pub files: Vec<String>,
}
#[derive(Serialize, schemars::JsonSchema)]
pub struct ReadFileResponse {
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Serialize, schemars::JsonSchema)]
pub struct SearchResponse {
pub matches: Vec<CodeMatch>,
pub truncated: bool,
}
#[tool_router(router = source_router, vis = "pub(crate)")]
impl GitPawMcpServer {
#[tool(
description = "List the repository's working-tree files (tracked plus \
untracked-but-not-ignored), optionally scoped to a subpath. Gitignored \
paths (build artifacts, secrets) are excluded. Paths are relative to the \
repository root. Empty when not a git repository."
)]
pub(crate) fn list_files(
&self,
Parameters(p): Parameters<ListFilesParams>,
) -> Json<FilesListResponse> {
Json(FilesListResponse {
files: query::source::list_files(&self.ctx.root, p.subpath.as_deref()),
})
}
#[tool(
description = "Return one file's content from the local working tree, by path relative to \
the repository root. Confined to the repository root: a path escaping it \
(e.g. \"../\", an absolute path) is refused with null content and a message, \
not a read outside the root. Gitignored paths are also refused. Null content \
with a message when the file is absent."
)]
pub(crate) fn read_file(
&self,
Parameters(p): Parameters<ReadFileParams>,
) -> Result<Json<ReadFileResponse>, ErrorData> {
let outcome = query::source::read_file(&self.ctx.root, &p.path).map_err(to_err)?;
Ok(Json(ReadFileResponse {
content: outcome.content,
message: outcome.message,
}))
}
#[tool(
description = "Search file contents across the repository's working tree (tracked plus \
untracked-but-not-ignored, binaries skipped), optionally scoped to a \
subpath. Returns matches as { path, line_number, line }, capped with a \
`truncated` flag. Empty when there are no matches or not a git repository."
)]
pub(crate) fn search_code(
&self,
Parameters(p): Parameters<SearchCodeParams>,
) -> Json<SearchResponse> {
let (matches, truncated) =
query::source::search_code(&self.ctx.root, &p.query, p.subpath.as_deref());
Json(SearchResponse { matches, truncated })
}
}
#[cfg(test)]
mod tests {
use crate::mcp::RepoContext;
use crate::mcp::server::GitPawMcpServer;
use rmcp::handler::server::wrapper::Parameters;
use std::path::Path;
use std::process::Command;
fn server_for(root: std::path::PathBuf) -> GitPawMcpServer {
GitPawMcpServer::new(RepoContext {
root,
git_paw_dir: None,
broker_url: None,
server_name: "git-paw".to_string(),
})
}
fn git_run(dir: &Path, args: &[&str]) {
assert!(
Command::new("git")
.current_dir(dir)
.args(args)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
}
fn fixture() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for args in [
vec!["init", "-q", "-b", "main"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "Test"],
] {
git_run(dir, &args);
}
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("src/main.rs"),
"fn main() {\n register_watch_target_http();\n}\n",
)
.unwrap();
std::fs::write(dir.join(".gitignore"), "target/\n").unwrap();
git_run(dir, &["add", "src/main.rs", ".gitignore"]);
git_run(dir, &["commit", "-q", "-m", "first"]);
std::fs::create_dir_all(dir.join("target/debug")).unwrap();
std::fs::write(dir.join("target/debug/foo"), "build artifact\n").unwrap();
tmp
}
#[test]
fn list_files_happy_path() {
let tmp = fixture();
let server = server_for(tmp.path().canonicalize().unwrap());
let resp = server.list_files(Parameters(super::ListFilesParams { subpath: None }));
assert!(resp.0.files.iter().any(|f| f == "src/main.rs"));
assert!(!resp.0.files.iter().any(|f| f.starts_with("target/")));
}
#[test]
fn list_files_empty_when_not_git() {
let tmp = tempfile::tempdir().unwrap();
let server = server_for(tmp.path().canonicalize().unwrap());
let resp = server.list_files(Parameters(super::ListFilesParams { subpath: None }));
assert!(resp.0.files.is_empty());
}
#[test]
fn read_file_happy_path() {
let tmp = fixture();
let server = server_for(tmp.path().canonicalize().unwrap());
let resp = server
.read_file(Parameters(super::ReadFileParams {
path: "src/main.rs".to_string(),
}))
.unwrap();
assert!(
resp.0
.content
.unwrap()
.contains("register_watch_target_http")
);
assert!(resp.0.message.is_none());
}
#[test]
fn read_file_traversal_refused_not_transport_error() {
let tmp = fixture();
let parent = tmp.path().parent().unwrap();
std::fs::write(parent.join("paw-tool-secret.txt"), "TOPSECRET").unwrap();
let server = server_for(tmp.path().canonicalize().unwrap());
let resp = server
.read_file(Parameters(super::ReadFileParams {
path: "../paw-tool-secret.txt".to_string(),
}))
.expect("traversal refusal is not a transport error");
assert!(resp.0.content.is_none());
assert!(resp.0.message.is_some());
}
#[test]
fn read_file_gitignored_refused_not_transport_error() {
let tmp = fixture();
let server = server_for(tmp.path().canonicalize().unwrap());
let resp = server
.read_file(Parameters(super::ReadFileParams {
path: "target/debug/foo".to_string(),
}))
.expect("gitignored refusal is not a transport error");
assert!(resp.0.content.is_none());
assert!(resp.0.message.is_some());
}
#[test]
fn search_code_happy_path() {
let tmp = fixture();
let server = server_for(tmp.path().canonicalize().unwrap());
let resp = server.search_code(Parameters(super::SearchCodeParams {
query: "register_watch_target_http".to_string(),
subpath: None,
}));
assert_eq!(resp.0.matches.len(), 1);
assert_eq!(resp.0.matches[0].path, "src/main.rs");
assert!(!resp.0.truncated);
}
#[test]
fn search_code_empty_when_no_match() {
let tmp = fixture();
let server = server_for(tmp.path().canonicalize().unwrap());
let resp = server.search_code(Parameters(super::SearchCodeParams {
query: "a-string-that-appears-nowhere".to_string(),
subpath: None,
}));
assert!(resp.0.matches.is_empty());
assert!(!resp.0.truncated);
}
}