From 55884714c4abe2579f5d443052d224dd483f454a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 21:22:49 +0000 Subject: [PATCH 1/3] feat(mcp): add query_graph tool with 8 intention-named relationship patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new MCP tool that exposes call-graph queries via a clean, vocabulary-driven API. Instead of receiving all relationship kinds at once (as get_symbol_context does), callers specify exactly one pattern and get deduplicated results for only that relationship type. Supported patterns: - callers_of / callees_of — direct call graph traversal - imports_of / importers_of — Import-kind edges only - inheritors_of / children_of — Inheritance + Implementation edges - tests_for — callers whose symbol or file path matches test/spec conventions - file_summary — all symbols referenced within a given file Results are deduplicated by symbol name (first occurrence wins) and capped at 50 nodes by default (max 500). No new infrastructure: all patterns dispatch directly to existing CallGraphUseCase methods with the appropriate reference_kind filter. https://claude.ai/code/session_01GoKejqoqkgxiug48Hp5LE8 --- src/connector/adapter/mcp/server.rs | 256 +++++++++++++++++++++++++++- 1 file changed, 254 insertions(+), 2 deletions(-) diff --git a/src/connector/adapter/mcp/server.rs b/src/connector/adapter/mcp/server.rs index 5a62f13..afe3b67 100644 --- a/src/connector/adapter/mcp/server.rs +++ b/src/connector/adapter/mcp/server.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use rmcp::handler::server::tool::ToolRouter; @@ -11,8 +12,9 @@ use rmcp::tool_router; use rmcp::ErrorData as McpError; use rmcp::ServerHandler; use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use crate::application::CallGraphQuery; use crate::connector::api::Container; use crate::domain::SearchQuery; @@ -21,10 +23,17 @@ use super::tools::SearchResultOutput; /// Server-side maximum for the number of results a single search can return. const MAX_LIMIT: usize = 100; +/// Server-side maximum for the number of nodes a single query_graph call can return. +const MAX_QUERY_LIMIT: usize = 500; + fn default_limit() -> usize { 10 } +fn default_query_limit() -> usize { + 50 +} + fn default_text_search() -> bool { true } @@ -92,6 +101,54 @@ pub struct ContextToolInput { pub regex: bool, } +/// Input parameters for the query_graph tool +#[derive(Debug, Deserialize, JsonSchema)] +pub struct QueryGraphInput { + /// Relationship pattern to query. One of: + /// callers_of, callees_of, imports_of, importers_of, + /// inheritors_of, children_of, tests_for, file_summary + pub pattern: String, + + /// Symbol name or file path (for file_summary) to query. + /// Resolved with the same substring-match fallback as analyze_impact. + pub target: String, + + /// Restrict results to a specific repository ID. + pub repository_id: Option, + + /// Maximum number of unique nodes to return (default: 50, server cap: 500). + #[serde(default = "default_query_limit")] + pub limit: usize, +} + +/// A single deduplicated graph node returned by query_graph +#[derive(Debug, Serialize)] +pub struct GraphQueryNode { + /// The symbol name (caller or callee depending on pattern) + pub symbol: String, + /// File path where the reference occurs + pub file_path: String, + /// Line number where the reference occurs + pub line: u32, + /// The kind of relationship (e.g. "call", "import", "inheritance") + pub reference_kind: String, + /// Repository the node belongs to + pub repository_id: String, +} + +/// Result returned by the query_graph tool +#[derive(Debug, Serialize)] +pub struct GraphQueryResult { + /// The pattern that was queried + pub pattern: String, + /// The target symbol or file that was queried + pub target: String, + /// Deduplicated nodes matching the query + pub nodes: Vec, + /// Total number of nodes returned (after deduplication) + pub total: usize, +} + // ── MCP Server ─────────────────────────────────────────────────────────────── /// MCP Server that exposes codesearch functionality @@ -215,6 +272,199 @@ impl CodesearchMcpServer { Ok(CallToolResult::success(vec![Content::text(json)])) } + + /// Query the call graph using an intention-named relationship pattern. + /// Returns deduplicated graph nodes for exactly the relationship type requested, + /// avoiding the noise of receiving all relationship kinds at once. + /// + /// Supported patterns: + /// • callers_of — who calls this symbol + /// • callees_of — what this symbol calls + /// • imports_of — what this symbol imports (Import edges only) + /// • importers_of — who imports this symbol (Import edges only) + /// • inheritors_of — who inherits from / implements this symbol + /// • children_of — what this symbol inherits from / implements + /// • tests_for — test functions or files that exercise this symbol + /// • file_summary — all symbols referenced within a file + /// + /// Requires the repository to have been indexed with call-graph support. + #[tool(name = "query_graph")] + async fn query_graph( + &self, + params: Parameters, + ) -> Result { + let input = params.0; + let limit = input.limit.min(MAX_QUERY_LIMIT); + + let use_case = self.container.call_graph_use_case(); + + let mut base_query = CallGraphQuery::new(); + if let Some(repo_id) = &input.repository_id { + base_query = base_query.with_repository(repo_id.clone()); + } + base_query = base_query.with_limit(limit as u32); + + // Each arm returns (references, use_caller). + // use_caller=true → node.symbol = caller_symbol (who performs the action) + // use_caller=false → node.symbol = callee_symbol (what is acted upon) + let (references, use_caller) = match input.pattern.as_str() { + "callers_of" => { + let refs = use_case + .find_callers(&input.target, &base_query) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + (refs, true) + } + "callees_of" => { + let refs = use_case + .find_callees(&input.target, &base_query) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + (refs, false) + } + "imports_of" => { + let q = base_query.with_reference_kind("import"); + let refs = use_case + .find_callees(&input.target, &q) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + (refs, false) + } + "importers_of" => { + let q = base_query.with_reference_kind("import"); + let refs = use_case + .find_callers(&input.target, &q) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + (refs, true) + } + "inheritors_of" => { + let q_inh = base_query.clone().with_reference_kind("inheritance"); + let q_imp = base_query.clone().with_reference_kind("implementation"); + let mut refs = use_case + .find_callers(&input.target, &q_inh) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + let mut refs2 = use_case + .find_callers(&input.target, &q_imp) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + refs.append(&mut refs2); + (refs, true) + } + "children_of" => { + let q_inh = base_query.clone().with_reference_kind("inheritance"); + let q_imp = base_query.clone().with_reference_kind("implementation"); + let mut refs = use_case + .find_callees(&input.target, &q_inh) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + let mut refs2 = use_case + .find_callees(&input.target, &q_imp) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + refs.append(&mut refs2); + (refs, false) + } + "tests_for" => { + let refs = use_case + .find_callers(&input.target, &base_query) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + let filtered: Vec<_> = refs + .into_iter() + .filter(|r| { + let sym = r.caller_symbol().unwrap_or("").to_lowercase(); + let file = r.reference_file_path().to_lowercase(); + sym.starts_with("test_") + || sym.ends_with("_test") + || sym.ends_with("_spec") + || file.contains("test") + || file.contains("spec") + }) + .collect(); + (filtered, true) + } + "file_summary" => { + let refs = use_case + .find_by_file(&input.target, &base_query) + .await + .map_err(|e| { + McpError::internal_error(format!("query_graph failed: {}", e), None) + })?; + (refs, false) + } + unknown => { + return Err(McpError::internal_error( + format!( + "Unknown pattern '{}'. Supported patterns: callers_of, callees_of, \ + imports_of, importers_of, inheritors_of, children_of, tests_for, \ + file_summary", + unknown + ), + None, + )); + } + }; + + // Deduplicate by symbol name, keeping the first reference site per unique symbol. + let mut seen: HashSet = HashSet::new(); + let nodes: Vec = references + .into_iter() + .filter_map(|r| { + let symbol = if use_caller { + r.caller_symbol() + .unwrap_or_else(|| r.caller_file_path()) + .to_string() + } else { + r.callee_symbol().to_string() + }; + if symbol.is_empty() || !seen.insert(symbol.clone()) { + return None; + } + Some(GraphQueryNode { + symbol, + file_path: r.reference_file_path().to_string(), + line: r.reference_line(), + reference_kind: r.reference_kind().as_str().to_string(), + repository_id: r.repository_id().to_string(), + }) + }) + .take(limit) + .collect(); + + let total = nodes.len(); + let result = GraphQueryResult { + pattern: input.pattern, + target: input.target, + nodes, + total, + }; + + let json = serde_json::to_string_pretty(&result).map_err(|e| { + McpError::internal_error(format!("Failed to serialize result: {}", e), None) + })?; + + Ok(CallToolResult::success(vec![Content::text(json)])) + } } #[tool_handler] @@ -229,7 +479,9 @@ impl ServerHandler for CodesearchMcpServer { • search_code — find code by natural language description (set text_search=false \ to disable keyword+semantic fusion)\n\ • analyze_impact — blast-radius analysis: what breaks if symbol X changes?\n\ - • get_symbol_context — 360° view of a symbol's callers and callees" + • get_symbol_context — 360° view of a symbol's callers and callees\n\ + • query_graph — precise relationship queries: callers_of, callees_of, \ + imports_of, importers_of, inheritors_of, children_of, tests_for, file_summary" .into(), ), } From 71312b9a0d57ebf18e718e5553ca0138b44947a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 21:36:25 +0000 Subject: [PATCH 2/3] feat(mcp): remove server-side cap from query_graph limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make limit an Option: omit it to receive all results, or set it to bound the response. Removes the MAX_QUERY_LIMIT=500 cap and the default_query_limit=50 default — the caller decides what it needs. https://claude.ai/code/session_01GoKejqoqkgxiug48Hp5LE8 --- src/connector/adapter/mcp/server.rs | 63 +++++++++++++---------------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/src/connector/adapter/mcp/server.rs b/src/connector/adapter/mcp/server.rs index afe3b67..51f05d9 100644 --- a/src/connector/adapter/mcp/server.rs +++ b/src/connector/adapter/mcp/server.rs @@ -23,17 +23,10 @@ use super::tools::SearchResultOutput; /// Server-side maximum for the number of results a single search can return. const MAX_LIMIT: usize = 100; -/// Server-side maximum for the number of nodes a single query_graph call can return. -const MAX_QUERY_LIMIT: usize = 500; - fn default_limit() -> usize { 10 } -fn default_query_limit() -> usize { - 50 -} - fn default_text_search() -> bool { true } @@ -116,9 +109,8 @@ pub struct QueryGraphInput { /// Restrict results to a specific repository ID. pub repository_id: Option, - /// Maximum number of unique nodes to return (default: 50, server cap: 500). - #[serde(default = "default_query_limit")] - pub limit: usize, + /// Maximum number of unique nodes to return. Omit to return all results. + pub limit: Option, } /// A single deduplicated graph node returned by query_graph @@ -145,7 +137,7 @@ pub struct GraphQueryResult { pub target: String, /// Deduplicated nodes matching the query pub nodes: Vec, - /// Total number of nodes returned (after deduplication) + /// Total number of nodes returned (after deduplication; equals len(nodes)) pub total: usize, } @@ -294,7 +286,6 @@ impl CodesearchMcpServer { params: Parameters, ) -> Result { let input = params.0; - let limit = input.limit.min(MAX_QUERY_LIMIT); let use_case = self.container.call_graph_use_case(); @@ -302,7 +293,9 @@ impl CodesearchMcpServer { if let Some(repo_id) = &input.repository_id { base_query = base_query.with_repository(repo_id.clone()); } - base_query = base_query.with_limit(limit as u32); + if let Some(limit) = input.limit { + base_query = base_query.with_limit(limit as u32); + } // Each arm returns (references, use_caller). // use_caller=true → node.symbol = caller_symbol (who performs the action) @@ -427,29 +420,29 @@ impl CodesearchMcpServer { // Deduplicate by symbol name, keeping the first reference site per unique symbol. let mut seen: HashSet = HashSet::new(); - let nodes: Vec = references - .into_iter() - .filter_map(|r| { - let symbol = if use_caller { - r.caller_symbol() - .unwrap_or_else(|| r.caller_file_path()) - .to_string() - } else { - r.callee_symbol().to_string() - }; - if symbol.is_empty() || !seen.insert(symbol.clone()) { - return None; - } - Some(GraphQueryNode { - symbol, - file_path: r.reference_file_path().to_string(), - line: r.reference_line(), - reference_kind: r.reference_kind().as_str().to_string(), - repository_id: r.repository_id().to_string(), - }) + let deduped = references.into_iter().filter_map(|r| { + let symbol = if use_caller { + r.caller_symbol() + .unwrap_or_else(|| r.caller_file_path()) + .to_string() + } else { + r.callee_symbol().to_string() + }; + if symbol.is_empty() || !seen.insert(symbol.clone()) { + return None; + } + Some(GraphQueryNode { + symbol, + file_path: r.reference_file_path().to_string(), + line: r.reference_line(), + reference_kind: r.reference_kind().as_str().to_string(), + repository_id: r.repository_id().to_string(), }) - .take(limit) - .collect(); + }); + let nodes: Vec = match input.limit { + Some(n) => deduped.take(n).collect(), + None => deduped.collect(), + }; let total = nodes.len(); let result = GraphQueryResult { From f586761e84ee51260e1d515d73dd46210bb1055b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 21:43:32 +0000 Subject: [PATCH 3/3] fix(mcp): address four code-review findings in query_graph - Replace QueryGraphInput::pattern: String with a typed QueryPattern enum (derive Deserialize/Serialize/JsonSchema, serde rename_all = snake_case) so invalid patterns are rejected at deserialization instead of at runtime; remove the unknown catch-all arm from the match. - Fix tests_for path heuristic: use std::path::Path to inspect individual components (exact folder names "test"/"tests"/"spec"/"specs") and the file stem instead of a raw substring match, avoiding false positives like "contest.rs" or "inspect.rs". - Drop the caller_file_path() fallback in the dedup iterator: when use_caller is true, entries with no caller_symbol are now filtered out (return None via ?) so GraphQueryNode.symbol always holds a real symbol, never a file path. - Halve the per-query limit for inheritors_of and children_of: each of the two sub-queries (inheritance + implementation) now receives (limit+1)/2 rows so the combined pre-dedup result stays within the requested bound. https://claude.ai/code/session_01GoKejqoqkgxiug48Hp5LE8 --- src/connector/adapter/mcp/server.rs | 127 ++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/src/connector/adapter/mcp/server.rs b/src/connector/adapter/mcp/server.rs index 51f05d9..9609cc6 100644 --- a/src/connector/adapter/mcp/server.rs +++ b/src/connector/adapter/mcp/server.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::path::Path; use std::sync::Arc; use rmcp::handler::server::tool::ToolRouter; @@ -94,13 +95,25 @@ pub struct ContextToolInput { pub regex: bool, } +/// Relationship pattern for the query_graph tool. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryPattern { + CallersOf, + CalleesOf, + ImportsOf, + ImportersOf, + InheritorsOf, + ChildrenOf, + TestsFor, + FileSummary, +} + /// Input parameters for the query_graph tool #[derive(Debug, Deserialize, JsonSchema)] pub struct QueryGraphInput { - /// Relationship pattern to query. One of: - /// callers_of, callees_of, imports_of, importers_of, - /// inheritors_of, children_of, tests_for, file_summary - pub pattern: String, + /// Relationship pattern to query. + pub pattern: QueryPattern, /// Symbol name or file path (for file_summary) to query. /// Resolved with the same substring-match fallback as analyze_impact. @@ -132,7 +145,7 @@ pub struct GraphQueryNode { #[derive(Debug, Serialize)] pub struct GraphQueryResult { /// The pattern that was queried - pub pattern: String, + pub pattern: QueryPattern, /// The target symbol or file that was queried pub target: String, /// Deduplicated nodes matching the query @@ -300,8 +313,8 @@ impl CodesearchMcpServer { // Each arm returns (references, use_caller). // use_caller=true → node.symbol = caller_symbol (who performs the action) // use_caller=false → node.symbol = callee_symbol (what is acted upon) - let (references, use_caller) = match input.pattern.as_str() { - "callers_of" => { + let (references, use_caller) = match input.pattern { + QueryPattern::CallersOf => { let refs = use_case .find_callers(&input.target, &base_query) .await @@ -310,7 +323,7 @@ impl CodesearchMcpServer { })?; (refs, true) } - "callees_of" => { + QueryPattern::CalleesOf => { let refs = use_case .find_callees(&input.target, &base_query) .await @@ -319,7 +332,7 @@ impl CodesearchMcpServer { })?; (refs, false) } - "imports_of" => { + QueryPattern::ImportsOf => { let q = base_query.with_reference_kind("import"); let refs = use_case .find_callees(&input.target, &q) @@ -329,7 +342,7 @@ impl CodesearchMcpServer { })?; (refs, false) } - "importers_of" => { + QueryPattern::ImportersOf => { let q = base_query.with_reference_kind("import"); let refs = use_case .find_callers(&input.target, &q) @@ -339,9 +352,24 @@ impl CodesearchMcpServer { })?; (refs, true) } - "inheritors_of" => { - let q_inh = base_query.clone().with_reference_kind("inheritance"); - let q_imp = base_query.clone().with_reference_kind("implementation"); + QueryPattern::InheritorsOf => { + // Halve the per-query limit so the combined result stays within the + // requested bound before deduplication. + let per_limit = input.limit.map(|n| ((n + 1) / 2) as u32); + let q_inh = { + let q = base_query.clone().with_reference_kind("inheritance"); + match per_limit { + Some(pl) => q.with_limit(pl), + None => q, + } + }; + let q_imp = { + let q = base_query.clone().with_reference_kind("implementation"); + match per_limit { + Some(pl) => q.with_limit(pl), + None => q, + } + }; let mut refs = use_case .find_callers(&input.target, &q_inh) .await @@ -357,9 +385,22 @@ impl CodesearchMcpServer { refs.append(&mut refs2); (refs, true) } - "children_of" => { - let q_inh = base_query.clone().with_reference_kind("inheritance"); - let q_imp = base_query.clone().with_reference_kind("implementation"); + QueryPattern::ChildrenOf => { + let per_limit = input.limit.map(|n| ((n + 1) / 2) as u32); + let q_inh = { + let q = base_query.clone().with_reference_kind("inheritance"); + match per_limit { + Some(pl) => q.with_limit(pl), + None => q, + } + }; + let q_imp = { + let q = base_query.clone().with_reference_kind("implementation"); + match per_limit { + Some(pl) => q.with_limit(pl), + None => q, + } + }; let mut refs = use_case .find_callees(&input.target, &q_inh) .await @@ -375,7 +416,7 @@ impl CodesearchMcpServer { refs.append(&mut refs2); (refs, false) } - "tests_for" => { + QueryPattern::TestsFor => { let refs = use_case .find_callers(&input.target, &base_query) .await @@ -385,18 +426,43 @@ impl CodesearchMcpServer { let filtered: Vec<_> = refs .into_iter() .filter(|r| { + // Symbol-name heuristics (language-agnostic conventions). let sym = r.caller_symbol().unwrap_or("").to_lowercase(); - let file = r.reference_file_path().to_lowercase(); - sym.starts_with("test_") + if sym.starts_with("test_") || sym.ends_with("_test") || sym.ends_with("_spec") - || file.contains("test") - || file.contains("spec") + { + return true; + } + // Path heuristics: inspect components and file stem rather than + // doing a raw substring match to avoid false positives like + // "contest.rs" or "inspect.rs". + let path = Path::new(r.reference_file_path()); + let test_dir = path.components().any(|c| { + if let std::path::Component::Normal(s) = c { + let s = s.to_string_lossy().to_lowercase(); + matches!(s.as_str(), "test" | "tests" | "spec" | "specs") + } else { + false + } + }); + if test_dir { + return true; + } + path.file_stem() + .map(|s| { + let s = s.to_string_lossy().to_lowercase(); + s == "test" + || s.starts_with("test_") + || s.ends_with("_test") + || s.ends_with("_spec") + }) + .unwrap_or(false) }) .collect(); (filtered, true) } - "file_summary" => { + QueryPattern::FileSummary => { let refs = use_case .find_by_file(&input.target, &base_query) .await @@ -405,26 +471,15 @@ impl CodesearchMcpServer { })?; (refs, false) } - unknown => { - return Err(McpError::internal_error( - format!( - "Unknown pattern '{}'. Supported patterns: callers_of, callees_of, \ - imports_of, importers_of, inheritors_of, children_of, tests_for, \ - file_summary", - unknown - ), - None, - )); - } }; // Deduplicate by symbol name, keeping the first reference site per unique symbol. + // When use_caller is true, entries without a caller_symbol are dropped — a file + // path is not a valid symbol and must not appear in GraphQueryNode.symbol. let mut seen: HashSet = HashSet::new(); let deduped = references.into_iter().filter_map(|r| { let symbol = if use_caller { - r.caller_symbol() - .unwrap_or_else(|| r.caller_file_path()) - .to_string() + r.caller_symbol()?.to_string() } else { r.callee_symbol().to_string() };