pub mod sse;
pub mod stdio;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub mod error_codes {
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Request {
pub jsonrpc: String,
pub id: Option<Value>,
pub method: String,
#[serde(default)]
pub params: Value,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Response {
pub jsonrpc: String,
pub id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
#[serde(skip)]
pub suppress: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl Response {
pub fn ok(id: Value, result: Value) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: Some(result),
error: None,
suppress: false,
}
}
pub fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError {
code,
message: message.into(),
data: None,
}),
suppress: false,
}
}
pub fn suppressed() -> Self {
Self {
jsonrpc: "2.0".into(),
id: Value::Null,
result: None,
error: None,
suppress: true,
}
}
}
#[derive(Clone)]
pub struct AnalyzerMcpServer {
base_url: String,
http: reqwest::Client,
}
impl AnalyzerMcpServer {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
http: reqwest::Client::new(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn dispatch(&self, req: Request) -> Response {
let is_notification = req.id.is_none();
let id = req.id.clone().unwrap_or(Value::Null);
if req.jsonrpc != "2.0" {
if is_notification {
return Response::suppressed();
}
return Response::err(id, error_codes::INVALID_REQUEST, "jsonrpc must be \"2.0\"");
}
match req.method.as_str() {
"initialize" => {
return Response::ok(
id,
serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {}, "resources": {} },
"serverInfo": {
"name": "trusty-analyzer",
"version": env!("CARGO_PKG_VERSION"),
}
}),
);
}
"notifications/initialized" | "initialized" => {
return Response::suppressed();
}
"resources/list" => {
return self.list_resources(id).await;
}
_ => {}
}
let (tool, arguments, via_tools_call) = match req.method.as_str() {
"tools/call" => {
let name = req
.params
.get("name")
.and_then(Value::as_str)
.map(str::to_owned);
let args = req
.params
.get("arguments")
.cloned()
.unwrap_or(Value::Object(Default::default()));
match name {
Some(n) => (n, args, true),
None => {
return Response::err(
id,
error_codes::INVALID_PARAMS,
"tools/call requires a 'name' field",
)
}
}
}
"tools/list" => {
return Response::ok(id, serde_json::json!({ "tools": tool_descriptors() }));
}
other => (other.to_string(), req.params.clone(), false),
};
let outcome = self.call_tool(&tool, &arguments).await;
if via_tools_call {
match outcome {
Ok(value) => Response::ok(id, wrap_tool_result(&value)),
Err(DispatchError::UnknownTool) => Response::err(
id,
error_codes::METHOD_NOT_FOUND,
format!("unknown tool: {tool}"),
),
Err(DispatchError::InvalidParams(msg)) => Response::ok(id, wrap_tool_error(&msg)),
Err(DispatchError::Transport(msg)) => Response::ok(id, wrap_tool_error(&msg)),
}
} else {
match outcome {
Ok(value) => Response::ok(id, wrap_text_content(&value)),
Err(DispatchError::UnknownTool) => Response::err(
id,
error_codes::METHOD_NOT_FOUND,
format!("unknown tool: {tool}"),
),
Err(DispatchError::InvalidParams(msg)) => {
Response::err(id, error_codes::INVALID_PARAMS, msg)
}
Err(DispatchError::Transport(msg)) => {
Response::err(id, error_codes::INTERNAL_ERROR, msg)
}
}
}
}
async fn list_resources(&self, id: Value) -> Response {
let resources = match self.get("/indexes").await {
Ok(value) => {
let ids: Vec<String> = value
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.get("id").and_then(Value::as_str).map(str::to_owned))
.collect()
})
.unwrap_or_default();
ids.into_iter()
.map(|index_id| {
serde_json::json!({
"uri": format!("trusty-analyzer://indexes/{index_id}"),
"name": format!("Index: {index_id}"),
"description": "trusty-search index available for analysis",
"mimeType": "application/json",
})
})
.collect::<Vec<_>>()
}
Err(e) => {
tracing::warn!("resources/list: GET /indexes failed: {e:?}");
Vec::new()
}
};
Response::ok(id, serde_json::json!({ "resources": resources }))
}
async fn call_tool(&self, tool: &str, args: &Value) -> Result<Value, DispatchError> {
match tool {
"complexity_hotspots" => self.handle_complexity_hotspots(args).await,
"find_smells" => self.handle_find_smells(args).await,
"analyze_quality" => self.handle_analyze_quality(args).await,
"run_diagnostics" => self.handle_run_diagnostics(args).await,
"list_facts" => self.handle_list_facts(args).await,
"upsert_fact" => self.handle_upsert_fact(args).await,
"delete_fact" => self.handle_delete_fact(args).await,
"extract_graph" => self.handle_extract_graph(args).await,
"list_entities" => self.handle_list_entities(args).await,
"cluster_concepts" => self.handle_cluster_concepts(args).await,
"analyzer_health" => self.handle_analyzer_health(args).await,
"ingest_scip" => self.handle_ingest_scip(args).await,
"extract_ner" => self.handle_extract_ner(args).await,
"suggest_refactors" => self.handle_suggest_refactors(args).await,
"review_diff" => self.handle_review_diff(args).await,
"review_github_pr" => self.handle_review_github_pr(args).await,
"deep_analysis" => self.handle_deep_analysis(args).await,
_ => Err(DispatchError::UnknownTool),
}
}
async fn handle_complexity_hotspots(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
let top_n = args.get("top_n").and_then(Value::as_u64).unwrap_or(20);
self.get(&format!(
"/indexes/{index_id}/complexity_hotspots?top_n={top_n}"
))
.await
}
async fn handle_find_smells(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
self.get(&format!("/indexes/{index_id}/smells")).await
}
async fn handle_analyze_quality(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
self.get(&format!("/indexes/{index_id}/quality")).await
}
async fn handle_run_diagnostics(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
let q = build_query(args, &["language", "tools"]);
self.get(&format!("/indexes/{index_id}/diagnostics{q}"))
.await
}
async fn handle_list_facts(&self, args: &Value) -> Result<Value, DispatchError> {
let q = build_query(args, &["subject", "predicate", "object"]);
self.get(&format!("/facts{q}")).await
}
async fn handle_upsert_fact(&self, args: &Value) -> Result<Value, DispatchError> {
let subject = require_str(args, "subject")?;
let predicate = require_str(args, "predicate")?;
let object = require_str(args, "object")?;
let index_id = require_str(args, "index_id")?;
let confidence = args
.get("confidence")
.and_then(Value::as_f64)
.unwrap_or(1.0);
let provenance = args
.get("provenance")
.cloned()
.unwrap_or_else(|| Value::Array(vec![]));
let body = serde_json::json!({
"subject": subject,
"predicate": predicate,
"object": object,
"index_id": index_id,
"confidence": confidence,
"provenance": provenance,
});
self.post("/facts", &body).await
}
async fn handle_delete_fact(&self, args: &Value) -> Result<Value, DispatchError> {
let id = args
.get("id")
.and_then(Value::as_u64)
.ok_or_else(|| DispatchError::InvalidParams("missing 'id' (u64)".into()))?;
self.delete(&format!("/facts/{id}")).await
}
async fn handle_extract_graph(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
let mut path = format!("/indexes/{index_id}/graph");
if let Some(lang) = args.get("language").and_then(Value::as_str) {
path.push_str(&format!("?language={}", urlencode(lang)));
}
self.get(&path).await
}
async fn handle_list_entities(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
let q = build_query(args, &["kind", "language"]);
self.get(&format!("/indexes/{index_id}/entities{q}")).await
}
async fn handle_cluster_concepts(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
let k = args.get("k").and_then(Value::as_u64).unwrap_or(8);
let path = match args.get("method").and_then(Value::as_str) {
Some(m) => format!("/indexes/{index_id}/clusters?k={k}&method={m}"),
None => format!("/indexes/{index_id}/clusters?k={k}"),
};
self.get(&path).await
}
async fn handle_analyzer_health(&self, _args: &Value) -> Result<Value, DispatchError> {
self.get("/health").await
}
async fn handle_suggest_refactors(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
let top_k = args.get("top_k").and_then(Value::as_u64).unwrap_or(20);
let mut path = format!("/indexes/{index_id}/refactor-suggestions?top_k={top_k}");
if let Some(file) = args.get("file").and_then(Value::as_str) {
path.push_str(&format!("&file={}", urlencode(file)));
}
if let Some(sev) = args.get("min_severity").and_then(Value::as_str) {
path.push_str(&format!("&min_severity={}", urlencode(sev)));
}
self.get(&path).await
}
async fn handle_extract_ner(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = index_id_or_default(args);
let top_k = args.get("top_k").and_then(Value::as_u64).unwrap_or(50);
self.get(&format!("/indexes/{index_id}/ner?top_k={top_k}"))
.await
}
async fn handle_ingest_scip(&self, args: &Value) -> Result<Value, DispatchError> {
use base64::Engine;
let index_id = index_id_or_default(args);
let b64 = require_str(args, "scip_base64")?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| {
DispatchError::InvalidParams(format!("scip_base64 is not valid base64: {e}"))
})?;
self.post_bytes(&format!("/indexes/{index_id}/scip"), bytes)
.await
}
async fn handle_review_diff(&self, args: &Value) -> Result<Value, DispatchError> {
let diff = require_str(args, "diff")?;
let index_id = require_str(args, "index_id")?;
let path = format!("/review?index_id={}", urlencode(index_id));
self.post_text(&path, diff).await
}
async fn handle_deep_analysis(&self, args: &Value) -> Result<Value, DispatchError> {
let index_id = require_str(args, "index_id")?;
let mut body = serde_json::json!({ "index_id": index_id });
if let Some(model) = args.get("model").and_then(Value::as_str) {
body["model"] = Value::from(model);
}
self.post("/analyze/deep", &body).await
}
async fn handle_review_github_pr(&self, args: &Value) -> Result<Value, DispatchError> {
let owner = require_str(args, "owner")?;
let repo = require_str(args, "repo")?;
let pr = args
.get("pr")
.and_then(Value::as_u64)
.ok_or_else(|| DispatchError::InvalidParams("missing or non-integer 'pr'".into()))?;
let index_id = require_str(args, "index_id")?;
let post_comment = args
.get("post_comment")
.and_then(Value::as_bool)
.unwrap_or(false);
let body = serde_json::json!({
"owner": owner,
"repo": repo,
"pr": pr,
"index_id": index_id,
"post_comment": post_comment,
});
self.post("/review/github-pr", &body).await
}
async fn post_text(&self, path: &str, body: &str) -> Result<Value, DispatchError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.post(&url)
.header("content-type", "text/x-patch")
.body(body.to_string())
.send()
.await
.map_err(|e| DispatchError::Transport(format!("POST {url}: {e}")))?;
let status = resp.status();
let body: Value = resp
.json()
.await
.map_err(|e| DispatchError::Transport(format!("decode {url}: {e}")))?;
if !status.is_success() {
return Err(DispatchError::Transport(format!(
"POST {url} returned {status}: {body}"
)));
}
Ok(body)
}
async fn post_bytes(&self, path: &str, body: Vec<u8>) -> Result<Value, DispatchError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.post(&url)
.header("content-type", "application/octet-stream")
.body(body)
.send()
.await
.map_err(|e| DispatchError::Transport(format!("POST {url}: {e}")))?;
let status = resp.status();
let body: Value = resp
.json()
.await
.map_err(|e| DispatchError::Transport(format!("decode {url}: {e}")))?;
if !status.is_success() {
return Err(DispatchError::Transport(format!(
"POST {url} returned {status}: {body}"
)));
}
Ok(body)
}
async fn get(&self, path: &str) -> Result<Value, DispatchError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.get(&url)
.send()
.await
.map_err(|e| DispatchError::Transport(format!("GET {url}: {e}")))?;
let status = resp.status();
let body: Value = resp
.json()
.await
.map_err(|e| DispatchError::Transport(format!("decode {url}: {e}")))?;
if !status.is_success() {
return Err(DispatchError::Transport(format!(
"GET {url} returned {status}: {body}"
)));
}
Ok(body)
}
async fn post(&self, path: &str, body: &Value) -> Result<Value, DispatchError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.post(&url)
.json(body)
.send()
.await
.map_err(|e| DispatchError::Transport(format!("POST {url}: {e}")))?;
let status = resp.status();
let body: Value = resp
.json()
.await
.map_err(|e| DispatchError::Transport(format!("decode {url}: {e}")))?;
if !status.is_success() {
return Err(DispatchError::Transport(format!(
"POST {url} returned {status}: {body}"
)));
}
Ok(body)
}
async fn delete(&self, path: &str) -> Result<Value, DispatchError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.http
.delete(&url)
.send()
.await
.map_err(|e| DispatchError::Transport(format!("DELETE {url}: {e}")))?;
let status = resp.status();
let body: Value = resp
.json()
.await
.map_err(|e| DispatchError::Transport(format!("decode {url}: {e}")))?;
if !status.is_success() {
return Err(DispatchError::Transport(format!(
"DELETE {url} returned {status}: {body}"
)));
}
Ok(body)
}
}
#[derive(Debug)]
enum DispatchError {
UnknownTool,
InvalidParams(String),
Transport(String),
}
fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, DispatchError> {
args.get(key)
.and_then(Value::as_str)
.ok_or_else(|| DispatchError::InvalidParams(format!("missing or non-string '{key}'")))
}
fn index_id_or_default(args: &Value) -> &str {
args.get("index")
.or_else(|| args.get("index_id"))
.and_then(Value::as_str)
.unwrap_or("default")
}
fn build_query(args: &Value, keys: &[&str]) -> String {
let mut q = String::new();
for key in keys {
if let Some(v) = args.get(*key).and_then(Value::as_str) {
let sep = if q.is_empty() { '?' } else { '&' };
q.push(sep);
q.push_str(key);
q.push('=');
q.push_str(&urlencode(v));
}
}
q
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn wrap_text_content(value: &Value) -> Value {
serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
}]
})
}
fn wrap_tool_result(value: &Value) -> Value {
serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
}],
"isError": false,
})
}
fn wrap_tool_error(msg: &str) -> Value {
serde_json::json!({
"content": [{ "type": "text", "text": format!("Error: {msg}") }],
"isError": true,
})
}
pub fn tool_descriptors() -> Value {
serde_json::json!([
{
"name": "complexity_hotspots",
"description": "Top-N chunks ranked by cyclomatic complexity",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" },
"top_n": { "type": "number" }
}
}
},
{
"name": "find_smells",
"description": "Chunks with at least one detected code smell",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" }
}
}
},
{
"name": "analyze_quality",
"description": "Aggregate quality stats: avg cyclomatic, %A, smell count",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" }
}
}
},
{
"name": "run_diagnostics",
"description": "Run available external static-analysis tools (clippy, ruff, biome, staticcheck, pmd, rubocop, phpstan, swiftlint, detekt, clang-tidy) across the index corpus on demand. Tools are auto-discovered: only installed binaries run. Returns normalized diagnostics with file, line, severity, rule code, and message.",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" },
"language": { "type": "string", "description": "Optional: restrict to one language tag (rust, python, typescript, go, java, ruby, php, swift, kotlin, cpp)" },
"tools": { "type": "string", "description": "Optional: comma-separated list of tool names to run; defaults to all available" }
}
}
},
{
"name": "list_facts",
"description": "List canonical facts, optionally filtered by subject/predicate/object",
"inputSchema": {
"type": "object",
"properties": {
"subject": { "type": "string" },
"predicate": { "type": "string" },
"object": { "type": "string" }
}
}
},
{
"name": "upsert_fact",
"description": "Insert or update a canonical fact triple",
"inputSchema": {
"type": "object",
"required": ["subject", "predicate", "object", "index_id"],
"properties": {
"subject": { "type": "string" },
"predicate": { "type": "string" },
"object": { "type": "string" },
"index_id": { "type": "string" },
"confidence": { "type": "number" },
"provenance": { "type": "array", "items": { "type": "string" } }
}
}
},
{
"name": "delete_fact",
"description": "Delete a fact by its u64 id",
"inputSchema": {
"type": "object",
"required": ["id"],
"properties": { "id": { "type": "number" } }
}
},
{
"name": "analyzer_health",
"description": "Probe analyzer daemon liveness and version",
"inputSchema": { "type": "object", "properties": {} }
},
{
"name": "extract_graph",
"description": "Build the multi-language knowledge graph (nodes + edges) for an index",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" },
"language": { "type": "string" }
}
}
},
{
"name": "cluster_concepts",
"description": "Group chunks into concept clusters using k-means over embeddings (BOW or neural)",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" },
"k": { "type": "number" },
"method": { "type": "string", "description": "Embedding method: 'bow' (default, fast) or 'neural' (semantic, requires fastembed model)" }
}
}
},
{
"name": "ingest_scip",
"description": "Ingest a SCIP (Scalable and Precise Index for Code) protobuf index for a given index_id, enriching the knowledge graph with fully-resolved symbols and cross-file relationships. The SCIP bytes must be base64-encoded.",
"inputSchema": {
"type": "object",
"required": ["scip_base64"],
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" },
"scip_base64": { "type": "string", "description": "Base64-encoded SCIP Index protobuf payload" }
}
}
},
{
"name": "extract_ner",
"description": "Extract named entities from doc comments for a code index using NER",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string", "description": "Index ID" },
"top_k": { "type": "integer", "description": "Max entities to return", "default": 50 }
}
}
},
{
"name": "suggest_refactors",
"description": "Suggest concrete refactoring actions (extract method, reduce nesting, ...) ranked by severity, derived from complexity metrics and code smells",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" },
"file": { "type": "string", "description": "Optional path filter — restrict suggestions to one file" },
"min_severity": { "type": "string", "description": "Minimum severity: 'low' (default), 'medium', 'high', 'critical'" },
"top_k": { "type": "number", "description": "Cap on suggestions returned (default 20)" }
}
}
},
{
"name": "review_diff",
"description": "Review a unified git diff and return a structured quality report (per-file complexity, code smells, grade A-F, recommendations). Cross-references the diff against the trusty-search index corpus, so trusty-search must be running. Deterministic and LLM-free — use the deep_analysis tool for LLM-augmented narrative.",
"inputSchema": {
"type": "object",
"required": ["diff", "index_id"],
"properties": {
"diff": { "type": "string", "description": "Unified git diff text to review" },
"index_id": { "type": "string", "description": "Index ID to cross-reference the diff against in trusty-search" }
}
}
},
{
"name": "deep_analysis",
"description": "Run an LLM-augmented deep analysis pass over an index: synthesises a deterministic review report from the indexed corpus, looks up detected frameworks, and asks an OpenRouter model for a prose narrative plus framework-aware recommendations. Requires OPENROUTER_API_KEY configured on the daemon.",
"inputSchema": {
"type": "object",
"required": ["index_id"],
"properties": {
"index_id": { "type": "string", "description": "trusty-search index ID to analyse" },
"model": { "type": "string", "description": "Optional OpenRouter model id (e.g. 'openai/gpt-4o-mini'); falls back to TRUSTY_LLM_MODEL on the daemon" }
}
}
},
{
"name": "review_github_pr",
"description": "Fetch a GitHub pull request's unified diff and run a structured quality review against a trusty-search index. Requires GITHUB_TOKEN set on the daemon. Optionally posts the review back as a PR comment.",
"inputSchema": {
"type": "object",
"required": ["owner", "repo", "pr", "index_id"],
"properties": {
"owner": { "type": "string", "description": "Repository owner (user or org)" },
"repo": { "type": "string", "description": "Repository name" },
"pr": { "type": "integer", "description": "Pull request number" },
"index_id": { "type": "string", "description": "trusty-search index ID to cross-reference" },
"post_comment": { "type": "boolean", "description": "Post the review back as a PR comment (default false)", "default": false }
}
}
},
{
"name": "list_entities",
"description": "List symbol-level entities (functions, classes, ...) for an index",
"inputSchema": {
"type": "object",
"properties": {
"index": { "type": "string" },
"index_id": { "type": "string" },
"kind": { "type": "string" },
"language": { "type": "string" }
}
}
}
])
}
#[cfg(test)]
mod tests {
use super::*;
fn req(method: &str, params: Value) -> Request {
Request {
jsonrpc: "2.0".into(),
id: Some(Value::from(1u64)),
method: method.into(),
params,
}
}
#[tokio::test]
async fn tools_list_contains_full_surface() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("tools/list", Value::Null)).await;
let result = resp.result.expect("expected result");
let tools = result
.get("tools")
.and_then(Value::as_array)
.expect("array");
let names: Vec<&str> = tools
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
for required in [
"complexity_hotspots",
"find_smells",
"analyze_quality",
"run_diagnostics",
"list_facts",
"upsert_fact",
"delete_fact",
"analyzer_health",
"ingest_scip",
] {
assert!(
names.contains(&required),
"missing tool '{required}' (got {names:?})"
);
}
}
#[tokio::test]
async fn tools_list_includes_review_diff() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("tools/list", Value::Null)).await;
let result = resp.result.expect("expected result");
let tools = result
.get("tools")
.and_then(Value::as_array)
.expect("array");
let names: Vec<&str> = tools
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"review_diff"), "got {names:?}");
}
#[tokio::test]
async fn review_diff_requires_diff_param() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_review_diff(&serde_json::json!({ "index_id": "x" }))
.await
.expect_err("missing diff param should fail");
assert!(matches!(err, DispatchError::InvalidParams(_)));
}
#[tokio::test]
async fn review_diff_requires_index_id() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_review_diff(&serde_json::json!({ "diff": "+++ b/x.rs\n" }))
.await
.expect_err("missing index_id param should fail");
assert!(matches!(err, DispatchError::InvalidParams(_)));
}
#[tokio::test]
async fn review_diff_with_args_attempts_post_to_review() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_review_diff(&serde_json::json!({
"diff": "+++ b/x.rs\n",
"index_id": "my-idx",
}))
.await
.expect_err("daemon unreachable");
match err {
DispatchError::Transport(msg) => {
assert!(msg.contains("/review"), "got {msg}");
assert!(msg.contains("index_id=my-idx"), "got {msg}");
}
other => panic!("expected Transport, got {other:?}"),
}
}
#[tokio::test]
async fn tools_list_includes_review_github_pr() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("tools/list", Value::Null)).await;
let result = resp.result.expect("expected result");
let tools = result
.get("tools")
.and_then(Value::as_array)
.expect("array");
let names: Vec<&str> = tools
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"review_github_pr"), "got {names:?}");
}
#[tokio::test]
async fn review_github_pr_requires_owner() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_review_github_pr(&serde_json::json!({
"repo": "r", "pr": 1, "index_id": "i"
}))
.await
.expect_err("missing owner should fail");
assert!(matches!(err, DispatchError::InvalidParams(_)));
}
#[tokio::test]
async fn review_github_pr_requires_pr_number() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_review_github_pr(&serde_json::json!({
"owner": "o", "repo": "r", "index_id": "i"
}))
.await
.expect_err("missing pr should fail");
assert!(matches!(err, DispatchError::InvalidParams(_)));
}
#[tokio::test]
async fn review_github_pr_posts_to_endpoint() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_review_github_pr(&serde_json::json!({
"owner": "o", "repo": "r", "pr": 7, "index_id": "i"
}))
.await
.expect_err("daemon unreachable");
match err {
DispatchError::Transport(msg) => {
assert!(msg.contains("/review/github-pr"), "got {msg}");
}
other => panic!("expected Transport, got {other:?}"),
}
}
#[tokio::test]
async fn tools_list_includes_deep_analysis() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("tools/list", Value::Null)).await;
let result = resp.result.expect("expected result");
let tools = result
.get("tools")
.and_then(Value::as_array)
.expect("array");
let names: Vec<&str> = tools
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"deep_analysis"), "got {names:?}");
}
#[tokio::test]
async fn deep_analysis_requires_index_id() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_deep_analysis(&serde_json::json!({}))
.await
.expect_err("missing index_id should fail");
assert!(matches!(err, DispatchError::InvalidParams(_)));
}
#[tokio::test]
async fn deep_analysis_posts_to_endpoint() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_deep_analysis(&serde_json::json!({ "index_id": "i", "model": "m" }))
.await
.expect_err("daemon unreachable");
match err {
DispatchError::Transport(msg) => {
assert!(msg.contains("/analyze/deep"), "got {msg}");
}
other => panic!("expected Transport, got {other:?}"),
}
}
#[tokio::test]
async fn resources_list_returns_envelope() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("resources/list", Value::Null)).await;
let result = resp.result.expect("expected result");
let resources = result
.get("resources")
.and_then(Value::as_array)
.expect("resources array");
assert!(resources.is_empty(), "expected empty list when daemon down");
}
#[tokio::test]
async fn initialize_advertises_resources_capability() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let resp = server.dispatch(req("initialize", Value::Null)).await;
let result = resp.result.expect("expected result");
assert!(result["capabilities"]["resources"].is_object());
}
#[tokio::test]
async fn unknown_tool_returns_method_not_found() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let resp = server
.dispatch(req(
"tools/call",
serde_json::json!({ "name": "no_such_tool", "arguments": {} }),
))
.await;
let err = resp.error.expect("expected error");
assert_eq!(err.code, error_codes::METHOD_NOT_FOUND);
}
#[tokio::test]
async fn handle_analyzer_health_calls_health_endpoint() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let err = server
.handle_analyzer_health(&Value::Null)
.await
.expect_err("daemon unreachable, expected transport error");
match err {
DispatchError::Transport(msg) => {
assert!(
msg.contains("/health"),
"expected transport error to mention /health, got: {msg}"
);
}
other => panic!("expected DispatchError::Transport, got {other:?}"),
}
}
#[test]
fn index_id_or_default_prefers_index_then_alias_then_default() {
let with_index = serde_json::json!({ "index": "primary" });
assert_eq!(index_id_or_default(&with_index), "primary");
let with_alias = serde_json::json!({ "index_id": "alias" });
assert_eq!(index_id_or_default(&with_alias), "alias");
let empty = serde_json::json!({});
assert_eq!(index_id_or_default(&empty), "default");
}
#[test]
fn build_query_skips_missing_keys() {
let args = serde_json::json!({ "subject": "fn auth", "object": "JWT" });
let q = build_query(&args, &["subject", "predicate", "object"]);
assert!(q.starts_with('?'), "expected leading '?', got {q}");
assert!(q.contains("subject=fn%20auth"), "got {q}");
assert!(q.contains("object=JWT"), "got {q}");
assert!(!q.contains("predicate"), "got {q}");
}
#[tokio::test]
async fn rejects_wrong_jsonrpc_version() {
let server = AnalyzerMcpServer::new("http://127.0.0.1:1");
let r = Request {
jsonrpc: "1.0".into(),
id: Some(Value::from(7u64)),
method: "tools/list".into(),
params: Value::Null,
};
let resp = server.dispatch(r).await;
let err = resp.error.expect("expected error");
assert_eq!(err.code, error_codes::INVALID_REQUEST);
}
}