Skip to main content

git_paw/mcp/tools/
docs.rs

1//! Documentation tools: `get_readme`, `list_docs`, `get_doc`.
2//!
3//! These serve the repository's own documentation via the bring-your-own
4//! `[governance].readme` and `[governance].docs` configuration. Unset paths
5//! degrade to null / empty results (never a transport error). `get_doc` is
6//! confined to the configured documentation directory: a path that escapes it
7//! is refused with a null body and a `message`, not a file read outside the
8//! directory.
9
10use 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/// 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::get_doc`].
27#[derive(Debug, Deserialize, schemars::JsonSchema)]
28pub struct GetDocParams {
29    /// Document path relative to the configured `[governance].docs` directory
30    /// (e.g. `user-guide/mcp.md`). Paths escaping the directory are refused.
31    pub path: String,
32}
33
34/// Response for `get_readme`.
35#[derive(Serialize, schemars::JsonSchema)]
36pub struct ReadmeResponse {
37    /// README content, or null when `[governance].readme` is unset or the
38    /// file is absent.
39    pub content: Option<String>,
40}
41
42/// Response for `list_docs`.
43#[derive(Serialize, schemars::JsonSchema)]
44pub struct DocsListResponse {
45    /// Documents under the configured docs directory, relative to it. Empty
46    /// when `[governance].docs` is unset or the directory is absent.
47    pub docs: Vec<query::docs::DocEntry>,
48}
49
50/// Response for `get_doc`.
51#[derive(Serialize, schemars::JsonSchema)]
52pub struct DocResponse {
53    /// Document content, or null when unset, not found, or refused.
54    pub content: Option<String>,
55    /// Human-readable note when `content` is null (e.g. a refused traversal or
56    /// an absent document). Absent when content is present.
57    #[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    /// `get_readme` — the configured repository README.
70    #[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    /// `list_docs` — Markdown documents under the configured docs directory.
81    #[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    /// `get_doc` — one document confined to the configured docs directory.
94    #[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        // A refused traversal is a successful response with null content + a
207        // message, not a transport-level error.
208        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}