use std::path::PathBuf;
use anyhow::Context;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::service::ServerInitializeError;
use rmcp::transport::stdio;
use rmcp::{schemars, tool, tool_handler, tool_router, Json, ServerHandler, ServiceExt};
use serde::{Deserialize, Serialize};
use crate::analyze::{self, AnalyzeEvent, AnalyzeRequest};
use crate::exit;
use crate::ext::{self, ExtensionRecord};
use crate::index::{self, IndexRequest, IndexSummary};
pub fn run() -> anyhow::Result<u8> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.context("could not start the MCP server runtime")?;
let served = runtime.block_on(async {
let service = match Server.serve(stdio()).await {
Ok(service) => service,
Err(ServerInitializeError::ConnectionClosed(_)) => return Ok(()),
Err(err) => {
return Err(err).context("MCP session failed to initialize on stdio");
}
};
service.waiting().await.context("MCP server task failed")?;
anyhow::Ok(())
});
runtime.shutdown_background();
served?;
Ok(exit::SUCCESS)
}
#[derive(Clone, Copy, Debug)]
struct Server;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
struct AnalyzeParams {
paths: Vec<String>,
lang: Option<String>,
analyzer: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
struct AnalyzeOutput {
records: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
struct IndexParams {
path: String,
output: Option<String>,
lang: Option<Vec<String>>,
}
fn json_schema_formats_only(schema: &mut schemars::Schema) {
use schemars::transform::{RecursiveTransform, Transform};
RecursiveTransform(|schema: &mut schemars::Schema| {
let rust_specific = schema
.get("format")
.and_then(|format| format.as_str())
.is_some_and(|format| format.starts_with("uint"));
if rust_specific {
schema.remove("format");
}
})
.transform(schema);
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
#[schemars(crate = "rmcp::schemars", transform = json_schema_formats_only)]
struct IndexOutput {
path: String,
bytes: usize,
files: usize,
symbols: usize,
references: usize,
same_file: usize,
cross_file: usize,
ambiguous: usize,
unresolved: usize,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
struct ExtListOutput {
extensions: Vec<ExtensionRecord>,
}
#[tool_router]
impl Server {
#[tool(
name = "qli_analyze",
description = "Analyze source files with qli and return the findings as structured \
records. Use it to find TODO/FIXME/XXX/HACK comment markers (analyzer `todofixme`, \
the default) or to list the definitions and references in Python, TypeScript/TSX, \
C#, and Angular template files (analyzer `defrefs`) without reading the files \
yourself. Accepts files and directories. Read-only apart from qli's own analysis \
cache.",
annotations(
title = "Analyze source files",
read_only_hint = true,
open_world_hint = false
)
)]
async fn analyze(
Parameters(params): Parameters<AnalyzeParams>,
) -> Result<Json<AnalyzeOutput>, String> {
blocking(move || {
let paths: Vec<PathBuf> = params.paths.iter().map(PathBuf::from).collect();
let request = AnalyzeRequest {
paths: &paths,
lang: params.lang.as_deref().unwrap_or(analyze::DEFAULT_LANG),
analyzer: params
.analyzer
.as_deref()
.unwrap_or(analyze::DEFAULT_ANALYZER),
no_cache: false,
};
let mut records = Vec::new();
let mut missing = Vec::new();
analyze::analyze(&request, |event| match event {
AnalyzeEvent::Analyzed { file, result } => {
let file_label = file.display().to_string();
records.extend(qli_outputs::jsonl::records(&file_label, result));
}
AnalyzeEvent::MissingPath(path) => {
missing.push(format!("path does not exist: {}", path.display()));
}
})?;
anyhow::ensure!(missing.is_empty(), "{}", missing.join("\n"));
Ok(AnalyzeOutput { records })
})
.await
}
#[tool(
name = "qli_index",
description = "Index a whole project with qli into one SCIP index file: every \
definition and reference in its Python, TypeScript/TSX, C#, and Angular template \
files, with references resolved across files. Use it when the user asks for a SCIP \
index or a code-navigation index of a project. Writes the index file (overwriting \
an existing one) and returns its path, size, and symbol and reference counts; the \
index itself is binary and is not returned.",
annotations(
title = "Index a project to SCIP",
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn index(
Parameters(params): Parameters<IndexParams>,
) -> Result<Json<IndexOutput>, String> {
blocking(move || {
let root = PathBuf::from(params.path);
let output = PathBuf::from(params.output.as_deref().unwrap_or(index::DEFAULT_OUTPUT));
let languages = params.lang.unwrap_or_default();
let mut tool_arguments = vec![
"index".to_owned(),
root.display().to_string(),
"--output".to_owned(),
output.display().to_string(),
];
if !languages.is_empty() {
tool_arguments.extend(["--lang".to_owned(), languages.join(",")]);
}
let IndexSummary { bytes, stats } = index::index(IndexRequest {
root: &root,
output: &output,
languages: &languages,
tool_arguments,
})?;
Ok(IndexOutput {
path: std::path::absolute(&output)
.unwrap_or(output)
.display()
.to_string(),
bytes,
files: stats.documents,
symbols: stats.symbols,
references: stats.references,
same_file: stats.same_file,
cross_file: stats.cross_file,
ambiguous: stats.ambiguous,
unresolved: stats.unresolved,
})
})
.await
}
#[tool(
name = "qli_ext_list",
description = "List the qli extensions installed on this machine: user-defined scripts \
grouped under `dev`, `prod`, `org`, and so on, each with its group, name, origin, \
and resolved path. Use it when the user asks which qli extensions they have or \
where one lives. It only lists them; it never runs one.",
annotations(
title = "List qli extensions",
read_only_hint = true,
open_world_hint = false
)
)]
async fn ext_list() -> Result<Json<ExtListOutput>, String> {
blocking(|| {
let discovery = ext::discover();
for warning in &discovery.warnings {
tracing::warn!("{warning}");
}
Ok(ExtListOutput {
extensions: ext::records(&discovery),
})
})
.await
}
}
#[tool_handler(
name = "qli",
instructions = "qli is a polyglot code-analysis CLI. `qli_analyze` reports TODO/FIXME \
markers or definitions and references in source files; `qli_index` writes a SCIP index \
of a whole project; `qli_ext_list` lists the user's qli extensions. Pass absolute \
paths: a relative path resolves against the server's working directory."
)]
impl ServerHandler for Server {}
async fn blocking<T, F>(call: F) -> Result<Json<T>, String>
where
T: Send + 'static,
F: FnOnce() -> anyhow::Result<T> + Send + 'static,
{
match tokio::task::spawn_blocking(call).await {
Ok(Ok(value)) => Ok(Json(value)),
Ok(Err(err)) => Err(format!("{err:#}")),
Err(err) => Err(format!("internal error: {err}")),
}
}