Skip to main content

git_paw/mcp/
server.rs

1//! stdio transport setup, tool-registry wiring, and lifecycle for the MCP
2//! server. This module only *wires* things together (design D2): it owns no
3//! tool logic (that lives in [`crate::mcp::tools`]) and no data reads (those
4//! live in [`crate::mcp::query`]).
5
6use 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/// The MCP server handler. Holds the resolved [`RepoContext`] (shared
17/// read-only by every tool) and the merged tool router.
18#[derive(Clone)]
19pub struct GitPawMcpServer {
20    /// Resolved repository context.
21    pub(crate) ctx: RepoContext,
22    /// Combined router across all five tool categories.
23    tool_router: ToolRouter<Self>,
24}
25
26impl GitPawMcpServer {
27    /// Builds the server, merging the per-category tool routers (each defined
28    /// in its own file under `tools/`).
29    #[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        // ServerInfo (InitializeResult) is #[non_exhaustive]; build from Default
46        // and override the fields we care about.
47        let mut info = ServerInfo::default();
48        info.capabilities = ServerCapabilities::builder().enable_tools().build();
49        // server_info (an Implementation) defaults to the rmcp SDK's own
50        // identity ("rmcp" / the rmcp crate version). Override it so the
51        // handshake advertises git-paw and its real crate version, honouring
52        // any configured [mcp].name resolved onto the RepoContext.
53        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
67/// Validates configuration that must be correct for the server to operate.
68///
69/// A configured `[specs].type` outside the supported set is a hard error per
70/// the spec — the server exits non-zero with a clear stderr message rather
71/// than silently mis-serving.
72fn 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
88/// Runs the stdio MCP server until the client closes stdin.
89///
90/// Initialises stderr logging, validates startup config, then drives the
91/// rmcp service loop on a Tokio runtime. Returns `Ok(())` (exit 0) on a clean
92/// stdin EOF.
93pub 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    // mcp-server "Server identity" — Scenario: Default identity is git-paw.
151    #[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    // mcp-server "Server identity" — Scenario: Configured name overrides the
160    // advertised identity (version stays the crate version).
161    #[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    // The SDK default identity ("rmcp") must never leak through.
170    #[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        // Spot-check one tool from each category is registered.
187        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}