use std::{env, io::stdout};
use libruskel::{Ruskel, SearchDomain, SearchOptions, describe_domains, parse_domain_tokens};
use serde::{Deserialize, Serialize};
use tmcp::{Result, Server, ServerCtx, mcp_server, schema::CallToolResult, tool};
use tokio::signal::ctrl_c;
use tracing::error;
use tracing_subscriber::filter::LevelFilter;
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
pub struct RuskelSkeletonTool {
pub target: String,
#[serde(default)]
pub private: bool,
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub bin: Option<String>,
#[serde(default)]
pub search_spec: Option<Vec<String>>,
#[serde(default = "default_frontmatter_enabled")]
pub frontmatter: bool,
#[serde(default)]
pub search_case_sensitive: bool,
#[serde(default)]
pub direct_match_only: bool,
#[serde(default)]
pub no_default_features: bool,
#[serde(default)]
pub all_features: bool,
#[serde(default)]
pub features: Vec<String>,
}
#[derive(Clone)]
pub struct RuskelServer {
ruskel: Ruskel,
}
#[mcp_server]
impl RuskelServer {
pub fn new(ruskel: Ruskel) -> Self {
Self { ruskel }
}
#[tool]
async fn ruskel(&self, _ctx: &ServerCtx, params: RuskelSkeletonTool) -> Result<CallToolResult> {
if env::var_os("RUSKEL_MCP_TEST_MODE").is_some() {
return Ok(run_test_mode(params));
}
let ruskel = self
.ruskel
.clone()
.with_frontmatter(params.frontmatter)
.with_bin_target(params.bin.clone());
if let Some(query) = params
.search
.as_ref()
.map(|q| q.trim())
.filter(|q| !q.is_empty())
{
return Ok(self.run_search_mode(&ruskel, ¶ms, query));
}
Ok(self.run_render_mode(&ruskel, params))
}
fn run_search_mode(
&self,
ruskel: &Ruskel,
params: &RuskelSkeletonTool,
query: &str,
) -> CallToolResult {
let mut options = SearchOptions::new(query);
options.include_private = params.private;
options.case_sensitive = params.search_case_sensitive;
let domains = match params.search_spec.as_ref() {
Some(spec) if !spec.is_empty() => parse_domain_tokens(spec.iter().map(|s| s.as_str())),
_ => SearchDomain::default(),
};
options.domains = domains;
options.expand_containers = !params.direct_match_only;
match ruskel.search(
¶ms.target,
params.no_default_features,
params.all_features,
params.features.clone(),
&options,
) {
Ok(response) => {
if response.results.is_empty() {
return CallToolResult::new()
.with_text_content(format!("No matches found for \"{}\".", query));
}
let mut summary = String::new();
summary.push_str(&format!(
"Found {} matches for \"{}\":\n",
response.results.len(),
query
));
for result in &response.results {
let labels = describe_domains(result.matched);
if labels.is_empty() {
summary.push_str(&format!(" - {}\n", result.path_string));
} else {
summary.push_str(&format!(
" - {} [{}]\n",
result.path_string,
labels.join(", ")
));
}
}
summary.push('\n');
summary.push_str(&response.rendered);
CallToolResult::new().with_text_content(summary)
}
Err(e) => {
error!("Failed to generate search results: {}", e);
CallToolResult::new()
.with_text_content(format!(
"Failed to search '{}' with query '{}': {}",
params.target, query, e
))
.mark_as_error()
}
}
}
fn run_render_mode(&self, ruskel: &Ruskel, params: RuskelSkeletonTool) -> CallToolResult {
match ruskel.render(
¶ms.target,
params.no_default_features,
params.all_features,
params.features,
params.private,
) {
Ok(output) => CallToolResult::new().with_text_content(output),
Err(e) => {
error!("Failed to generate skeleton: {}", e);
CallToolResult::new()
.with_text_content(format!(
"Failed to generate skeleton for '{}': {}",
params.target, e
))
.mark_as_error()
}
}
}
}
fn run_test_mode(params: RuskelSkeletonTool) -> CallToolResult {
let mut summary = String::new();
summary.push_str("ruskel test-mode output\n");
summary.push_str(&format!("target: {}\n", params.target));
summary.push_str(&format!("private: {}\n", params.private));
if let Some(search) = params.search {
summary.push_str(&format!("search: {}\n", search));
}
if let Some(spec) = params.search_spec
&& !spec.is_empty()
{
summary.push_str(&format!("search_spec: {}\n", spec.join(",")));
}
CallToolResult::new().with_text_content(summary)
}
const fn default_frontmatter_enabled() -> bool {
true
}
pub async fn run_mcp_server(
ruskel: Ruskel,
addr: Option<String>,
log_level: Option<LevelFilter>,
) -> Result<()> {
if addr.is_some() {
let level = log_level.unwrap_or(LevelFilter::INFO);
let filter = format!("ruskel_mcp={level},tmcp={level}");
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(filter))
.with_writer(stdout)
.without_time()
.init();
}
let server = Server::new(move || RuskelServer::new(ruskel.clone()));
match addr {
Some(addr) => {
tracing::info!("Starting MCP server on {}", addr);
let handle = server.serve_tcp(addr).await?;
ctrl_c().await?;
handle.stop().await
}
None => server.serve_stdio().await,
}
}