1use crate::types::{
2 FileListFormat, FileListOptions, FileListReport, Node, NodeEdge, NodeKind, SearchOptions,
3};
4use crate::{find_nearest_codegraph_root, CodeGraph};
5use anyhow::{anyhow, Context, Result};
6use serde_json::{json, Value};
7use std::io::{self, BufRead, Write};
8use std::path::PathBuf;
9
10const PROTOCOL_VERSION: &str = "2024-11-05";
11const SERVER_INSTRUCTIONS: &str = "# Codegraph — code intelligence over an indexed knowledge graph\n\nStart with codegraph_status to check index health. Use codegraph_files, codegraph_search, codegraph_context, codegraph_callers/codegraph_callees, codegraph_impact, codegraph_node, and codegraph_explore for read-only exploration. Treat results as navigation context, not correctness proof; final validation still comes from the target repo's tests, type checks, linters, or build commands. Do not initialize or reindex a project unless the user explicitly asks for that workspace-changing action.\n\nCross-project policy: each tool call may pass projectPath to query that initialized CodeGraph project directly. The server does not maintain a cross-project result cache; switching projectPath changes only that call.";
12
13pub struct MCPServer {
14 project_path: Option<PathBuf>,
15}
16
17impl MCPServer {
18 pub fn new(project_path: Option<PathBuf>) -> Self {
19 Self { project_path }
20 }
21
22 pub fn start(&mut self) -> Result<()> {
23 let stdin = io::stdin();
24 for line in stdin.lock().lines() {
25 let line = line?;
26 if line.trim().is_empty() {
27 continue;
28 }
29 let response = match serde_json::from_str::<Value>(&line) {
30 Ok(message) => self.handle_message(message),
31 Err(_) => Some(error_response(
32 Value::Null,
33 -32700,
34 "Parse error: invalid JSON",
35 )),
36 };
37 if let Some(response) = response {
38 println!("{}", serde_json::to_string(&response)?);
39 io::stdout().flush()?;
40 }
41 }
42 Ok(())
43 }
44
45 fn handle_message(&mut self, message: Value) -> Option<Value> {
46 let id = message.get("id").cloned();
47 let method = message
48 .get("method")
49 .and_then(Value::as_str)
50 .unwrap_or_default();
51 match method {
52 "initialize" => {
53 if let Some(path) = project_path_from_initialize(&message) {
54 self.project_path = Some(path);
55 }
56 id.map(|id| json!({
57 "jsonrpc": "2.0",
58 "id": id,
59 "result": {
60 "protocolVersion": PROTOCOL_VERSION,
61 "capabilities": { "tools": {} },
62 "serverInfo": { "name": "codegraph", "version": env!("CARGO_PKG_VERSION") },
63 "instructions": SERVER_INSTRUCTIONS,
64 }
65 }))
66 }
67 "initialized" => None,
68 "tools/list" => id.map(|id| {
69 json!({
70 "jsonrpc": "2.0",
71 "id": id,
72 "result": { "tools": tools() }
73 })
74 }),
75 "tools/call" => {
76 let Some(id) = id else { return None };
77 let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
78 let name = params
79 .get("name")
80 .and_then(Value::as_str)
81 .unwrap_or_default();
82 let args = params
83 .get("arguments")
84 .cloned()
85 .unwrap_or_else(|| json!({}));
86 match self.execute_tool(name, &args) {
87 Ok(result) => Some(json!({ "jsonrpc": "2.0", "id": id, "result": result })),
88 Err(err) => Some(error_response(
89 id,
90 -32603,
91 &format!("Tool execution failed: {err}"),
92 )),
93 }
94 }
95 "ping" => id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "result": {} })),
96 _ => id.map(|id| error_response(id, -32601, &format!("Method not found: {method}"))),
97 }
98 }
99
100 fn execute_tool(&self, name: &str, args: &Value) -> Result<Value> {
101 if !is_known_tool(name) {
102 return Err(anyhow!("Unknown tool: {name}"));
103 }
104 let cg = self.open_project(args)?;
105 match name {
106 "codegraph_search" => {
107 let query = required_str(args, "query")?;
108 let limit = clamp(
109 args.get("limit").and_then(Value::as_i64).unwrap_or(10),
110 1,
111 100,
112 );
113 let kind = optional_node_kind(args, "kind")?;
114 let results = cg.search_nodes(
115 query,
116 SearchOptions {
117 limit,
118 kind,
119 ..Default::default()
120 },
121 )?;
122 if results.is_empty() {
123 Ok(text_result(format!("No results found for \"{query}\"")))
124 } else {
125 let lines = results
126 .into_iter()
127 .map(|r| format_node(&r.node))
128 .collect::<Vec<_>>()
129 .join("\n");
130 Ok(text_result(lines))
131 }
132 }
133 "codegraph_context" => {
134 let task = required_str(args, "task")?;
135 let max_nodes = clamp(
136 args.get("maxNodes").and_then(Value::as_i64).unwrap_or(20),
137 1,
138 200,
139 );
140 let include_code = args
141 .get("includeCode")
142 .and_then(Value::as_bool)
143 .unwrap_or(true);
144 if args.get("format").and_then(Value::as_str) == Some("json") {
145 Ok(text_result(serde_json::to_string_pretty(
146 &cg.build_context_report(task, max_nodes, include_code)?,
147 )?))
148 } else {
149 Ok(text_result(cg.build_context(
150 task,
151 max_nodes,
152 include_code,
153 )?))
154 }
155 }
156 "codegraph_callers" => {
157 let symbol = required_str(args, "symbol")?;
158 let limit = clamp(
159 args.get("limit").and_then(Value::as_i64).unwrap_or(20),
160 1,
161 100,
162 ) as usize;
163 let depth = clamp(
164 args.get("depth").and_then(Value::as_i64).unwrap_or(2),
165 1,
166 10,
167 ) as usize;
168 let nodes = find_matching_nodes(&cg, symbol)?;
169 if nodes.is_empty() {
170 return Ok(text_result(format!(
171 "Symbol \"{symbol}\" not found in the codebase"
172 )));
173 }
174 let mut out = Vec::new();
175 for node in nodes {
176 out.extend(cg.get_callers(&node.id, depth)?);
177 }
178 Ok(text_result(format_node_edges(
179 &format!("Callers of {symbol}"),
180 &out,
181 limit,
182 )))
183 }
184 "codegraph_callees" => {
185 let symbol = required_str(args, "symbol")?;
186 let limit = clamp(
187 args.get("limit").and_then(Value::as_i64).unwrap_or(20),
188 1,
189 100,
190 ) as usize;
191 let depth = clamp(
192 args.get("depth").and_then(Value::as_i64).unwrap_or(2),
193 1,
194 10,
195 ) as usize;
196 let nodes = find_matching_nodes(&cg, symbol)?;
197 if nodes.is_empty() {
198 return Ok(text_result(format!(
199 "Symbol \"{symbol}\" not found in the codebase"
200 )));
201 }
202 let mut out = Vec::new();
203 for node in nodes {
204 out.extend(cg.get_callees(&node.id, depth)?);
205 }
206 Ok(text_result(format_node_edges(
207 &format!("Callees of {symbol}"),
208 &out,
209 limit,
210 )))
211 }
212 "codegraph_impact" => {
213 let symbol = required_str(args, "symbol")?;
214 let depth = clamp(
215 args.get("depth").and_then(Value::as_i64).unwrap_or(2),
216 1,
217 10,
218 ) as usize;
219 let limit = clamp(
220 args.get("limit").and_then(Value::as_i64).unwrap_or(50),
221 1,
222 200,
223 ) as usize;
224 let nodes = find_matching_nodes(&cg, symbol)?;
225 if nodes.is_empty() {
226 return Ok(text_result(format!(
227 "Symbol \"{symbol}\" not found in the codebase"
228 )));
229 }
230 let mut lines = vec![format!("## Impact: {symbol}")];
231 for node in nodes {
232 let impact = cg.get_impact_radius(&node.id, depth)?;
233 let mut impact_nodes = impact.nodes.into_values().collect::<Vec<_>>();
234 impact_nodes.sort_by(|a, b| {
235 a.file_path
236 .cmp(&b.file_path)
237 .then_with(|| a.start_line.cmp(&b.start_line))
238 .then_with(|| a.name.cmp(&b.name))
239 });
240 for n in impact_nodes.into_iter().take(limit) {
241 lines.push(format!("- {}", format_node(&n)));
242 }
243 }
244 Ok(text_result(lines.join("\n")))
245 }
246 "codegraph_paths" => {
247 let from = required_str(args, "from")?;
248 let to = required_str(args, "to")?;
249 let depth = clamp(
250 args.get("depth").and_then(Value::as_i64).unwrap_or(4),
251 1,
252 10,
253 ) as usize;
254 let limit = clamp(
255 args.get("limit").and_then(Value::as_i64).unwrap_or(5),
256 1,
257 50,
258 ) as usize;
259 let from_node = find_matching_nodes(&cg, from)?.into_iter().next();
260 let to_node = find_matching_nodes(&cg, to)?.into_iter().next();
261 let (Some(from_node), Some(to_node)) = (from_node, to_node) else {
262 return Ok(text_result(format!(
263 "Could not resolve path endpoints: {from} -> {to}"
264 )));
265 };
266 let paths = cg.find_paths(&from_node.id, &to_node.id, depth, limit)?;
267 Ok(text_result(format_paths(from, to, &paths)))
268 }
269 "codegraph_node" => {
270 let symbol = required_str(args, "symbol")?;
271 let include_code = args
272 .get("includeCode")
273 .and_then(Value::as_bool)
274 .unwrap_or(false);
275 let nodes = find_matching_nodes(&cg, symbol)?;
276 let Some(node) = nodes.first() else {
277 return Ok(text_result(format!(
278 "Symbol \"{symbol}\" not found in the codebase"
279 )));
280 };
281 let mut out = format_node(node);
282 if include_code {
283 if let Ok(code) = cg.read_node_source(node) {
284 out.push_str("\n\n```");
285 out.push_str(node.language.as_str());
286 out.push('\n');
287 out.push_str(&code);
288 out.push_str("\n```");
289 }
290 }
291 Ok(text_result(out))
292 }
293 "codegraph_explore" => {
294 let query = required_str(args, "query")?;
295 let max_files = clamp(
296 args.get("maxFiles").and_then(Value::as_i64).unwrap_or(12),
297 1,
298 20,
299 ) as usize;
300 let report = cg.build_explore_report(query, max_files)?;
301 Ok(text_result(format_explore_report(&report, 35_000)))
302 }
303 "codegraph_status" => {
304 let stats = cg.stats()?;
305 Ok(text_result(format!(
306 "**Files indexed:** {}\n**Nodes:** {}\n**Edges:** {}\n**Last indexed at:** {}\n**Stale files:** {}",
307 stats.file_count,
308 stats.node_count,
309 stats.edge_count,
310 format_optional_timestamp_ms(stats.last_indexed_at),
311 stats.stale_file_count
312 )))
313 }
314 "codegraph_files" => {
315 let format = args
316 .get("format")
317 .and_then(Value::as_str)
318 .unwrap_or("tree")
319 .parse::<FileListFormat>()
320 .map_err(|_| {
321 anyhow!("codegraph_files format must be grouped, flat, or tree")
322 })?;
323 let report = cg.list_files(FileListOptions {
324 format,
325 path_filter: args.get("path").and_then(Value::as_str).map(str::to_string),
326 pattern: args
327 .get("pattern")
328 .and_then(Value::as_str)
329 .map(str::to_string),
330 include_metadata: args
331 .get("includeMetadata")
332 .and_then(Value::as_bool)
333 .unwrap_or(false),
334 max_depth: args
335 .get("maxDepth")
336 .and_then(Value::as_i64)
337 .map(|depth| clamp(depth, 1, 20) as usize),
338 })?;
339 Ok(text_result(format_file_report(&report)))
340 }
341 "codegraph_affected" => {
342 let files = required_string_array(args, "files")?;
343 Ok(text_result(serde_json::to_string_pretty(
344 &cg.build_affected_report(&files)?,
345 )?))
346 }
347 _ => Err(anyhow!("Unknown tool: {name}")),
348 }
349 }
350
351 fn open_project(&self, args: &Value) -> Result<CodeGraph> {
352 if let Some(path) = args.get("projectPath").and_then(Value::as_str) {
353 return CodeGraph::open(path).with_context(|| {
354 format!(
355 "Unable to open projectPath `{path}`. Run `cgz init {path}` first or pass the path to an initialized project."
356 )
357 });
358 }
359 let start = self
360 .project_path
361 .clone()
362 .unwrap_or(std::env::current_dir()?);
363 let root = find_nearest_codegraph_root(&start)
364 .ok_or_else(|| {
365 anyhow!(
366 "CodeGraph is not initialized for `{}`. Run `cgz init --index {}` or pass projectPath for an initialized project.",
367 start.display(),
368 start.display()
369 )
370 })?;
371 CodeGraph::open(&root).with_context(|| {
372 format!(
373 "Unable to open initialized CodeGraph project `{}`. Re-run `cgz init --index {}` if the index is missing or corrupt.",
374 root.display(),
375 root.display()
376 )
377 })
378 }
379}
380
381fn tools() -> Value {
382 json!([
383 tool(
384 "codegraph_search",
385 "Quick symbol/file search. Use kind to narrow results when looking for a specific node type.",
386 json!({
387 "query": string_prop("Search text matched against symbol names, qualified names, signatures, and file paths."),
388 "kind": enum_prop("Optional node kind filter.", NODE_KIND_VALUES),
389 "limit": number_prop("Maximum results to return.", 10, 1, 100),
390 "projectPath": project_path_prop()
391 }),
392 vec!["query"]
393 ),
394 tool(
395 "codegraph_context",
396 "Build task-oriented context from matching symbols and files.",
397 json!({
398 "task": string_prop("Natural-language task, symbol, or file term to investigate."),
399 "maxNodes": number_prop("Maximum matched nodes to include.", 20, 1, 200),
400 "includeCode": bool_prop("Include source snippets for matched nodes.", true),
401 "format": enum_prop_with_default("Output format.", &["text", "json"], "text"),
402 "projectPath": project_path_prop()
403 }),
404 vec!["task"]
405 ),
406 tool(
407 "codegraph_callers",
408 "Find all functions/methods that call a specific symbol.",
409 json!({"symbol": string_prop("Symbol name to resolve."), "depth": number_prop("Traversal depth.", 2, 1, 10), "limit": number_prop("Maximum results.", 20, 1, 100), "projectPath": project_path_prop()}),
410 vec!["symbol"]
411 ),
412 tool(
413 "codegraph_callees",
414 "Find all functions/methods that a specific symbol calls.",
415 json!({"symbol": string_prop("Symbol name to resolve."), "depth": number_prop("Traversal depth.", 2, 1, 10), "limit": number_prop("Maximum results.", 20, 1, 100), "projectPath": project_path_prop()}),
416 vec!["symbol"]
417 ),
418 tool(
419 "codegraph_impact",
420 "Analyze the impact radius of changing a symbol.",
421 json!({"symbol": string_prop("Symbol name to resolve."), "depth": number_prop("Traversal depth.", 2, 1, 10), "limit": number_prop("Maximum impacted nodes.", 50, 1, 200), "projectPath": project_path_prop()}),
422 vec!["symbol"]
423 ),
424 tool(
425 "codegraph_paths",
426 "Find bounded dependency/call paths between two symbols.",
427 json!({"from": string_prop("Start symbol."), "to": string_prop("Target symbol."), "depth": number_prop("Maximum path depth.", 4, 1, 10), "limit": number_prop("Maximum paths.", 5, 1, 50), "projectPath": project_path_prop()}),
428 vec!["from", "to"]
429 ),
430 tool(
431 "codegraph_node",
432 "Get detailed information about a specific code symbol.",
433 json!({"symbol": string_prop("Symbol name to resolve."), "includeCode": bool_prop("Include source snippet.", false), "projectPath": project_path_prop()}),
434 vec!["symbol"]
435 ),
436 tool(
437 "codegraph_explore",
438 "Deep exploration tool for a topic. Returns grouped source sections, relationship map, additional relevant files, and truncation notices. Budget guidance: small projects usually need 1-2 calls; medium projects need a few targeted calls; large projects should use narrow symbol/file queries.",
439 json!({"query": string_prop("Topic, symbol, or file term to explore."), "maxFiles": number_prop("Maximum source files to include.", 12, 1, 20), "projectPath": project_path_prop()}),
440 vec!["query"]
441 ),
442 tool(
443 "codegraph_status",
444 "Get index health and staleness for the selected initialized project.",
445 json!({"projectPath": project_path_prop()}),
446 vec![]
447 ),
448 tool(
449 "codegraph_files",
450 "Get indexed project files.",
451 json!({
452 "path": string_prop("Optional path prefix filter."),
453 "pattern": string_prop("Optional glob-like pattern such as *.rs."),
454 "format": enum_prop_with_default("Output layout.", &["tree", "flat", "grouped"], "tree"),
455 "includeMetadata": bool_prop("Include file size and timestamps.", false),
456 "maxDepth": number_prop("Maximum file path depth.", 20, 1, 20),
457 "projectPath": project_path_prop()
458 }),
459 vec![]
460 ),
461 tool(
462 "codegraph_affected",
463 "Return affected test candidates for changed files.",
464 json!({"files": {"type":"array", "items": {"type":"string"}, "description": "Changed file paths relative to the project root."}, "projectPath": project_path_prop()}),
465 vec!["files"]
466 ),
467 ])
468}
469
470fn tool(name: &str, description: &str, properties: Value, required: Vec<&str>) -> Value {
471 json!({
472 "name": name,
473 "description": description,
474 "inputSchema": {
475 "type": "object",
476 "properties": properties,
477 "required": required,
478 }
479 })
480}
481
482const NODE_KIND_VALUES: &[&str] = &[
483 "file",
484 "module",
485 "class",
486 "struct",
487 "interface",
488 "trait",
489 "protocol",
490 "function",
491 "method",
492 "property",
493 "field",
494 "variable",
495 "constant",
496 "enum",
497 "enum_member",
498 "type_alias",
499 "namespace",
500 "parameter",
501 "import",
502 "export",
503 "route",
504 "component",
505];
506
507fn string_prop(description: &str) -> Value {
508 json!({"type": "string", "description": description})
509}
510
511fn bool_prop(description: &str, default: bool) -> Value {
512 json!({"type": "boolean", "description": description, "default": default})
513}
514
515fn number_prop(description: &str, default: i64, minimum: i64, maximum: i64) -> Value {
516 json!({
517 "type": "number",
518 "description": description,
519 "default": default,
520 "minimum": minimum,
521 "maximum": maximum
522 })
523}
524
525fn enum_prop(description: &str, values: &[&str]) -> Value {
526 json!({"type": "string", "description": description, "enum": values})
527}
528
529fn enum_prop_with_default(description: &str, values: &[&str], default: &str) -> Value {
530 json!({"type": "string", "description": description, "enum": values, "default": default})
531}
532
533fn project_path_prop() -> Value {
534 string_prop("Optional path to an initialized CodeGraph project. Applies only to this tool call; results are not cached across projectPath values.")
535}
536
537fn is_known_tool(name: &str) -> bool {
538 matches!(
539 name,
540 "codegraph_search"
541 | "codegraph_context"
542 | "codegraph_callers"
543 | "codegraph_callees"
544 | "codegraph_impact"
545 | "codegraph_paths"
546 | "codegraph_node"
547 | "codegraph_explore"
548 | "codegraph_status"
549 | "codegraph_files"
550 | "codegraph_affected"
551 )
552}
553
554fn project_path_from_initialize(message: &Value) -> Option<PathBuf> {
555 let params = message.get("params")?;
556 if let Some(uri) = params.get("rootUri").and_then(Value::as_str) {
557 return Some(file_uri_to_path(uri));
558 }
559 params
560 .get("workspaceFolders")
561 .and_then(Value::as_array)
562 .and_then(|folders| folders.first())
563 .and_then(|folder| folder.get("uri"))
564 .and_then(Value::as_str)
565 .map(file_uri_to_path)
566}
567
568fn file_uri_to_path(uri: &str) -> PathBuf {
569 let without_scheme = uri.strip_prefix("file://").unwrap_or(uri);
570 PathBuf::from(percent_decode(without_scheme))
571}
572
573fn percent_decode(input: &str) -> String {
574 let mut out = String::new();
575 let bytes = input.as_bytes();
576 let mut i = 0;
577 while i < bytes.len() {
578 if bytes[i] == b'%' && i + 2 < bytes.len() {
579 if let Ok(hex) = u8::from_str_radix(&input[i + 1..i + 3], 16) {
580 out.push(hex as char);
581 i += 3;
582 continue;
583 }
584 }
585 out.push(bytes[i] as char);
586 i += 1;
587 }
588 out
589}
590
591fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> {
592 args.get(key)
593 .and_then(Value::as_str)
594 .filter(|s| !s.is_empty())
595 .ok_or_else(|| anyhow!("{key} must be a non-empty string"))
596}
597
598fn required_string_array(args: &Value, key: &str) -> Result<Vec<String>> {
599 let values = args
600 .get(key)
601 .and_then(Value::as_array)
602 .ok_or_else(|| anyhow!("{key} must be an array of strings"))?;
603 let mut out = Vec::new();
604 for value in values {
605 let Some(item) = value.as_str().filter(|s| !s.is_empty()) else {
606 return Err(anyhow!("{key} must be an array of non-empty strings"));
607 };
608 out.push(item.to_string());
609 }
610 Ok(out)
611}
612
613fn optional_node_kind(args: &Value, key: &str) -> Result<Option<NodeKind>> {
614 let Some(kind) = args.get(key).and_then(Value::as_str) else {
615 return Ok(None);
616 };
617 if kind.is_empty() {
618 return Ok(None);
619 }
620 let node_kind = match kind {
621 "file" => NodeKind::File,
622 "module" => NodeKind::Module,
623 "class" => NodeKind::Class,
624 "struct" => NodeKind::Struct,
625 "interface" => NodeKind::Interface,
626 "trait" => NodeKind::Trait,
627 "protocol" => NodeKind::Protocol,
628 "function" => NodeKind::Function,
629 "method" => NodeKind::Method,
630 "property" => NodeKind::Property,
631 "field" => NodeKind::Field,
632 "variable" => NodeKind::Variable,
633 "constant" => NodeKind::Constant,
634 "enum" => NodeKind::Enum,
635 "enum_member" => NodeKind::EnumMember,
636 "type_alias" => NodeKind::TypeAlias,
637 "namespace" => NodeKind::Namespace,
638 "parameter" => NodeKind::Parameter,
639 "import" => NodeKind::Import,
640 "export" => NodeKind::Export,
641 "route" => NodeKind::Route,
642 "component" => NodeKind::Component,
643 _ => {
644 return Err(anyhow!(
645 "{key} must be one of: {}",
646 NODE_KIND_VALUES.join(", ")
647 ))
648 }
649 };
650 Ok(Some(node_kind))
651}
652
653fn clamp(value: i64, min: i64, max: i64) -> i64 {
654 value.max(min).min(max)
655}
656
657fn find_matching_nodes(cg: &CodeGraph, symbol: &str) -> Result<Vec<Node>> {
658 Ok(cg
659 .search_nodes(
660 symbol,
661 SearchOptions {
662 limit: 50,
663 ..Default::default()
664 },
665 )?
666 .into_iter()
667 .map(|r| r.node)
668 .collect())
669}
670
671fn format_node(node: &Node) -> String {
672 format!(
673 "{} {} {}:{}",
674 node.kind, node.name, node.file_path, node.start_line
675 )
676}
677
678fn format_node_edges(title: &str, edges: &[NodeEdge], limit: usize) -> String {
679 if edges.is_empty() {
680 return format!("No results found for {title}");
681 }
682 let mut lines = vec![format!("## {title}")];
683 for edge in edges.iter().take(limit) {
684 lines.push(format!(
685 "- depth {} {} via {}",
686 edge.depth,
687 format_node(&edge.node),
688 edge.edge.kind
689 ));
690 }
691 lines.join("\n")
692}
693
694fn format_paths(from: &str, to: &str, paths: &[crate::types::GraphPath]) -> String {
695 if paths.is_empty() {
696 return format!("No paths found from {from} to {to}");
697 }
698 let mut lines = vec![format!("## Paths: {from} -> {to}")];
699 for (idx, path) in paths.iter().enumerate() {
700 lines.push(format!("Path {}:", idx + 1));
701 lines.push(
702 path.nodes
703 .iter()
704 .map(format_node)
705 .collect::<Vec<_>>()
706 .join("\n -> "),
707 );
708 }
709 lines.join("\n")
710}
711
712fn format_file_report(report: &FileListReport) -> String {
713 if report.total_files == 0 {
714 return "No indexed files matched.".to_string();
715 }
716 match report.format.as_str() {
717 "flat" => report
718 .files
719 .iter()
720 .map(format_file_entry)
721 .collect::<Vec<_>>()
722 .join("\n"),
723 "grouped" => report
724 .groups
725 .iter()
726 .map(|group| {
727 let mut lines = vec![format!("{}: {}", group.language, group.count)];
728 for file in &group.files {
729 lines.push(format!(" {}", format_file_entry(file)));
730 }
731 lines.join("\n")
732 })
733 .collect::<Vec<_>>()
734 .join("\n"),
735 _ => {
736 let mut lines = Vec::new();
737 for entry in &report.tree {
738 push_tree_entry(entry, 0, &mut lines);
739 }
740 lines.join("\n")
741 }
742 }
743}
744
745fn format_file_entry(file: &crate::types::FileListEntry) -> String {
746 let mut out = format!(
747 "{} ({}, {} symbols)",
748 file.path, file.language, file.node_count
749 );
750 if let Some(size) = file.size {
751 out.push_str(&format!(", {size} bytes"));
752 }
753 out
754}
755
756fn push_tree_entry(entry: &crate::types::FileTreeEntry, depth: usize, lines: &mut Vec<String>) {
757 let indent = " ".repeat(depth);
758 if entry.kind == "dir" {
759 lines.push(format!("{indent}{}/", entry.name));
760 for child in &entry.children {
761 push_tree_entry(child, depth + 1, lines);
762 }
763 } else {
764 let mut line = format!(
765 "{indent}{} ({}, {} symbols)",
766 entry.name,
767 entry
768 .language
769 .map(|lang| lang.as_str())
770 .unwrap_or("unknown"),
771 entry.node_count.unwrap_or_default()
772 );
773 if let Some(size) = entry.size {
774 line.push_str(&format!(", {size} bytes"));
775 }
776 lines.push(line);
777 }
778}
779
780fn format_explore_report(report: &crate::types::ExploreReport, max_chars: usize) -> String {
781 let mut out = format!(
782 "## Explore: {}\n\nBudget: {}\n\n",
783 report.query, report.budget_guidance
784 );
785
786 if report.source_files.is_empty() {
787 out.push_str("No matching source sections found.\n");
788 } else {
789 out.push_str("## Source Sections\n");
790 for file in &report.source_files {
791 out.push_str(&format!("\n### {} ({})\n", file.path, file.language));
792 for section in &file.sections {
793 out.push_str(&format!(
794 "- `{}` `{}` lines {}-{}: {}\n\n```{}\n",
795 section.kind,
796 section.symbol,
797 section.start_line,
798 section.end_line,
799 section.reason,
800 file.language.as_str()
801 ));
802 out.push_str(§ion.code);
803 if !section.code.ends_with('\n') {
804 out.push('\n');
805 }
806 out.push_str("```\n");
807 if section.truncated {
808 out.push_str("[section truncated]\n");
809 }
810 }
811 }
812 }
813
814 if !report.relationships.is_empty() {
815 out.push_str("\n## Relationship Map\n");
816 for relationship in &report.relationships {
817 out.push_str(&format!(
818 "- {} `{}` --{}--> `{}` ({})\n",
819 relationship.direction,
820 relationship.source,
821 relationship.kind,
822 relationship.target,
823 relationship.file_path
824 ));
825 }
826 }
827
828 if !report.additional_files.is_empty() {
829 out.push_str("\n## Additional Relevant Files\n");
830 for file in &report.additional_files {
831 out.push_str(&format!("- `{file}`\n"));
832 }
833 }
834
835 if !report.warnings.is_empty() {
836 out.push_str("\n## Warnings\n");
837 for warning in &report.warnings {
838 out.push_str(&format!("- {warning}\n"));
839 }
840 }
841
842 if report.truncated {
843 out.push_str("\n[truncated]");
844 if let Some(reason) = &report.truncated_reason {
845 out.push(' ');
846 out.push_str(reason);
847 }
848 }
849
850 if out.chars().count() > max_chars {
851 let mut bounded = out.chars().take(max_chars).collect::<String>();
852 bounded.push_str("\n\n[truncated] MCP response exceeded text budget.");
853 bounded
854 } else {
855 out
856 }
857}
858
859fn text_result(text: String) -> Value {
860 json!({ "content": [{ "type": "text", "text": text }] })
861}
862
863fn format_optional_timestamp_ms(value: Option<i64>) -> String {
864 value
865 .map(|ms| ms.to_string())
866 .unwrap_or_else(|| "unknown".to_string())
867}
868
869fn error_response(id: Value, code: i64, message: &str) -> Value {
870 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
871}