git_paw/mcp/tools/
docs.rs1use rmcp::handler::server::wrapper::{Json, Parameters};
11use rmcp::{ErrorData, schemars, tool, tool_router};
12use serde::{Deserialize, Serialize};
13
14use crate::config::GovernanceConfig;
15use crate::error::PawError;
16use crate::mcp::query;
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 GetDocParams {
29 pub path: String,
32}
33
34#[derive(Serialize, schemars::JsonSchema)]
36pub struct ReadmeResponse {
37 pub content: Option<String>,
40}
41
42#[derive(Serialize, schemars::JsonSchema)]
44pub struct DocsListResponse {
45 pub docs: Vec<query::docs::DocEntry>,
48}
49
50#[derive(Serialize, schemars::JsonSchema)]
52pub struct DocResponse {
53 pub content: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
58 pub message: Option<String>,
59}
60
61impl GitPawMcpServer {
62 fn docs_governance(&self) -> Result<GovernanceConfig, ErrorData> {
63 query::governance::load(&self.ctx.root).map_err(to_err)
64 }
65}
66
67#[tool_router(router = docs_router, vis = "pub(crate)")]
68impl GitPawMcpServer {
69 #[tool(
71 description = "Return the configured [governance].readme content, or null when unset or \
72 the file is absent. Errors only if the configured file is unreadable."
73 )]
74 pub(crate) fn get_readme(&self) -> Result<Json<ReadmeResponse>, ErrorData> {
75 let gov = self.docs_governance()?;
76 let content = query::docs::read_readme(&self.ctx.root, &gov).map_err(to_err)?;
77 Ok(Json(ReadmeResponse { content }))
78 }
79
80 #[tool(
82 description = "List Markdown documents under the configured [governance].docs directory, \
83 each with its path relative to that directory. Empty when unset or the \
84 directory is absent."
85 )]
86 pub(crate) fn list_docs(&self) -> Result<Json<DocsListResponse>, ErrorData> {
87 let gov = self.docs_governance()?;
88 Ok(Json(DocsListResponse {
89 docs: query::docs::list_docs(&self.ctx.root, &gov),
90 }))
91 }
92
93 #[tool(
95 description = "Return the content of one document under the configured [governance].docs \
96 directory, by path relative to it. Confined to that directory: a path \
97 escaping it (e.g. \"../\") is refused with null content and a message, not \
98 a read outside the directory. Null content with a message when the doc is \
99 absent or docs is unset."
100 )]
101 pub(crate) fn get_doc(
102 &self,
103 Parameters(p): Parameters<GetDocParams>,
104 ) -> Result<Json<DocResponse>, ErrorData> {
105 let gov = self.docs_governance()?;
106 let content = query::docs::read_doc(&self.ctx.root, &gov, &p.path).map_err(to_err)?;
107 let message = if content.is_none() {
108 Some(format!(
109 "no document available for path {:?} (unset, not found, or refused as outside \
110 the configured docs directory)",
111 p.path
112 ))
113 } else {
114 None
115 };
116 Ok(Json(DocResponse { content, message }))
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use crate::mcp::RepoContext;
123 use crate::mcp::server::GitPawMcpServer;
124
125 fn server_for(root: std::path::PathBuf) -> GitPawMcpServer {
126 GitPawMcpServer::new(RepoContext {
127 root,
128 git_paw_dir: None,
129 broker_url: None,
130 server_name: "git-paw".to_string(),
131 })
132 }
133
134 fn write_config(root: &std::path::Path, body: &str) {
135 let dir = root.join(".git-paw");
136 std::fs::create_dir_all(&dir).unwrap();
137 std::fs::write(dir.join("config.toml"), body).unwrap();
138 }
139
140 #[test]
141 fn get_readme_returns_content_when_configured() {
142 let tmp = tempfile::tempdir().unwrap();
143 std::fs::write(tmp.path().join("README.md"), "# Project").unwrap();
144 write_config(tmp.path(), "[governance]\nreadme = \"README.md\"\n");
145 let server = server_for(tmp.path().to_path_buf());
146 let resp = server.get_readme().unwrap();
147 assert_eq!(resp.0.content.as_deref(), Some("# Project"));
148 }
149
150 #[test]
151 fn get_readme_null_when_unconfigured() {
152 let tmp = tempfile::tempdir().unwrap();
153 let server = server_for(tmp.path().to_path_buf());
154 let resp = server.get_readme().unwrap();
155 assert!(resp.0.content.is_none());
156 }
157
158 #[test]
159 fn list_docs_empty_when_unconfigured() {
160 let tmp = tempfile::tempdir().unwrap();
161 let server = server_for(tmp.path().to_path_buf());
162 let resp = server.list_docs().unwrap();
163 assert!(resp.0.docs.is_empty());
164 }
165
166 #[test]
167 fn list_docs_enumerates_configured_dir() {
168 let tmp = tempfile::tempdir().unwrap();
169 let docs = tmp.path().join("docs/src");
170 std::fs::create_dir_all(docs.join("user-guide")).unwrap();
171 std::fs::write(docs.join("user-guide/mcp.md"), "# MCP").unwrap();
172 write_config(tmp.path(), "[governance]\ndocs = \"docs/src\"\n");
173 let server = server_for(tmp.path().to_path_buf());
174 let resp = server.list_docs().unwrap();
175 let paths: Vec<&str> = resp.0.docs.iter().map(|d| d.path.as_str()).collect();
176 assert_eq!(paths, vec!["user-guide/mcp.md"]);
177 }
178
179 #[test]
180 fn get_doc_happy_path() {
181 let tmp = tempfile::tempdir().unwrap();
182 let docs = tmp.path().join("docs/src");
183 std::fs::create_dir_all(&docs).unwrap();
184 std::fs::write(docs.join("intro.md"), "# Intro").unwrap();
185 write_config(tmp.path(), "[governance]\ndocs = \"docs/src\"\n");
186 let server = server_for(tmp.path().to_path_buf());
187 let resp = server
188 .get_doc(rmcp::handler::server::wrapper::Parameters(
189 super::GetDocParams {
190 path: "intro.md".to_string(),
191 },
192 ))
193 .unwrap();
194 assert_eq!(resp.0.content.as_deref(), Some("# Intro"));
195 assert!(resp.0.message.is_none());
196 }
197
198 #[test]
199 fn get_doc_traversal_refused_not_transport_error() {
200 let tmp = tempfile::tempdir().unwrap();
201 let docs = tmp.path().join("docs/src");
202 std::fs::create_dir_all(&docs).unwrap();
203 std::fs::write(tmp.path().join("secret.txt"), "TOPSECRET").unwrap();
204 write_config(tmp.path(), "[governance]\ndocs = \"docs/src\"\n");
205 let server = server_for(tmp.path().to_path_buf());
206 let resp = server
209 .get_doc(rmcp::handler::server::wrapper::Parameters(
210 super::GetDocParams {
211 path: "../../secret.txt".to_string(),
212 },
213 ))
214 .expect("traversal refusal is not a transport error");
215 assert!(resp.0.content.is_none());
216 assert!(resp.0.message.is_some());
217 }
218}