1use std::path::Path;
7
8use rmcp::handler::server::router::tool::ToolRouter;
9use rmcp::model::{ServerCapabilities, ServerInfo};
10use rmcp::transport::stdio;
11use rmcp::{ServerHandler, ServiceExt, tool_handler};
12
13use crate::error::PawError;
14use crate::mcp::{RepoContext, logging};
15
16#[derive(Clone)]
19pub struct GitPawMcpServer {
20 pub(crate) ctx: RepoContext,
22 tool_router: ToolRouter<Self>,
24}
25
26impl GitPawMcpServer {
27 #[must_use]
30 pub fn new(ctx: RepoContext) -> Self {
31 let tool_router = Self::coordination_router()
32 + Self::governance_router()
33 + Self::project_router()
34 + Self::session_router()
35 + Self::git_router()
36 + Self::docs_router()
37 + Self::source_router();
38 Self { ctx, tool_router }
39 }
40}
41
42#[tool_handler(router = self.tool_router)]
43impl ServerHandler for GitPawMcpServer {
44 fn get_info(&self) -> ServerInfo {
45 let mut info = ServerInfo::default();
48 info.capabilities = ServerCapabilities::builder().enable_tools().build();
49 info.server_info.name.clone_from(&self.ctx.server_name);
54 info.server_info.version = env!("CARGO_PKG_VERSION").to_string();
55 info.instructions = Some(
56 "Read-only git-paw repository state over MCP: coordination intents/conflicts, \
57 governance docs, specs and tasks, session status and learnings, agent skills, \
58 git context, and source browsing (list_files, read_file, search_code over the \
59 local working tree). Tools return empty/null results (not errors) when their data \
60 source is unavailable."
61 .to_string(),
62 );
63 info
64 }
65}
66
67fn validate_startup_config(ctx: &RepoContext) -> Result<(), PawError> {
73 let config = crate::config::load_config(&ctx.root, None)?;
74 if let Some(specs) = config.specs.as_ref()
75 && let Some(spec_type) = specs.spec_type.as_deref()
76 {
77 const VALID: [&str; 3] = ["openspec", "markdown", "speckit"];
78 if !VALID.contains(&spec_type) {
79 return Err(PawError::McpError(format!(
80 "invalid [specs].type = \"{spec_type}\" in .git-paw/config.toml. \
81 Valid values: openspec, markdown, speckit."
82 )));
83 }
84 }
85 Ok(())
86}
87
88pub fn run(ctx: RepoContext, log_file: Option<&Path>) -> Result<(), PawError> {
94 logging::init(log_file)?;
95 validate_startup_config(&ctx)?;
96
97 logging::info(&format!("serving repository {}", ctx.root.display()));
98 if ctx.broker_url.is_none() {
99 logging::info("no active broker; coordination/session tools will return empty results");
100 }
101
102 let runtime = tokio::runtime::Builder::new_multi_thread()
103 .enable_all()
104 .build()
105 .map_err(|e| PawError::McpError(format!("failed to build async runtime: {e}")))?;
106
107 runtime.block_on(async move {
108 let server = GitPawMcpServer::new(ctx);
109 let service = server
110 .serve(stdio())
111 .await
112 .map_err(|e| PawError::McpError(format!("failed to start MCP server: {e}")))?;
113 let reason = service
114 .waiting()
115 .await
116 .map_err(|e| PawError::McpError(format!("MCP server loop error: {e}")))?;
117 logging::info(&format!("MCP server stopped: {reason:?}"));
118 Ok(())
119 })
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn ctx() -> RepoContext {
127 ctx_named("git-paw")
128 }
129
130 fn ctx_named(name: &str) -> RepoContext {
131 RepoContext {
132 root: std::path::PathBuf::from("/tmp"),
133 git_paw_dir: None,
134 broker_url: None,
135 server_name: name.to_string(),
136 }
137 }
138
139 #[test]
140 fn server_advertises_tool_capability_and_instructions() {
141 let server = GitPawMcpServer::new(ctx());
142 let info = server.get_info();
143 assert!(
144 info.capabilities.tools.is_some(),
145 "tools capability advertised"
146 );
147 assert!(info.instructions.is_some());
148 }
149
150 #[test]
152 fn server_identity_defaults_to_git_paw_with_crate_version() {
153 let server = GitPawMcpServer::new(ctx());
154 let info = server.get_info();
155 assert_eq!(info.server_info.name, "git-paw");
156 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
157 }
158
159 #[test]
162 fn server_identity_uses_configured_name_keeping_crate_version() {
163 let server = GitPawMcpServer::new(ctx_named("my-project"));
164 let info = server.get_info();
165 assert_eq!(info.server_info.name, "my-project");
166 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
167 }
168
169 #[test]
171 fn server_identity_is_not_the_sdk_default() {
172 let server = GitPawMcpServer::new(ctx());
173 let info = server.get_info();
174 assert_ne!(info.server_info.name, "rmcp");
175 }
176
177 #[test]
178 fn new_merges_all_category_routers() {
179 let server = GitPawMcpServer::new(ctx());
180 let names: Vec<String> = server
181 .tool_router
182 .list_all()
183 .into_iter()
184 .map(|t| t.name.to_string())
185 .collect();
186 for expected in [
188 "get_intents",
189 "get_conflicts",
190 "get_dod",
191 "get_constitution",
192 "get_specs",
193 "get_skill",
194 "get_session_status",
195 "get_learnings",
196 "get_branches",
197 "get_diff",
198 "get_readme",
199 "list_docs",
200 "get_doc",
201 "list_files",
202 "read_file",
203 "search_code",
204 ] {
205 assert!(
206 names.iter().any(|n| n == expected),
207 "tool {expected} should be registered; have: {names:?}"
208 );
209 }
210 }
211}