use std::path::Path;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::model::{ServerCapabilities, ServerInfo};
use rmcp::transport::stdio;
use rmcp::{ServerHandler, ServiceExt, tool_handler};
use crate::error::PawError;
use crate::mcp::{RepoContext, logging};
#[derive(Clone)]
pub struct GitPawMcpServer {
pub(crate) ctx: RepoContext,
tool_router: ToolRouter<Self>,
}
impl GitPawMcpServer {
#[must_use]
pub fn new(ctx: RepoContext) -> Self {
let tool_router = Self::coordination_router()
+ Self::governance_router()
+ Self::project_router()
+ Self::session_router()
+ Self::git_router()
+ Self::docs_router()
+ Self::source_router();
Self { ctx, tool_router }
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for GitPawMcpServer {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder().enable_tools().build();
info.server_info.name.clone_from(&self.ctx.server_name);
info.server_info.version = env!("CARGO_PKG_VERSION").to_string();
info.instructions = Some(
"Read-only git-paw repository state over MCP: coordination intents/conflicts, \
governance docs, specs and tasks, session status and learnings, agent skills, \
git context, and source browsing (list_files, read_file, search_code over the \
local working tree). Tools return empty/null results (not errors) when their data \
source is unavailable."
.to_string(),
);
info
}
}
fn validate_startup_config(ctx: &RepoContext) -> Result<(), PawError> {
let config = crate::config::load_config(&ctx.root, None)?;
if let Some(specs) = config.specs.as_ref()
&& let Some(spec_type) = specs.spec_type.as_deref()
{
const VALID: [&str; 3] = ["openspec", "markdown", "speckit"];
if !VALID.contains(&spec_type) {
return Err(PawError::McpError(format!(
"invalid [specs].type = \"{spec_type}\" in .git-paw/config.toml. \
Valid values: openspec, markdown, speckit."
)));
}
}
Ok(())
}
pub fn run(ctx: RepoContext, log_file: Option<&Path>) -> Result<(), PawError> {
logging::init(log_file)?;
validate_startup_config(&ctx)?;
logging::info(&format!("serving repository {}", ctx.root.display()));
if ctx.broker_url.is_none() {
logging::info("no active broker; coordination/session tools will return empty results");
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| PawError::McpError(format!("failed to build async runtime: {e}")))?;
runtime.block_on(async move {
let server = GitPawMcpServer::new(ctx);
let service = server
.serve(stdio())
.await
.map_err(|e| PawError::McpError(format!("failed to start MCP server: {e}")))?;
let reason = service
.waiting()
.await
.map_err(|e| PawError::McpError(format!("MCP server loop error: {e}")))?;
logging::info(&format!("MCP server stopped: {reason:?}"));
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> RepoContext {
ctx_named("git-paw")
}
fn ctx_named(name: &str) -> RepoContext {
RepoContext {
root: std::path::PathBuf::from("/tmp"),
git_paw_dir: None,
broker_url: None,
server_name: name.to_string(),
}
}
#[test]
fn server_advertises_tool_capability_and_instructions() {
let server = GitPawMcpServer::new(ctx());
let info = server.get_info();
assert!(
info.capabilities.tools.is_some(),
"tools capability advertised"
);
assert!(info.instructions.is_some());
}
#[test]
fn server_identity_defaults_to_git_paw_with_crate_version() {
let server = GitPawMcpServer::new(ctx());
let info = server.get_info();
assert_eq!(info.server_info.name, "git-paw");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn server_identity_uses_configured_name_keeping_crate_version() {
let server = GitPawMcpServer::new(ctx_named("my-project"));
let info = server.get_info();
assert_eq!(info.server_info.name, "my-project");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn server_identity_is_not_the_sdk_default() {
let server = GitPawMcpServer::new(ctx());
let info = server.get_info();
assert_ne!(info.server_info.name, "rmcp");
}
#[test]
fn new_merges_all_category_routers() {
let server = GitPawMcpServer::new(ctx());
let names: Vec<String> = server
.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for expected in [
"get_intents",
"get_conflicts",
"get_dod",
"get_constitution",
"get_specs",
"get_skill",
"get_session_status",
"get_learnings",
"get_branches",
"get_diff",
"get_readme",
"list_docs",
"get_doc",
"list_files",
"read_file",
"search_code",
] {
assert!(
names.iter().any(|n| n == expected),
"tool {expected} should be registered; have: {names:?}"
);
}
}
}