use std::path::PathBuf;
use grapha_core::graph::Graph;
use serde_json::{Value, json};
use tantivy::Index;
use crate::mcp::types::ToolDefinition;
use crate::query;
use crate::recall::{self, Recall};
use crate::search;
use crate::store::Store;
use crate::{assets, concepts, localization};
pub struct McpState {
pub graph: Graph,
pub search_index: Index,
pub store_path: PathBuf,
pub recall: Recall,
}
pub fn tool_definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
name: "search_symbols".to_string(),
description: "Search for symbols by name, kind, module, repo, file, or role. Returns matching symbols with relevance scores.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (symbol name or keyword)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default: 20)",
"default": 20
},
"kind": {
"type": "string",
"description": "Filter by symbol kind (function, struct, enum, trait, etc.)"
},
"module": {
"type": "string",
"description": "Filter by module name"
},
"repo": {
"type": "string",
"description": "Filter by repo name"
},
"file": {
"type": "string",
"description": "Filter by file path glob"
},
"role": {
"type": "string",
"description": "Filter by role (entry_point, terminal, internal)"
},
"fuzzy": {
"type": "boolean",
"description": "Enable fuzzy matching (default: false)",
"default": false
},
"exact_name": {
"type": "boolean",
"description": "Require an exact declaration-name match (default: false)",
"default": false
},
"declarations_only": {
"type": "boolean",
"description": "Exclude synthetic nodes and accessor functions (default: false)",
"default": false
},
"public_only": {
"type": "boolean",
"description": "Keep only public symbols (default: false)",
"default": false
}
},
"required": ["query"]
}),
},
ToolDefinition {
name: "get_index_status".to_string(),
description: "Show the last index timestamp, repo snapshot metadata, and whether results may be stale.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "get_symbol_context".to_string(),
description: "Get 360-degree context for a symbol: callers, callees, implementors, containment, and type references.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Symbol name or ID"
}
},
"required": ["symbol"]
}),
},
ToolDefinition {
name: "get_impact".to_string(),
description: "Analyze the blast radius of changing a symbol using BFS traversal.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Symbol name or ID"
},
"depth": {
"type": "integer",
"description": "Maximum traversal depth (default: 3)",
"default": 3
}
},
"required": ["symbol"]
}),
},
ToolDefinition {
name: "get_file_map".to_string(),
description: "Get a map of files and symbols organized by module and directory.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"module": {
"type": "string",
"description": "Filter by module name (optional)"
}
}
}),
},
ToolDefinition {
name: "trace".to_string(),
description: "Trace dataflow forward from a symbol to terminals, or reverse from a symbol back to entry points.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Symbol name or ID"
},
"direction": {
"type": "string",
"enum": ["forward", "reverse"],
"description": "Trace direction (default: forward)",
"default": "forward"
},
"depth": {
"type": "integer",
"description": "Maximum traversal depth (default: 10 for forward, unlimited for reverse)"
}
},
"required": ["symbol"]
}),
},
ToolDefinition {
name: "get_file_symbols".to_string(),
description: "List all symbols in a file, ordered by source position. Returns declarations (structs, functions, properties, etc.) excluding synthetic view/branch nodes.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"file": {
"type": "string",
"description": "File name or path suffix (e.g. \"RoomPage.swift\" or \"src/main.rs\")"
}
},
"required": ["file"]
}),
},
ToolDefinition {
name: "batch_context".to_string(),
description: "Get 360-degree context for multiple symbols in a single call. Returns a map of symbol ID to context result. More efficient than multiple get_symbol_context calls.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"symbols": {
"type": "array",
"items": { "type": "string" },
"description": "Array of symbol names or IDs"
}
},
"required": ["symbols"]
}),
},
ToolDefinition {
name: "analyze_complexity".to_string(),
description: "Analyze the structural complexity of a type (struct, class, enum, protocol). Returns property count, method count, dependency count, invalidation sources, init parameter count, extension count, containment depth, blast radius, and an overall severity rating.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Type name or ID to analyze"
}
},
"required": ["symbol"]
}),
},
ToolDefinition {
name: "detect_smells".to_string(),
description: "Scan for code smells across the repo or within a specific module, file, or symbol scope: god types (>15 properties), excessive dependencies (>10), wide invalidation surfaces (>5 sources), massive inits (>8 params), deep nesting (>5 levels), high fan-out/fan-in (>15 calls), and many extensions (>5). Returns smells sorted by severity.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"module": {
"type": "string",
"description": "Limit smell analysis to a specific module"
},
"file": {
"type": "string",
"description": "Limit smell analysis to symbols declared in a matching file"
},
"symbol": {
"type": "string",
"description": "Limit smell analysis to a specific symbol and its local neighborhood"
}
}
}),
},
ToolDefinition {
name: "get_module_summary".to_string(),
description: "Get high-level metrics for each module: symbol count, file count, symbols by kind, edge count, cross-module coupling ratio, entry points, and terminals. Sorted by symbol count descending.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "search_concepts".to_string(),
description: "Resolve a business concept or product term to likely code scopes using stored concept bindings first, then localization, asset, and symbol heuristics.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Business concept text"
},
"limit": {
"type": "integer",
"description": "Maximum number of scopes to return (default: 20)",
"default": 20
}
},
"required": ["query"]
}),
},
ToolDefinition {
name: "get_concept".to_string(),
description: "Show a stored concept mapping, including aliases and bound symbols.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"term": {
"type": "string",
"description": "Concept text or alias"
}
},
"required": ["term"]
}),
},
ToolDefinition {
name: "bind_concept".to_string(),
description: "Persist a confirmed concept-to-symbol mapping for future lookups.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"concept": {
"type": "string",
"description": "Canonical business concept text"
},
"symbols": {
"type": "array",
"items": { "type": "string" },
"description": "One or more symbol queries or IDs to bind"
}
},
"required": ["concept", "symbols"]
}),
},
ToolDefinition {
name: "add_concept_alias".to_string(),
description: "Add one or more aliases for a concept in the project concept store.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"concept": {
"type": "string",
"description": "Canonical business concept text"
},
"aliases": {
"type": "array",
"items": { "type": "string" },
"description": "Aliases to add"
}
},
"required": ["concept", "aliases"]
}),
},
ToolDefinition {
name: "remove_concept".to_string(),
description: "Remove a concept from the project concept store.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"concept": {
"type": "string",
"description": "Concept text or alias"
}
},
"required": ["concept"]
}),
},
ToolDefinition {
name: "reload".to_string(),
description: "Reload the graph and search index from disk. Use after running `grapha index` from the CLI to pick up changes without restarting the MCP server.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
]
}
fn text_content(text: String) -> Value {
json!({
"content": [{
"type": "text",
"text": text
}]
})
}
fn tool_error(message: String) -> Value {
json!({
"content": [{
"type": "text",
"text": message
}],
"isError": true
})
}
fn format_query_error(err: &query::QueryResolveError) -> String {
match err {
query::QueryResolveError::NotFound { query } => {
format!("symbol not found: {query}")
}
query::QueryResolveError::Ambiguous { query, candidates } => {
let mut msg = format!("ambiguous query: {query}\n");
for c in candidates {
msg.push_str(&format!(
" - {} [{:?}] in {} ({})\n",
c.name,
c.kind,
c.file,
c.locator.as_deref().unwrap_or(c.id.as_str())
));
}
msg.push_str(&format!("hint: {}", query::ambiguity_hint()));
msg
}
query::QueryResolveError::NotFunction { hint } => hint.clone(),
}
}
fn serialize_result<T: serde::Serialize>(result: &T) -> Value {
match serde_json::to_string_pretty(result) {
Ok(json) => text_content(json),
Err(e) => tool_error(format!("failed to serialize result: {e}")),
}
}
pub fn handle_tool_call(state: &mut McpState, tool_name: &str, arguments: &Value) -> Value {
match tool_name {
"search_symbols" => handle_search_symbols(state, arguments),
"get_index_status" => handle_get_index_status(state),
"get_symbol_context" => handle_get_symbol_context(state, arguments),
"get_impact" => handle_get_impact(state, arguments),
"get_file_map" => handle_get_file_map(state, arguments),
"trace" => handle_trace(state, arguments),
"get_file_symbols" => handle_get_file_symbols(state, arguments),
"batch_context" => handle_batch_context(state, arguments),
"analyze_complexity" => handle_analyze_complexity(state, arguments),
"detect_smells" => handle_detect_smells(state, arguments),
"get_module_summary" => handle_get_module_summary(state),
"search_concepts" => handle_search_concepts(state, arguments),
"get_concept" => handle_get_concept(state, arguments),
"bind_concept" => handle_bind_concept(state, arguments),
"add_concept_alias" => handle_add_concept_alias(state, arguments),
"remove_concept" => handle_remove_concept(state, arguments),
"reload" => handle_reload(state),
_ => tool_error(format!("unknown tool: {tool_name}")),
}
}
fn resolve_symbol(state: &mut McpState, query: &str) -> Result<String, Value> {
match recall::resolve_with_recall(&state.graph, query, &mut state.recall) {
Ok(node) => Ok(node.id.clone()),
Err(e) => Err(tool_error(format_query_error(&e))),
}
}
fn handle_search_symbols(state: &McpState, arguments: &Value) -> Value {
let query_str = match arguments.get("query").and_then(|v| v.as_str()) {
Some(q) => q,
None => return tool_error("missing required parameter: query".to_string()),
};
let limit = arguments
.get("limit")
.and_then(|v| v.as_u64())
.unwrap_or(20) as usize;
let options = search::SearchOptions {
kind: arguments
.get("kind")
.and_then(|v| v.as_str())
.map(String::from),
module: arguments
.get("module")
.and_then(|v| v.as_str())
.map(String::from),
repo: arguments
.get("repo")
.and_then(|v| v.as_str())
.map(String::from),
file_glob: arguments
.get("file")
.and_then(|v| v.as_str())
.map(String::from),
role: arguments
.get("role")
.and_then(|v| v.as_str())
.map(String::from),
fuzzy: arguments
.get("fuzzy")
.and_then(|v| v.as_bool())
.unwrap_or(false),
exact_name: arguments
.get("exact_name")
.and_then(|v| v.as_bool())
.unwrap_or(false),
declarations_only: arguments
.get("declarations_only")
.and_then(|v| v.as_bool())
.unwrap_or(false),
public_only: arguments
.get("public_only")
.and_then(|v| v.as_bool())
.unwrap_or(false),
};
match search::search_filtered(&state.search_index, query_str, limit, &options) {
Ok(results) => serialize_result(&results),
Err(e) => tool_error(format!("search failed: {e}")),
}
}
fn handle_get_index_status(state: &McpState) -> Value {
let project_root = state.store_path.parent().unwrap_or(&state.store_path);
match crate::index_status::load_index_status(project_root, &state.store_path) {
Ok(status) => serialize_result(&status),
Err(error) => tool_error(format!("failed to load index status: {error}")),
}
}
fn handle_get_symbol_context(state: &mut McpState, arguments: &Value) -> Value {
let query = match arguments.get("symbol").and_then(|v| v.as_str()) {
Some(s) => s,
None => return tool_error("missing required parameter: symbol".to_string()),
};
let symbol_id = match resolve_symbol(state, query) {
Ok(id) => id,
Err(e) => return e,
};
match query::context::query_context(&state.graph, &symbol_id) {
Ok(result) => serialize_result(&result),
Err(e) => tool_error(format_query_error(&e)),
}
}
fn handle_get_impact(state: &mut McpState, arguments: &Value) -> Value {
let query = match arguments.get("symbol").and_then(|v| v.as_str()) {
Some(s) => s,
None => return tool_error("missing required parameter: symbol".to_string()),
};
let symbol_id = match resolve_symbol(state, query) {
Ok(id) => id,
Err(e) => return e,
};
let depth = arguments.get("depth").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
match query::impact::query_impact(&state.graph, &symbol_id, depth) {
Ok(result) => serialize_result(&result),
Err(e) => tool_error(format_query_error(&e)),
}
}
fn handle_get_file_map(state: &McpState, arguments: &Value) -> Value {
let module = arguments.get("module").and_then(|v| v.as_str());
let result = query::map::file_map(&state.graph, module);
serialize_result(&result)
}
fn handle_trace(state: &mut McpState, arguments: &Value) -> Value {
let query = match arguments.get("symbol").and_then(|v| v.as_str()) {
Some(s) => s,
None => return tool_error("missing required parameter: symbol".to_string()),
};
let symbol_id = match resolve_symbol(state, query) {
Ok(id) => id,
Err(e) => return e,
};
let direction = arguments
.get("direction")
.and_then(|v| v.as_str())
.unwrap_or("forward");
let depth = arguments.get("depth").and_then(|v| v.as_u64());
match direction {
"forward" => {
let max_depth = depth.unwrap_or(10) as usize;
match query::trace::query_trace(&state.graph, &symbol_id, max_depth) {
Ok(result) => serialize_result(&result),
Err(e) => tool_error(format_query_error(&e)),
}
}
"reverse" => {
let max_depth = depth.map(|d| d as usize);
match query::reverse::query_reverse(&state.graph, &symbol_id, max_depth) {
Ok(result) => serialize_result(&result),
Err(e) => tool_error(format_query_error(&e)),
}
}
other => tool_error(format!(
"invalid direction: {other} (expected \"forward\" or \"reverse\")"
)),
}
}
fn handle_get_file_symbols(state: &McpState, arguments: &Value) -> Value {
let file = match arguments.get("file").and_then(|v| v.as_str()) {
Some(f) => f,
None => return tool_error("missing required parameter: file".to_string()),
};
let result = query::file_symbols::query_file_symbols(&state.graph, file);
if result.total == 0 {
return tool_error(format!("no symbols found in file matching: {file}"));
}
serialize_result(&result)
}
fn handle_batch_context(state: &mut McpState, arguments: &Value) -> Value {
let symbols = match arguments.get("symbols").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return tool_error("missing required parameter: symbols (array)".to_string()),
};
let symbol_strs: Vec<&str> = symbols.iter().filter_map(|v| v.as_str()).collect();
if symbol_strs.is_empty() {
return tool_error("symbols array is empty".to_string());
}
if symbol_strs.len() > 20 {
return tool_error("batch_context supports at most 20 symbols per call".to_string());
}
let mut results: Vec<Value> = Vec::with_capacity(symbol_strs.len());
for symbol in &symbol_strs {
let resolved = resolve_symbol(state, symbol);
let query_id = match &resolved {
Ok(id) => id.as_str(),
Err(_) => symbol,
};
match query::context::query_context(&state.graph, query_id) {
Ok(ctx) => {
results.push(json!({
"query": symbol,
"result": serde_json::to_value(&ctx).unwrap_or(Value::Null),
}));
}
Err(e) => {
results.push(json!({
"query": symbol,
"error": format_query_error(&e),
}));
}
}
}
serialize_result(&results)
}
fn handle_analyze_complexity(state: &mut McpState, arguments: &Value) -> Value {
let query = match arguments.get("symbol").and_then(|v| v.as_str()) {
Some(s) => s,
None => return tool_error("missing required parameter: symbol".to_string()),
};
let symbol_id = match resolve_symbol(state, query) {
Ok(id) => id,
Err(e) => return e,
};
match query::complexity::query_complexity(&state.graph, &symbol_id) {
Ok(result) => serialize_result(&result),
Err(e) => tool_error(format_query_error(&e)),
}
}
fn handle_detect_smells(state: &McpState, arguments: &Value) -> Value {
let module_filter = arguments.get("module").and_then(|v| v.as_str());
let file_filter = arguments.get("file").and_then(|v| v.as_str());
let symbol_filter = arguments.get("symbol").and_then(|v| v.as_str());
let scope_count = [module_filter, file_filter, symbol_filter]
.into_iter()
.flatten()
.count();
if scope_count > 1 {
return tool_error("choose only one of module, file, or symbol".to_string());
}
let result = if let Some(file) = file_filter {
query::smells::detect_smells_for_file(&state.graph, file)
} else if let Some(symbol_query) = symbol_filter {
let node = match query::resolve_node(&state.graph, symbol_query) {
Ok(node) => node,
Err(e) => return tool_error(format_query_error(&e)),
};
query::smells::detect_smells_for_symbol(&state.graph, &node.id)
} else if let Some(module) = module_filter {
query::smells::detect_smells_for_module(&state.graph, module)
} else {
query::smells::detect_smells(&state.graph)
};
serialize_result(&result)
}
fn handle_get_module_summary(state: &McpState) -> Value {
let result = query::module_summary::query_module_summary(&state.graph);
serialize_result(&result)
}
fn handle_search_concepts(state: &McpState, arguments: &Value) -> Value {
let query = match arguments.get("query").and_then(|v| v.as_str()) {
Some(query) => query,
None => return tool_error("missing required parameter: query".to_string()),
};
let limit = arguments
.get("limit")
.and_then(|v| v.as_u64())
.unwrap_or(concepts::DEFAULT_CONCEPT_SEARCH_LIMIT as u64) as usize;
let concept_index = match concepts::load_concept_index_from_store(&state.store_path) {
Ok(index) => index,
Err(error) => return tool_error(format!("failed to load concept store: {error}")),
};
let catalogs =
localization::load_catalog_index_from_store(&state.store_path).unwrap_or_default();
let assets_index = assets::load_asset_index_from_store(&state.store_path).unwrap_or_default();
match concepts::search_concepts(
&state.graph,
&state.search_index,
&concept_index,
&catalogs,
&assets_index,
query,
limit,
) {
Ok(result) => serialize_result(&result),
Err(error) => tool_error(format!("concept search failed: {error}")),
}
}
fn handle_get_concept(state: &McpState, arguments: &Value) -> Value {
let term = match arguments.get("term").and_then(|v| v.as_str()) {
Some(term) => term,
None => return tool_error("missing required parameter: term".to_string()),
};
let concept_index = match concepts::load_concept_index_from_store(&state.store_path) {
Ok(index) => index,
Err(error) => return tool_error(format!("failed to load concept store: {error}")),
};
match concepts::show_concept(&state.graph, &concept_index, term) {
Ok(result) => serialize_result(&result),
Err(error) => tool_error(error.to_string()),
}
}
fn handle_bind_concept(state: &mut McpState, arguments: &Value) -> Value {
let concept = match arguments.get("concept").and_then(|v| v.as_str()) {
Some(concept) => concept,
None => return tool_error("missing required parameter: concept".to_string()),
};
let symbols = match arguments.get("symbols").and_then(|v| v.as_array()) {
Some(symbols) => symbols,
None => return tool_error("missing required parameter: symbols".to_string()),
};
if symbols.is_empty() {
return tool_error("symbols array is empty".to_string());
}
let mut unique_ids = std::collections::BTreeSet::new();
for symbol in symbols {
let Some(symbol_query) = symbol.as_str() else {
return tool_error("symbols must be an array of strings".to_string());
};
let node = match query::resolve_node(&state.graph, symbol_query) {
Ok(node) => node,
Err(error) => return tool_error(format_query_error(&error)),
};
unique_ids.insert(node.id.clone());
}
let mut concept_index = match concepts::load_concept_index_from_store(&state.store_path) {
Ok(index) => index,
Err(error) => return tool_error(format!("failed to load concept store: {error}")),
};
let result = match concept_index.bind_concept(
concept,
&unique_ids.into_iter().collect::<Vec<_>>(),
vec![concepts::ConceptEvidence {
kind: "manual".to_string(),
value: concept.trim().to_string(),
match_kind: "confirmed".to_string(),
table: None,
key: None,
source_value: None,
ui_path: Vec::new(),
note: Some("manual concept binding".to_string()),
}],
) {
Ok(result) => result,
Err(error) => return tool_error(error.to_string()),
};
match concepts::save_concept_index_to_store(&state.store_path, &concept_index) {
Ok(()) => serialize_result(&result),
Err(error) => tool_error(format!("failed to save concept store: {error}")),
}
}
fn handle_add_concept_alias(state: &McpState, arguments: &Value) -> Value {
let concept = match arguments.get("concept").and_then(|v| v.as_str()) {
Some(concept) => concept,
None => return tool_error("missing required parameter: concept".to_string()),
};
let aliases = match arguments.get("aliases").and_then(|v| v.as_array()) {
Some(aliases) => aliases,
None => return tool_error("missing required parameter: aliases".to_string()),
};
if aliases.is_empty() {
return tool_error("aliases array is empty".to_string());
}
let mut concept_index = match concepts::load_concept_index_from_store(&state.store_path) {
Ok(index) => index,
Err(error) => return tool_error(format!("failed to load concept store: {error}")),
};
let alias_values: Vec<String> = aliases
.iter()
.filter_map(|value| value.as_str().map(ToString::to_string))
.collect();
if alias_values.len() != aliases.len() {
return tool_error("aliases must be an array of strings".to_string());
}
let result = match concept_index.add_aliases(concept, &alias_values) {
Ok(result) => result,
Err(error) => return tool_error(error.to_string()),
};
match concepts::save_concept_index_to_store(&state.store_path, &concept_index) {
Ok(()) => serialize_result(&result),
Err(error) => tool_error(format!("failed to save concept store: {error}")),
}
}
fn handle_remove_concept(state: &McpState, arguments: &Value) -> Value {
let concept = match arguments.get("concept").and_then(|v| v.as_str()) {
Some(concept) => concept,
None => return tool_error("missing required parameter: concept".to_string()),
};
let mut concept_index = match concepts::load_concept_index_from_store(&state.store_path) {
Ok(index) => index,
Err(error) => return tool_error(format!("failed to load concept store: {error}")),
};
let result = concept_index.remove_concept(concept);
match concepts::save_concept_index_to_store(&state.store_path, &concept_index) {
Ok(()) => serialize_result(&result),
Err(error) => tool_error(format!("failed to save concept store: {error}")),
}
}
fn handle_reload(state: &mut McpState) -> Value {
let db_path = state.store_path.join("grapha.db");
let search_index_path = state.store_path.join("search_index");
let store = crate::store::sqlite::SqliteStore::new(db_path);
let graph = match store.load() {
Ok(g) => g,
Err(e) => return tool_error(format!("failed to reload graph: {e}")),
};
let search_index = if search_index_path.exists() {
match tantivy::Index::open_in_dir(&search_index_path) {
Ok(idx) => idx,
Err(e) => return tool_error(format!("failed to reload search index: {e}")),
}
} else {
match search::build_index(&graph, &search_index_path) {
Ok(idx) => idx,
Err(e) => return tool_error(format!("failed to build search index: {e}")),
}
};
let node_count = graph.nodes.len();
let edge_count = graph.edges.len();
state.graph = graph;
state.search_index = search_index;
let valid_ids: std::collections::HashSet<&str> =
state.graph.nodes.iter().map(|n| n.id.as_str()).collect();
state.recall.prune(&valid_ids);
text_content(format!(
"Reloaded successfully: {node_count} nodes, {edge_count} edges"
))
}
#[cfg(test)]
mod tests {
use super::*;
use grapha_core::graph::{Edge, EdgeKind, Node, NodeKind, Span, Visibility};
use std::collections::HashMap;
#[test]
fn tool_definitions_count() {
let tools = tool_definitions();
assert_eq!(tools.len(), 17);
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"search_symbols"));
assert!(names.contains(&"get_index_status"));
assert!(names.contains(&"get_symbol_context"));
assert!(names.contains(&"get_impact"));
assert!(names.contains(&"get_file_map"));
assert!(names.contains(&"trace"));
assert!(names.contains(&"get_file_symbols"));
assert!(names.contains(&"batch_context"));
assert!(names.contains(&"analyze_complexity"));
assert!(names.contains(&"detect_smells"));
assert!(names.contains(&"get_module_summary"));
assert!(names.contains(&"search_concepts"));
assert!(names.contains(&"get_concept"));
assert!(names.contains(&"bind_concept"));
assert!(names.contains(&"add_concept_alias"));
assert!(names.contains(&"remove_concept"));
assert!(names.contains(&"reload"));
}
#[test]
fn unknown_tool_returns_error() {
let mut state = make_test_state();
let result = handle_tool_call(&mut state, "nonexistent", &json!({}));
assert!(
result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
}
#[test]
fn search_symbols_missing_query_returns_error() {
let mut state = make_test_state();
let result = handle_tool_call(&mut state, "search_symbols", &json!({}));
assert!(
result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
}
#[test]
fn get_file_symbols_missing_file_returns_error() {
let mut state = make_test_state();
let result = handle_tool_call(&mut state, "get_file_symbols", &json!({}));
assert!(
result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
}
#[test]
fn batch_context_empty_array_returns_error() {
let mut state = make_test_state();
let result = handle_tool_call(&mut state, "batch_context", &json!({"symbols": []}));
assert!(
result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
}
#[test]
fn detect_smells_on_empty_graph() {
let mut state = make_test_state();
let result = handle_tool_call(&mut state, "detect_smells", &json!({}));
assert!(
!result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
}
#[test]
fn detect_smells_rejects_multiple_scopes() {
let mut state = make_test_state();
let result = handle_tool_call(
&mut state,
"detect_smells",
&json!({"module": "Room", "file": "RoomPage.swift"}),
);
assert!(
result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
}
#[test]
fn detect_smells_symbol_scope_limits_results() {
let mut state = make_test_state_with_smells();
let result = handle_tool_call(&mut state, "detect_smells", &json!({"symbol": "MainView"}));
assert!(
!result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
let text = result["content"][0]["text"].as_str().unwrap();
let parsed: Value = serde_json::from_str(text).unwrap();
assert_eq!(parsed["total"], 1);
assert_eq!(parsed["smells"][0]["symbol"]["name"], "MainView");
}
#[test]
fn get_module_summary_on_empty_graph() {
let mut state = make_test_state();
let result = handle_tool_call(&mut state, "get_module_summary", &json!({}));
assert!(
!result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false)
);
}
fn make_test_state() -> McpState {
let graph = Graph {
version: String::new(),
nodes: vec![],
edges: vec![],
};
let schema = tantivy::schema::Schema::builder().build();
let index = Index::create_in_ram(schema);
McpState {
graph,
search_index: index,
store_path: PathBuf::from("/tmp/test"),
recall: Recall::new(),
}
}
fn make_test_state_with_smells() -> McpState {
fn node(id: &str, name: &str, kind: NodeKind, file: &str) -> Node {
Node {
id: id.into(),
kind,
name: name.into(),
file: PathBuf::from(file),
span: Span {
start: [1, 0],
end: [10, 0],
},
visibility: Visibility::Public,
metadata: HashMap::new(),
role: None,
signature: None,
doc_comment: None,
module: Some("App".to_string()),
snippet: None,
repo: None,
}
}
fn edge(source: &str, target: &str, kind: EdgeKind) -> Edge {
Edge {
source: source.into(),
target: target.into(),
kind,
confidence: 1.0,
direction: None,
operation: None,
condition: None,
async_boundary: None,
provenance: vec![],
repo: None,
}
}
let graph = Graph {
version: String::new(),
nodes: vec![
node(
"src/main.rs::MainView",
"MainView",
NodeKind::Struct,
"src/main.rs",
),
node(
"src/main.rs::MainView::L1",
"L1",
NodeKind::View,
"src/main.rs",
),
node(
"src/main.rs::MainView::L2",
"L2",
NodeKind::View,
"src/main.rs",
),
node(
"src/main.rs::MainView::L3",
"L3",
NodeKind::View,
"src/main.rs",
),
node(
"src/main.rs::MainView::L4",
"L4",
NodeKind::View,
"src/main.rs",
),
node(
"src/main.rs::MainView::L5",
"L5",
NodeKind::View,
"src/main.rs",
),
node(
"src/main.rs::MainView::L6",
"L6",
NodeKind::View,
"src/main.rs",
),
node(
"src/other.rs::SecondaryView",
"SecondaryView",
NodeKind::Struct,
"src/other.rs",
),
node(
"src/other.rs::SecondaryView::L1",
"L1",
NodeKind::View,
"src/other.rs",
),
node(
"src/other.rs::SecondaryView::L2",
"L2",
NodeKind::View,
"src/other.rs",
),
node(
"src/other.rs::SecondaryView::L3",
"L3",
NodeKind::View,
"src/other.rs",
),
node(
"src/other.rs::SecondaryView::L4",
"L4",
NodeKind::View,
"src/other.rs",
),
node(
"src/other.rs::SecondaryView::L5",
"L5",
NodeKind::View,
"src/other.rs",
),
node(
"src/other.rs::SecondaryView::L6",
"L6",
NodeKind::View,
"src/other.rs",
),
],
edges: vec![
edge(
"src/main.rs::MainView",
"src/main.rs::MainView::L1",
EdgeKind::Contains,
),
edge(
"src/main.rs::MainView::L1",
"src/main.rs::MainView::L2",
EdgeKind::Contains,
),
edge(
"src/main.rs::MainView::L2",
"src/main.rs::MainView::L3",
EdgeKind::Contains,
),
edge(
"src/main.rs::MainView::L3",
"src/main.rs::MainView::L4",
EdgeKind::Contains,
),
edge(
"src/main.rs::MainView::L4",
"src/main.rs::MainView::L5",
EdgeKind::Contains,
),
edge(
"src/main.rs::MainView::L5",
"src/main.rs::MainView::L6",
EdgeKind::Contains,
),
edge(
"src/other.rs::SecondaryView",
"src/other.rs::SecondaryView::L1",
EdgeKind::Contains,
),
edge(
"src/other.rs::SecondaryView::L1",
"src/other.rs::SecondaryView::L2",
EdgeKind::Contains,
),
edge(
"src/other.rs::SecondaryView::L2",
"src/other.rs::SecondaryView::L3",
EdgeKind::Contains,
),
edge(
"src/other.rs::SecondaryView::L3",
"src/other.rs::SecondaryView::L4",
EdgeKind::Contains,
),
edge(
"src/other.rs::SecondaryView::L4",
"src/other.rs::SecondaryView::L5",
EdgeKind::Contains,
),
edge(
"src/other.rs::SecondaryView::L5",
"src/other.rs::SecondaryView::L6",
EdgeKind::Contains,
),
],
};
let schema = tantivy::schema::Schema::builder().build();
let index = Index::create_in_ram(schema);
McpState {
graph,
search_index: index,
store_path: PathBuf::from("/tmp/test"),
recall: Recall::new(),
}
}
}