Skip to main content

codegraph/
mcp.rs

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(&section.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}