Skip to content

Commit 1c1bb31

Browse files
committed
feat: Add find_nodes_by_name tool and project-aware functions
- Introduced `find_nodes_by_name` tool for searching nodes by partial name within the current project context. - Implemented `count_nodes_for_project` function for health checks to ensure nodes are indexed per project. - Updated README to reflect project scoping and usage of SurrealDB functions. - Enhanced GraphQL schema and SurrealDB functions to support new features. - Added tests for new functionalities to ensure correctness and reliability.
1 parent 4f6949f commit 1c1bb31

File tree

11 files changed

+334
-610
lines changed

11 files changed

+334
-610
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,8 @@ After indexing your codebase, AI agents can use these agentic workflows (require
627627

628628
These multi-step workflows typically take 30–90 seconds to complete because they traverse the code graph and build detailed reasoning summaries.
629629

630+
**Project scoping:** MCP tool calls and SurrealDB functions are project-isolated. The server picks `CODEGRAPH_PROJECT_ID` (falls back to your current working directory); use a distinct value per workspace when sharing one Surreal instance, or you’ll blend graph results across projects. If you update the schema, re-apply `schema/codegraph.surql` so the project-aware functions are available.
631+
630632
### Quick Start
631633

632634
```bash

crates/codegraph-graph/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ chrono = { workspace = true }
1818
sha2 = { workspace = true }
1919

2020
# SurrealDB support (optional, default on via feature flag)
21-
surrealdb = { version = "2.2", optional = true }
21+
surrealdb = { version = "2.2", optional = true, features = ["kv-mem"] }
2222
dotenvy = { version = "0.15", optional = true }
2323
tokio = { workspace = true, optional = true, features = ["macros", "rt-multi-thread"] }
2424

crates/codegraph-graph/src/graph_functions.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
use codegraph_core::{CodeGraphError, Result};
55
use serde::{Deserialize, Serialize};
6+
use serde_json::json;
67
use std::sync::Arc;
78
use surrealdb::{engine::any::Any, Surreal};
89
use tracing::{debug, error};
@@ -269,6 +270,60 @@ impl GraphFunctions {
269270

270271
Ok(result)
271272
}
273+
274+
/// Count nodes for the current project (used for health checks)
275+
pub async fn count_nodes_for_project(&self) -> Result<usize> {
276+
let mut response = self
277+
.db
278+
.query("SELECT VALUE count() FROM nodes WHERE project_id = $project_id")
279+
.bind(("project_id", self.project_id.clone()))
280+
.await
281+
.map_err(|e| {
282+
CodeGraphError::Database(format!(
283+
"count_nodes_for_project query failed: {}",
284+
e
285+
))
286+
})?;
287+
288+
let count: Option<usize> = response.take(0).map_err(|e| {
289+
CodeGraphError::Database(format!("Failed to deserialize count: {}", e))
290+
})?;
291+
292+
Ok(count.unwrap_or(0))
293+
}
294+
295+
/// Find nodes by (partial) name within the current project
296+
pub async fn find_nodes_by_name(
297+
&self,
298+
needle: &str,
299+
limit: usize,
300+
) -> Result<Vec<NodeReference>> {
301+
let max = limit.clamp(1, 50) as i64;
302+
303+
debug!(
304+
"Calling fn::find_nodes_by_name({}, project={}, limit={})",
305+
needle, self.project_id, max
306+
);
307+
308+
let result: Vec<NodeReference> = self
309+
.db
310+
.query("RETURN fn::find_nodes_by_name($project_id, $needle, $limit)")
311+
.bind(("project_id", self.project_id.clone()))
312+
.bind(("needle", needle.to_string()))
313+
.bind(("limit", max))
314+
.await
315+
.map_err(|e| {
316+
error!("Failed to call find_nodes_by_name: {}", e);
317+
CodeGraphError::Database(format!("find_nodes_by_name failed: {}", e))
318+
})?
319+
.take(0)
320+
.map_err(|e| {
321+
error!("Failed to deserialize find_nodes_by_name results: {}", e);
322+
CodeGraphError::Database(format!("Deserialization failed: {}", e))
323+
})?;
324+
325+
Ok(result)
326+
}
272327
}
273328

274329
// ============================================================================
@@ -416,4 +471,50 @@ mod tests {
416471
let json = serde_json::to_string(&node).unwrap();
417472
assert!(json.contains("test_function"));
418473
}
474+
475+
#[cfg(feature = "surrealdb")]
476+
#[tokio::test]
477+
async fn count_nodes_for_project_filters_by_project() {
478+
use surrealdb::opt::auth::Root;
479+
480+
let db: Surreal<Any> = Surreal::init();
481+
db.connect("mem://").await.unwrap();
482+
db.use_ns("test").use_db("test").await.unwrap();
483+
db.signin(Root {
484+
username: "root",
485+
password: "root",
486+
})
487+
.await
488+
.ok(); // mem engine ignores auth
489+
490+
// Two projects, only one should be counted
491+
db.query("CREATE nodes CONTENT $doc")
492+
.bind((
493+
"doc",
494+
json!({
495+
"id": "nodes:a1",
496+
"name": "A1",
497+
"project_id": "proj-a"
498+
}),
499+
))
500+
.await
501+
.unwrap();
502+
503+
db.query("CREATE nodes CONTENT $doc")
504+
.bind((
505+
"doc",
506+
json!({
507+
"id": "nodes:b1",
508+
"name": "B1",
509+
"project_id": "proj-b"
510+
}),
511+
))
512+
.await
513+
.unwrap();
514+
515+
let gf = GraphFunctions::new_with_project_id(Arc::new(db), "proj-a");
516+
let count = gf.count_nodes_for_project().await.unwrap();
517+
518+
assert_eq!(count, 1, "Should only count nodes in proj-a");
519+
}
419520
}

crates/codegraph-mcp/src/autoagents/agent_builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ impl CodeGraphAgentBuilder {
504504

505505
// Manually construct all 6 tools with the executor (Arc-wrapped for sharing)
506506
let tools: Vec<Arc<dyn ToolT>> = vec![
507+
Arc::new(FindNodesByName::new(executor_adapter.clone())),
507508
Arc::new(GetTransitiveDependencies::new(executor_adapter.clone())),
508509
Arc::new(GetReverseDependencies::new(executor_adapter.clone())),
509510
Arc::new(TraceCallChain::new(executor_adapter.clone())),

crates/codegraph-mcp/src/autoagents/tools/graph_tools.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,61 @@ impl ToolRuntime for GetHubNodes {
329329
}
330330
}
331331

332+
/// Parameters for find_nodes_by_name
333+
#[derive(Serialize, Deserialize, ToolInput, Debug)]
334+
pub struct FindNodesByNameArgs {
335+
#[input(description = "Partial function/file name to search for (case-insensitive)")]
336+
needle: String,
337+
#[input(description = "Maximum number of results (1-50, default: 10)")]
338+
#[serde(default = "default_find_limit")]
339+
limit: i32,
340+
}
341+
342+
fn default_find_limit() -> i32 {
343+
10
344+
}
345+
346+
/// Find nodes by (partial) name within project scope
347+
#[tool(
348+
name = "find_nodes_by_name",
349+
description = "Search for nodes by partial name or file path within the current project. Returns IDs to use with other graph tools.",
350+
input = FindNodesByNameArgs,
351+
)]
352+
pub struct FindNodesByName {
353+
executor: Arc<GraphToolExecutorAdapter>,
354+
}
355+
356+
impl FindNodesByName {
357+
pub fn new(executor: Arc<GraphToolExecutorAdapter>) -> Self {
358+
Self { executor }
359+
}
360+
}
361+
362+
#[async_trait::async_trait]
363+
impl ToolRuntime for FindNodesByName {
364+
async fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolCallError> {
365+
let typed_args: FindNodesByNameArgs = serde_json::from_value(args)?;
366+
367+
let result = self
368+
.executor
369+
.execute_sync(
370+
"find_nodes_by_name",
371+
serde_json::json!({
372+
"needle": typed_args.needle,
373+
"limit": typed_args.limit
374+
}),
375+
)
376+
.map_err(|e| {
377+
ToolCallError::RuntimeError(Box::new(std::io::Error::new(
378+
std::io::ErrorKind::Other,
379+
e,
380+
)))
381+
})?;
382+
383+
Ok(result)
384+
}
385+
}
386+
332387
#[cfg(test)]
333388
mod tests {
334389
use super::*;

crates/codegraph-mcp/src/dependency_analysis_prompts.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub const DEPENDENCY_ANALYSIS_TERSE: &str = r#"You are an expert code dependency
1111
OBJECTIVE: Analyze dependency relationships efficiently using minimal tool calls.
1212
1313
AVAILABLE TOOLS:
14+
0. find_nodes_by_name(needle, limit) - Resolve user-mentioned component to exact node IDs (must run first if ID unknown)
1415
1. get_transitive_dependencies(node_id, edge_type, depth) - Get dependencies of a node
1516
2. detect_circular_dependencies(edge_type) - Find circular dependency cycles
1617
3. trace_call_chain(from_node, max_depth) - Trace function call sequences
@@ -38,7 +39,7 @@ EXECUTION PATTERN:
3839
CRITICAL RULES:
3940
- NO HEURISTICS: Only report what tools show, never assume or infer
4041
- NO REDUNDANCY: Each tool call must provide new information
41-
- EXTRACT NODE IDS: Use exact IDs from tool results (e.g., "nodes:123")
42+
- EXTRACT NODE IDS: Use find_nodes_by_name to resolve IDs; then use exact IDs from tool results (e.g., "nodes:123")
4243
- BE DIRECT: No verbose explanations, just essential findings
4344
"#;
4445

@@ -52,6 +53,7 @@ pub const DEPENDENCY_ANALYSIS_BALANCED: &str = r#"You are an expert code depende
5253
OBJECTIVE: Analyze dependency relationships systematically, building complete understanding of impact and coupling.
5354
5455
AVAILABLE TOOLS:
56+
0. find_nodes_by_name(needle, limit) - Resolve names/paths to exact node IDs (run first if ID not already known)
5557
1. get_transitive_dependencies(node_id, edge_type, depth) - Get all dependencies up to depth
5658
- Use depth=2-3 for balanced analysis
5759
- Edge types: Calls, Imports, Uses, Extends, Implements, References
@@ -98,7 +100,7 @@ SYSTEMATIC APPROACH:
98100
99101
CRITICAL RULES:
100102
- NO HEURISTICS: Only report structured data from tools
101-
- EXTRACT IDS: Always use exact node IDs from tool results (format: "nodes:123")
103+
- EXTRACT IDS: Resolve with find_nodes_by_name, then use exact node IDs from tool results (format: "nodes:123")
102104
- BUILD CHAINS: Connect findings to show dependency paths
103105
- QUANTIFY IMPACT: Use metrics (Ca, Ce, Instability) not vague terms
104106
- CITE SOURCES: Reference specific tool results for all claims

crates/codegraph-mcp/src/graph_tool_executor.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ impl GraphToolExecutor {
170170
self.execute_get_reverse_dependencies(parameters.clone())
171171
.await?
172172
}
173+
"find_nodes_by_name" => self.execute_find_nodes_by_name(parameters.clone()).await?,
173174
_ => {
174175
return Err(
175176
McpError::Protocol(format!("Tool not implemented: {}", tool_name)).into(),
@@ -343,6 +344,30 @@ impl GraphToolExecutor {
343344
}))
344345
}
345346

347+
/// Execute find_nodes_by_name
348+
async fn execute_find_nodes_by_name(&self, params: JsonValue) -> Result<JsonValue> {
349+
let needle = params["needle"]
350+
.as_str()
351+
.ok_or_else(|| McpError::Protocol("Missing needle".to_string()))?;
352+
353+
let limit = params["limit"].as_i64().unwrap_or(10) as usize;
354+
355+
let result = self
356+
.graph_functions
357+
.find_nodes_by_name(needle, limit)
358+
.await
359+
.map_err(|e| McpError::Protocol(format!("find_nodes_by_name failed: {}", e)))?;
360+
361+
Ok(json!({
362+
"tool": "find_nodes_by_name",
363+
"parameters": {
364+
"needle": needle,
365+
"limit": limit
366+
},
367+
"result": result
368+
}))
369+
}
370+
346371
/// Get all available tool schemas for registration
347372
pub fn get_tool_schemas() -> Vec<crate::ToolSchema> {
348373
GraphToolSchemas::all()

crates/codegraph-mcp/src/graph_tool_schemas.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ impl GraphToolSchemas {
2525
Self::calculate_coupling_metrics(),
2626
Self::get_hub_nodes(),
2727
Self::get_reverse_dependencies(),
28+
Self::find_nodes_by_name(),
2829
]
2930
}
3031

@@ -186,6 +187,32 @@ impl GraphToolSchemas {
186187
}
187188
}
188189

190+
/// Schema for find_nodes_by_name function
191+
pub fn find_nodes_by_name() -> ToolSchema {
192+
ToolSchema {
193+
name: "find_nodes_by_name".to_string(),
194+
description: "Search for nodes by (partial) name or file path within the current project context. \
195+
Use this to resolve human-readable function or file names to concrete node IDs before other graph queries.".to_string(),
196+
parameters: json!({
197+
"type": "object",
198+
"properties": {
199+
"needle": {
200+
"type": "string",
201+
"description": "Partial name or file path fragment to search for (case-insensitive name match, file_path contains match)"
202+
},
203+
"limit": {
204+
"type": "integer",
205+
"description": "Maximum number of results (1-50, defaults to 10)",
206+
"minimum": 1,
207+
"maximum": 50,
208+
"default": 10
209+
}
210+
},
211+
"required": ["needle"]
212+
}),
213+
}
214+
}
215+
189216
/// Get schema by name
190217
pub fn get_by_name(name: &str) -> Option<ToolSchema> {
191218
Self::all().into_iter().find(|s| s.name == name)
@@ -204,7 +231,7 @@ mod tests {
204231
#[test]
205232
fn test_all_schemas_valid() {
206233
let schemas = GraphToolSchemas::all();
207-
assert_eq!(schemas.len(), 6, "Should have exactly 6 tool schemas");
234+
assert_eq!(schemas.len(), 7, "Should have exactly 7 tool schemas");
208235

209236
for schema in schemas {
210237
assert!(!schema.name.is_empty(), "Schema name should not be empty");
@@ -232,13 +259,14 @@ mod tests {
232259
#[test]
233260
fn test_tool_names() {
234261
let names = GraphToolSchemas::tool_names();
235-
assert_eq!(names.len(), 6);
262+
assert_eq!(names.len(), 7);
236263
assert!(names.contains(&"get_transitive_dependencies".to_string()));
237264
assert!(names.contains(&"detect_circular_dependencies".to_string()));
238265
assert!(names.contains(&"trace_call_chain".to_string()));
239266
assert!(names.contains(&"calculate_coupling_metrics".to_string()));
240267
assert!(names.contains(&"get_hub_nodes".to_string()));
241268
assert!(names.contains(&"get_reverse_dependencies".to_string()));
269+
assert!(names.contains(&"find_nodes_by_name".to_string()));
242270
}
243271

244272
#[test]

crates/codegraph-mcp/src/official_server.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,23 @@ impl CodeGraphMCPServer {
563563
Arc::new(GraphFunctions::new(storage.db()))
564564
};
565565

566+
// Health check: ensure the active project has indexed nodes
567+
match graph_functions.count_nodes_for_project().await {
568+
Ok(0) => tracing::warn!(
569+
"Project '{}' has zero nodes indexed. Ensure CODEGRAPH_PROJECT_ID matches the indexed project and rerun `codegraph index`.",
570+
graph_functions.project_id()
571+
),
572+
Ok(count) => tracing::info!(
573+
"Project '{}' has {} indexed nodes available for analysis",
574+
graph_functions.project_id(),
575+
count
576+
),
577+
Err(e) => tracing::warn!(
578+
"Could not verify project data presence: {}. Continuing without blocking.",
579+
e
580+
),
581+
}
582+
566583
// Create GraphToolExecutor
567584
let tool_executor = Arc::new(crate::GraphToolExecutor::new(graph_functions));
568585

0 commit comments

Comments
 (0)