use rmcp::{
ServerHandler, ServiceExt,
handler::server::tool::ToolRouter,
model::{ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::io::stdio,
};
use sephera_core::core::{
code_loc::{CodeLoc, IgnoreMatcher},
compression::CompressionMode,
context::ContextBuilder,
};
#[derive(Clone)]
pub struct SepheraServer {
tool_router: ToolRouter<Self>,
}
impl SepheraServer {
#[must_use]
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
}
impl Default for SepheraServer {
fn default() -> Self {
Self::new()
}
}
#[tool_router]
impl SepheraServer {
#[tool(
name = "loc",
description = "Count lines of code, comment lines, and empty lines for supported languages in a directory tree. Returns per-language metrics and aggregate totals."
)]
fn loc(
&self,
rmcp::handler::server::wrapper::Parameters(param): rmcp::handler::server::wrapper::Parameters<LocInput>,
) -> Result<String, rmcp::ErrorData> {
let ignore_patterns = param.ignore.unwrap_or_default();
let ignore_matcher = IgnoreMatcher::from_patterns(&ignore_patterns)
.map_err(|e| {
rmcp::ErrorData::internal_error(
format!("invalid ignore pattern: {e}"),
None,
)
})?;
let report = CodeLoc::new(¶m.path, ignore_matcher)
.analyze()
.map_err(|e| {
rmcp::ErrorData::internal_error(
format!("analysis failed: {e}"),
None,
)
})?;
let mut output = String::new();
output.push_str(&format!(
"Files scanned: {}\nLanguages detected: {}\n\n",
report.files_scanned, report.languages_detected
));
for lang in &report.by_language {
output.push_str(&format!(
"{}: {} code, {} comment, {} empty ({} bytes)\n",
lang.language,
lang.metrics.code_lines,
lang.metrics.comment_lines,
lang.metrics.empty_lines,
lang.metrics.size_bytes,
));
}
output.push_str(&format!(
"\nTotal: {} code, {} comment, {} empty ({} bytes)\n",
report.totals.code_lines,
report.totals.comment_lines,
report.totals.empty_lines,
report.totals.size_bytes,
));
Ok(output)
}
#[tool(
name = "context",
description = "Build an LLM-ready context pack for a repository or focused sub-paths. Supports token budgets, focus paths, and compression modes. Returns structured JSON."
)]
fn context(
&self,
rmcp::handler::server::wrapper::Parameters(param): rmcp::handler::server::wrapper::Parameters<ContextInput>,
) -> Result<String, rmcp::ErrorData> {
let ignore_patterns = param.ignore.unwrap_or_default();
let ignore_matcher = IgnoreMatcher::from_patterns(&ignore_patterns)
.map_err(|e| {
rmcp::ErrorData::internal_error(
format!("invalid ignore pattern: {e}"),
None,
)
})?;
let focus_paths: Vec<std::path::PathBuf> = param
.focus
.unwrap_or_default()
.into_iter()
.map(std::path::PathBuf::from)
.collect();
let budget_tokens = param.budget.unwrap_or(128_000);
let compression_mode = match param.compress.as_deref() {
Some("signatures") => CompressionMode::Signatures,
Some("skeleton") => CompressionMode::Skeleton,
Some("none") | None => CompressionMode::None,
Some(other) => {
return Err(rmcp::ErrorData::invalid_params(
format!(
"invalid compression mode '{other}'; expected 'none', 'signatures', or 'skeleton'"
),
None,
));
}
};
let builder = ContextBuilder::new(
¶m.path,
ignore_matcher,
focus_paths,
budget_tokens,
)
.with_compression(compression_mode);
let report = builder.build().map_err(|e| {
rmcp::ErrorData::internal_error(
format!("context build failed: {e}"),
None,
)
})?;
serde_json::to_string_pretty(&report).map_err(|e| {
rmcp::ErrorData::internal_error(
format!("JSON serialization failed: {e}"),
None,
)
})
}
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct LocInput {
path: String,
ignore: Option<Vec<String>>,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct ContextInput {
path: String,
focus: Option<Vec<String>>,
ignore: Option<Vec<String>>,
budget: Option<u64>,
compress: Option<String>,
}
#[tool_handler]
impl ServerHandler for SepheraServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
))
}
}
pub async fn run_mcp_server() -> anyhow::Result<()> {
let server = SepheraServer::new();
let transport = stdio();
let service = server.serve(transport).await?;
service.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_info_returns_expected_metadata() {
let server = SepheraServer::new();
let info = server.get_info();
assert_eq!(info.server_info.name, env!("CARGO_PKG_NAME"));
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn loc_tool_valid_directory() {
let server = SepheraServer::new();
let current_dir = env!("CARGO_MANIFEST_DIR");
let param = rmcp::handler::server::wrapper::Parameters(LocInput {
path: current_dir.to_string(),
ignore: None,
});
let result = server.loc(param);
assert!(result.is_ok(), "loc tool should succeed for manifest dir");
let output = result.unwrap();
assert!(output.contains("Files scanned:"));
assert!(output.contains("Languages detected:"));
}
#[test]
fn loc_tool_invalid_directory() {
let server = SepheraServer::new();
let param = rmcp::handler::server::wrapper::Parameters(LocInput {
path: "/path/to/nonexistent/dir/for/test/sephera".to_string(),
ignore: None,
});
let result = server.loc(param);
assert!(result.is_err(), "loc tool should fail for nonexistent dir");
}
#[test]
fn context_tool_valid_directory() {
let server = SepheraServer::new();
let current_dir = env!("CARGO_MANIFEST_DIR");
let param = rmcp::handler::server::wrapper::Parameters(ContextInput {
path: current_dir.to_string(),
focus: None,
ignore: None,
budget: Some(1000),
compress: Some("signatures".to_string()),
});
let result = server.context(param);
assert!(
result.is_ok(),
"context tool should succeed for manifest dir"
);
let output = result.unwrap();
assert!(output.contains("\"files_considered\""));
assert!(output.contains("\"budget_tokens\""));
}
}