Skip to main content

agentic_codebase/mcp/
server.rs

1//! MCP server implementation.
2//!
3//! Synchronous JSON-RPC 2.0 server that exposes code graph operations
4//! through the Model Context Protocol. All operations are in-process
5//! with no async runtime required.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use serde_json::{json, Value};
11
12use crate::engine::query::{ImpactParams, MatchMode, SymbolLookupParams};
13use crate::engine::QueryEngine;
14use crate::format::reader::AcbReader;
15use crate::graph::CodeGraph;
16use crate::grounding::{Grounded, GroundingEngine, GroundingResult};
17use crate::types::{CodeUnitType, EdgeType};
18use crate::workspace::{ContextRole, TranslationMap, TranslationStatus, WorkspaceManager};
19
20use super::protocol::{JsonRpcError, JsonRpcRequest, JsonRpcResponse};
21
22/// MCP server capability information.
23const SERVER_NAME: &str = "agentic-codebase";
24/// MCP server version.
25const SERVER_VERSION: &str = "0.1.0";
26/// MCP protocol version supported.
27const PROTOCOL_VERSION: &str = "2024-11-05";
28
29/// Record of a tool call or analysis context entry.
30#[derive(Debug, Clone)]
31pub struct OperationRecord {
32    pub tool_name: String,
33    pub summary: String,
34    pub timestamp: u64,
35    pub graph_name: Option<String>,
36}
37
38/// A synchronous MCP server that handles JSON-RPC 2.0 messages.
39///
40/// Holds loaded code graphs and dispatches tool/resource/prompt requests
41/// to the appropriate handler.
42#[derive(Debug)]
43pub struct McpServer {
44    /// Loaded code graphs keyed by name.
45    graphs: HashMap<String, CodeGraph>,
46    /// Query engine for executing queries.
47    engine: QueryEngine,
48    /// Whether the server has been initialised.
49    initialized: bool,
50    /// Log of operations with context for this session.
51    operation_log: Vec<OperationRecord>,
52    /// Timestamp when this session started.
53    session_start_time: Option<u64>,
54    /// Multi-context workspace manager.
55    workspace_manager: WorkspaceManager,
56    /// Translation maps keyed by workspace ID.
57    translation_maps: HashMap<String, TranslationMap>,
58    /// Deferred graph path for lazy loading on first tool call.
59    deferred_graph: Option<(String, String)>,
60}
61
62impl McpServer {
63    fn parse_unit_type(raw: &str) -> Option<CodeUnitType> {
64        match raw.trim().to_ascii_lowercase().as_str() {
65            "module" | "modules" => Some(CodeUnitType::Module),
66            "symbol" | "symbols" => Some(CodeUnitType::Symbol),
67            "type" | "types" => Some(CodeUnitType::Type),
68            "function" | "functions" => Some(CodeUnitType::Function),
69            "parameter" | "parameters" => Some(CodeUnitType::Parameter),
70            "import" | "imports" => Some(CodeUnitType::Import),
71            "test" | "tests" => Some(CodeUnitType::Test),
72            "doc" | "docs" | "document" | "documents" => Some(CodeUnitType::Doc),
73            "config" | "configs" => Some(CodeUnitType::Config),
74            "pattern" | "patterns" => Some(CodeUnitType::Pattern),
75            "trait" | "traits" => Some(CodeUnitType::Trait),
76            "impl" | "implementation" | "implementations" => Some(CodeUnitType::Impl),
77            "macro" | "macros" => Some(CodeUnitType::Macro),
78            _ => None,
79        }
80    }
81
82    /// Create a new MCP server with no loaded graphs.
83    pub fn new() -> Self {
84        Self {
85            graphs: HashMap::new(),
86            engine: QueryEngine::new(),
87            initialized: false,
88            operation_log: Vec::new(),
89            session_start_time: None,
90            workspace_manager: WorkspaceManager::new(),
91            translation_maps: HashMap::new(),
92            deferred_graph: None,
93        }
94    }
95
96    /// Load a code graph into the server under the given name.
97    pub fn load_graph(&mut self, name: String, graph: CodeGraph) {
98        self.graphs.insert(name, graph);
99    }
100
101    /// Set a deferred graph path for lazy loading.
102    ///
103    /// If no graphs are loaded when a tool is called, the server will
104    /// attempt to load this graph automatically. This provides a safety
105    /// net when startup auto-resolve fails (e.g. wrong CWD).
106    pub fn set_deferred_graph(&mut self, name: String, path: String) {
107        self.deferred_graph = Some((name, path));
108    }
109
110    /// Attempt to lazy-load the deferred graph. Called automatically
111    /// before tool dispatch when no graphs are loaded.
112    fn try_lazy_load(&mut self) {
113        if let Some((name, path)) = self.deferred_graph.take() {
114            match AcbReader::read_from_file(Path::new(&path)) {
115                Ok(graph) => {
116                    self.graphs.insert(name, graph);
117                }
118                Err(_) => {
119                    // Re-store the deferred path so we don't lose it
120                    self.deferred_graph = Some((name, path));
121                }
122            }
123        }
124    }
125
126    /// Remove a loaded code graph.
127    pub fn unload_graph(&mut self, name: &str) -> Option<CodeGraph> {
128        self.graphs.remove(name)
129    }
130
131    /// Get a reference to a loaded graph by name.
132    pub fn get_graph(&self, name: &str) -> Option<&CodeGraph> {
133        self.graphs.get(name)
134    }
135
136    /// List all loaded graph names.
137    pub fn graph_names(&self) -> Vec<&str> {
138        self.graphs.keys().map(|s| s.as_str()).collect()
139    }
140
141    /// Check if the server has been initialised.
142    pub fn is_initialized(&self) -> bool {
143        self.initialized
144    }
145
146    /// Handle a raw JSON-RPC message string.
147    ///
148    /// Parses the message, dispatches to the appropriate handler, and
149    /// returns the serialised JSON-RPC response.
150    pub fn handle_raw(&mut self, raw: &str) -> String {
151        let response = match super::protocol::parse_request(raw) {
152            Ok(request) => {
153                if request.id.is_none() {
154                    self.handle_notification(&request.method, &request.params);
155                    return String::new();
156                }
157                self.handle_request(request)
158            }
159            Err(error_response) => error_response,
160        };
161        serde_json::to_string(&response).unwrap_or_else(|_| {
162            r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Serialization failed"}}"#
163                .to_string()
164        })
165    }
166
167    /// Handle a parsed JSON-RPC request.
168    pub fn handle_request(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
169        let id = request.id.clone().unwrap_or(Value::Null);
170        match request.method.as_str() {
171            "initialize" => self.handle_initialize(id, &request.params),
172            "shutdown" => self.handle_shutdown(id),
173            "tools/list" => self.handle_tools_list(id),
174            "tools/call" => self.handle_tools_call(id, &request.params),
175            "resources/list" => self.handle_resources_list(id),
176            "resources/read" => self.handle_resources_read(id, &request.params),
177            "prompts/list" => self.handle_prompts_list(id),
178            _ => JsonRpcResponse::error(id, JsonRpcError::method_not_found(&request.method)),
179        }
180    }
181
182    /// Handle JSON-RPC notifications (messages without an `id`).
183    ///
184    /// Notification methods intentionally produce no response frame.
185    fn handle_notification(&mut self, method: &str, _params: &Value) {
186        if method == "notifications/initialized" {
187            self.initialized = true;
188        }
189    }
190
191    // ========================================================================
192    // Method handlers
193    // ========================================================================
194
195    /// Handle the "initialize" method.
196    fn handle_initialize(&mut self, id: Value, _params: &Value) -> JsonRpcResponse {
197        self.initialized = true;
198        self.session_start_time = Some(
199            std::time::SystemTime::now()
200                .duration_since(std::time::UNIX_EPOCH)
201                .unwrap_or_default()
202                .as_secs(),
203        );
204        self.operation_log.clear();
205        JsonRpcResponse::success(
206            id,
207            json!({
208                "protocolVersion": PROTOCOL_VERSION,
209                "capabilities": {
210                    "tools": { "listChanged": false },
211                    "resources": { "subscribe": false, "listChanged": false },
212                    "prompts": { "listChanged": false }
213                },
214                "serverInfo": {
215                    "name": SERVER_NAME,
216                    "version": SERVER_VERSION
217                }
218            }),
219        )
220    }
221
222    /// Handle the "shutdown" method.
223    fn handle_shutdown(&mut self, id: Value) -> JsonRpcResponse {
224        self.initialized = false;
225        JsonRpcResponse::success(id, json!(null))
226    }
227
228    /// Handle "tools/list".
229    fn handle_tools_list(&self, id: Value) -> JsonRpcResponse {
230        JsonRpcResponse::success(
231            id,
232            json!({
233                "tools": [
234                    {
235                        "name": "symbol_lookup",
236                        "description": "Look up symbols by name in the code graph.",
237                        "inputSchema": {
238                            "type": "object",
239                            "properties": {
240                                "graph": { "type": "string", "description": "Graph name" },
241                                "name": { "type": "string", "description": "Symbol name to search for" },
242                                "mode": { "type": "string", "enum": ["exact", "prefix", "contains", "fuzzy"], "default": "prefix" },
243                                "limit": { "type": "integer", "minimum": 1, "default": 10 }
244                            },
245                            "required": ["name"]
246                        }
247                    },
248                    {
249                        "name": "impact_analysis",
250                        "description": "Analyse the impact of changing a code unit.",
251                        "inputSchema": {
252                            "type": "object",
253                            "properties": {
254                                "graph": { "type": "string", "description": "Graph name" },
255                                "unit_id": { "type": "integer", "description": "Code unit ID to analyse" },
256                                "max_depth": { "type": "integer", "minimum": 0, "default": 3 }
257                            },
258                            "required": ["unit_id"]
259                        }
260                    },
261                    {
262                        "name": "graph_stats",
263                        "description": "Get summary statistics about a loaded code graph.",
264                        "inputSchema": {
265                            "type": "object",
266                            "properties": {
267                                "graph": { "type": "string", "description": "Graph name" }
268                            }
269                        }
270                    },
271                    {
272                        "name": "list_units",
273                        "description": "List code units in a graph, optionally filtered by type.",
274                        "inputSchema": {
275                            "type": "object",
276                            "properties": {
277                                "graph": { "type": "string", "description": "Graph name" },
278                                "unit_type": {
279                                    "type": "string",
280                                    "description": "Filter by unit type",
281                                    "enum": [
282                                        "module", "symbol", "type", "function", "parameter", "import",
283                                        "test", "doc", "config", "pattern", "trait", "impl", "macro"
284                                    ]
285                                },
286                                "limit": { "type": "integer", "default": 50 }
287                            }
288                        }
289                    },
290                    {
291                        "name": "analysis_log",
292                        "description": "Log the intent and context behind a code analysis. Call this to record WHY you are performing a lookup or analysis.",
293                        "inputSchema": {
294                            "type": "object",
295                            "properties": {
296                                "intent": {
297                                    "type": "string",
298                                    "description": "Why you are analysing — the goal or reason for the code query"
299                                },
300                                "finding": {
301                                    "type": "string",
302                                    "description": "What you found or concluded from the analysis"
303                                },
304                                "graph": {
305                                    "type": "string",
306                                    "description": "Optional graph name this analysis relates to"
307                                },
308                                "topic": {
309                                    "type": "string",
310                                    "description": "Optional topic or category (e.g., 'refactoring', 'bug-hunt')"
311                                }
312                            },
313                            "required": ["intent"]
314                        }
315                    },
316                    // ── Grounding tools ──────────────────────────────────
317                    {
318                        "name": "codebase_ground",
319                        "description": "Verify a claim about code has graph evidence. Use before asserting code exists.",
320                        "inputSchema": {
321                            "type": "object",
322                            "properties": {
323                                "claim": { "type": "string", "description": "The claim to verify (e.g., 'function validate_token exists')" },
324                                "graph": { "type": "string", "description": "Graph name" },
325                                "strict": { "type": "boolean", "description": "If true, partial matches return Ungrounded (default: false)", "default": false }
326                            },
327                            "required": ["claim"]
328                        }
329                    },
330                    {
331                        "name": "codebase_evidence",
332                        "description": "Get graph evidence for a symbol name.",
333                        "inputSchema": {
334                            "type": "object",
335                            "properties": {
336                                "name": { "type": "string", "description": "Symbol name to find" },
337                                "graph": { "type": "string", "description": "Graph name" },
338                                "types": {
339                                    "type": "array",
340                                    "items": { "type": "string" },
341                                    "description": "Filter by type: function, struct, enum, module, trait (optional)"
342                                }
343                            },
344                            "required": ["name"]
345                        }
346                    },
347                    {
348                        "name": "codebase_suggest",
349                        "description": "Find symbols similar to a name (for corrections).",
350                        "inputSchema": {
351                            "type": "object",
352                            "properties": {
353                                "name": { "type": "string", "description": "Name to find similar matches for" },
354                                "graph": { "type": "string", "description": "Graph name" },
355                                "limit": { "type": "integer", "minimum": 1, "default": 5, "description": "Max suggestions (default: 5)" }
356                            },
357                            "required": ["name"]
358                        }
359                    },
360                    // ── Workspace tools ──────────────────────────────────
361                    {
362                        "name": "workspace_create",
363                        "description": "Create a workspace to load multiple codebases.",
364                        "inputSchema": {
365                            "type": "object",
366                            "properties": {
367                                "name": { "type": "string", "description": "Workspace name (e.g., 'cpp-to-rust-migration')" }
368                            },
369                            "required": ["name"]
370                        }
371                    },
372                    {
373                        "name": "workspace_add",
374                        "description": "Add a codebase to an existing workspace.",
375                        "inputSchema": {
376                            "type": "object",
377                            "properties": {
378                                "workspace": { "type": "string", "description": "Workspace name or id" },
379                                "graph": { "type": "string", "description": "Name of a loaded graph to add" },
380                                "path": { "type": "string", "description": "Path label for this codebase" },
381                                "role": { "type": "string", "enum": ["source", "target", "reference", "comparison"], "description": "Role of this codebase" },
382                                "language": { "type": "string", "description": "Optional language hint" }
383                            },
384                            "required": ["workspace", "graph", "role"]
385                        }
386                    },
387                    {
388                        "name": "workspace_list",
389                        "description": "List all contexts in a workspace.",
390                        "inputSchema": {
391                            "type": "object",
392                            "properties": {
393                                "workspace": { "type": "string", "description": "Workspace name or id" }
394                            },
395                            "required": ["workspace"]
396                        }
397                    },
398                    {
399                        "name": "workspace_query",
400                        "description": "Search across all codebases in workspace.",
401                        "inputSchema": {
402                            "type": "object",
403                            "properties": {
404                                "workspace": { "type": "string", "description": "Workspace name or id" },
405                                "query": { "type": "string", "description": "Search query" },
406                                "roles": { "type": "array", "items": { "type": "string" }, "description": "Filter by role (optional)" }
407                            },
408                            "required": ["workspace", "query"]
409                        }
410                    },
411                    {
412                        "name": "workspace_compare",
413                        "description": "Compare a symbol between source and target.",
414                        "inputSchema": {
415                            "type": "object",
416                            "properties": {
417                                "workspace": { "type": "string", "description": "Workspace name or id" },
418                                "symbol": { "type": "string", "description": "Symbol to compare" }
419                            },
420                            "required": ["workspace", "symbol"]
421                        }
422                    },
423                    {
424                        "name": "workspace_xref",
425                        "description": "Find where symbol exists/doesn't exist across contexts.",
426                        "inputSchema": {
427                            "type": "object",
428                            "properties": {
429                                "workspace": { "type": "string", "description": "Workspace name or id" },
430                                "symbol": { "type": "string", "description": "Symbol to find" }
431                            },
432                            "required": ["workspace", "symbol"]
433                        }
434                    },
435                    // ── Translation tools ────────────────────────────────
436                    {
437                        "name": "translation_record",
438                        "description": "Record source→target symbol mapping.",
439                        "inputSchema": {
440                            "type": "object",
441                            "properties": {
442                                "workspace": { "type": "string", "description": "Workspace name or id" },
443                                "source_symbol": { "type": "string", "description": "Symbol in source codebase" },
444                                "target_symbol": { "type": "string", "description": "Symbol in target (null if not ported)" },
445                                "status": { "type": "string", "enum": ["not_started", "in_progress", "ported", "verified", "skipped"], "description": "Porting status" },
446                                "notes": { "type": "string", "description": "Optional notes" }
447                            },
448                            "required": ["workspace", "source_symbol", "status"]
449                        }
450                    },
451                    {
452                        "name": "translation_progress",
453                        "description": "Get migration progress statistics.",
454                        "inputSchema": {
455                            "type": "object",
456                            "properties": {
457                                "workspace": { "type": "string", "description": "Workspace name or id" }
458                            },
459                            "required": ["workspace"]
460                        }
461                    },
462                    {
463                        "name": "translation_remaining",
464                        "description": "List symbols not yet ported.",
465                        "inputSchema": {
466                            "type": "object",
467                            "properties": {
468                                "workspace": { "type": "string", "description": "Workspace name or id" },
469                                "module": { "type": "string", "description": "Filter by module (optional)" }
470                            },
471                            "required": ["workspace"]
472                        }
473                    }
474                ]
475            }),
476        )
477    }
478
479    /// Handle "tools/call".
480    fn handle_tools_call(&mut self, id: Value, params: &Value) -> JsonRpcResponse {
481        // Lazy auto-load: if no graphs are loaded, try the deferred path.
482        if self.graphs.is_empty() {
483            self.try_lazy_load();
484        }
485
486        let tool_name = match params.get("name").and_then(|v| v.as_str()) {
487            Some(name) => name,
488            None => {
489                return JsonRpcResponse::error(
490                    id,
491                    JsonRpcError::invalid_params("Missing 'name' field in tools/call params"),
492                );
493            }
494        };
495
496        let arguments = params
497            .get("arguments")
498            .cloned()
499            .unwrap_or(Value::Object(serde_json::Map::new()));
500
501        let result = match tool_name {
502            "symbol_lookup" => self.tool_symbol_lookup(id.clone(), &arguments),
503            "impact_analysis" => self.tool_impact_analysis(id.clone(), &arguments),
504            "graph_stats" => self.tool_graph_stats(id.clone(), &arguments),
505            "list_units" => self.tool_list_units(id.clone(), &arguments),
506            "analysis_log" => return self.tool_analysis_log(id, &arguments),
507            // Grounding tools
508            "codebase_ground" => self.tool_codebase_ground(id.clone(), &arguments),
509            "codebase_evidence" => self.tool_codebase_evidence(id.clone(), &arguments),
510            "codebase_suggest" => self.tool_codebase_suggest(id.clone(), &arguments),
511            // Workspace tools
512            "workspace_create" => return self.tool_workspace_create(id, &arguments),
513            "workspace_add" => return self.tool_workspace_add(id, &arguments),
514            "workspace_list" => self.tool_workspace_list(id.clone(), &arguments),
515            "workspace_query" => self.tool_workspace_query(id.clone(), &arguments),
516            "workspace_compare" => self.tool_workspace_compare(id.clone(), &arguments),
517            "workspace_xref" => self.tool_workspace_xref(id.clone(), &arguments),
518            // Translation tools
519            "translation_record" => return self.tool_translation_record(id, &arguments),
520            "translation_progress" => self.tool_translation_progress(id.clone(), &arguments),
521            "translation_remaining" => self.tool_translation_remaining(id.clone(), &arguments),
522            _ => {
523                return JsonRpcResponse::error(
524                    id,
525                    JsonRpcError::method_not_found(format!("Unknown tool: {}", tool_name)),
526                );
527            }
528        };
529
530        // Auto-log the tool call (skip analysis_log to avoid recursion).
531        let now = std::time::SystemTime::now()
532            .duration_since(std::time::UNIX_EPOCH)
533            .unwrap_or_default()
534            .as_secs();
535        let summary = truncate_json_summary(&arguments, 200);
536        let graph_name = arguments
537            .get("graph")
538            .and_then(|v| v.as_str())
539            .map(String::from);
540        self.operation_log.push(OperationRecord {
541            tool_name: tool_name.to_string(),
542            summary,
543            timestamp: now,
544            graph_name,
545        });
546
547        result
548    }
549
550    /// Handle "resources/list".
551    fn handle_resources_list(&self, id: Value) -> JsonRpcResponse {
552        let mut resources = Vec::new();
553
554        for name in self.graphs.keys() {
555            resources.push(json!({
556                "uri": format!("acb://graphs/{}/stats", name),
557                "name": format!("{} statistics", name),
558                "description": format!("Statistics for the {} code graph.", name),
559                "mimeType": "application/json"
560            }));
561            resources.push(json!({
562                "uri": format!("acb://graphs/{}/units", name),
563                "name": format!("{} units", name),
564                "description": format!("All code units in the {} graph.", name),
565                "mimeType": "application/json"
566            }));
567        }
568
569        JsonRpcResponse::success(id, json!({ "resources": resources }))
570    }
571
572    /// Handle "resources/read".
573    fn handle_resources_read(&self, id: Value, params: &Value) -> JsonRpcResponse {
574        let uri = match params.get("uri").and_then(|v| v.as_str()) {
575            Some(u) => u,
576            None => {
577                return JsonRpcResponse::error(
578                    id,
579                    JsonRpcError::invalid_params("Missing 'uri' field"),
580                );
581            }
582        };
583
584        // Parse URI: acb://graphs/{name}/stats or acb://graphs/{name}/units
585        if let Some(rest) = uri.strip_prefix("acb://graphs/") {
586            let parts: Vec<&str> = rest.splitn(2, '/').collect();
587            if parts.len() == 2 {
588                let graph_name = parts[0];
589                let resource = parts[1];
590
591                if let Some(graph) = self.graphs.get(graph_name) {
592                    return match resource {
593                        "stats" => {
594                            let stats = graph.stats();
595                            JsonRpcResponse::success(
596                                id,
597                                json!({
598                                    "contents": [{
599                                        "uri": uri,
600                                        "mimeType": "application/json",
601                                        "text": serde_json::to_string_pretty(&json!({
602                                            "unit_count": stats.unit_count,
603                                            "edge_count": stats.edge_count,
604                                            "dimension": stats.dimension,
605                                        })).unwrap_or_default()
606                                    }]
607                                }),
608                            )
609                        }
610                        "units" => {
611                            let units: Vec<Value> = graph
612                                .units()
613                                .iter()
614                                .map(|u| {
615                                    json!({
616                                        "id": u.id,
617                                        "name": u.name,
618                                        "type": u.unit_type.label(),
619                                        "file": u.file_path.display().to_string(),
620                                    })
621                                })
622                                .collect();
623                            JsonRpcResponse::success(
624                                id,
625                                json!({
626                                    "contents": [{
627                                        "uri": uri,
628                                        "mimeType": "application/json",
629                                        "text": serde_json::to_string_pretty(&units).unwrap_or_default()
630                                    }]
631                                }),
632                            )
633                        }
634                        _ => JsonRpcResponse::error(
635                            id,
636                            JsonRpcError::invalid_params(format!(
637                                "Unknown resource type: {}",
638                                resource
639                            )),
640                        ),
641                    };
642                } else {
643                    return JsonRpcResponse::error(
644                        id,
645                        JsonRpcError::invalid_params(format!("Graph not found: {}", graph_name)),
646                    );
647                }
648            }
649        }
650
651        JsonRpcResponse::error(
652            id,
653            JsonRpcError::invalid_params(format!("Invalid resource URI: {}", uri)),
654        )
655    }
656
657    /// Handle "prompts/list".
658    fn handle_prompts_list(&self, id: Value) -> JsonRpcResponse {
659        JsonRpcResponse::success(
660            id,
661            json!({
662                "prompts": [
663                    {
664                        "name": "analyse_unit",
665                        "description": "Analyse a code unit including its dependencies, stability, and test coverage.",
666                        "arguments": [
667                            {
668                                "name": "graph",
669                                "description": "Graph name",
670                                "required": false
671                            },
672                            {
673                                "name": "unit_name",
674                                "description": "Name of the code unit to analyse",
675                                "required": true
676                            }
677                        ]
678                    },
679                    {
680                        "name": "explain_coupling",
681                        "description": "Explain coupling between two code units.",
682                        "arguments": [
683                            {
684                                "name": "graph",
685                                "description": "Graph name",
686                                "required": false
687                            },
688                            {
689                                "name": "unit_a",
690                                "description": "First unit name",
691                                "required": true
692                            },
693                            {
694                                "name": "unit_b",
695                                "description": "Second unit name",
696                                "required": true
697                            }
698                        ]
699                    }
700                ]
701            }),
702        )
703    }
704
705    // ========================================================================
706    // Tool implementations
707    // ========================================================================
708
709    /// Resolve a graph name from arguments, defaulting to the first loaded graph.
710    fn resolve_graph<'a>(
711        &'a self,
712        args: &'a Value,
713    ) -> Result<(&'a str, &'a CodeGraph), JsonRpcError> {
714        let graph_name = args.get("graph").and_then(|v| v.as_str()).unwrap_or("");
715
716        if graph_name.is_empty() {
717            // Use the first graph if available.
718            if let Some((name, graph)) = self.graphs.iter().next() {
719                return Ok((name.as_str(), graph));
720            }
721            return Err(JsonRpcError::invalid_params(
722                "No graphs loaded. Start the MCP server with --graph <path.acb>, \
723                 or set AGENTRA_WORKSPACE_ROOT to a repository for auto-compilation.",
724            ));
725        }
726
727        self.graphs
728            .get(graph_name)
729            .map(|g| (graph_name, g))
730            .ok_or_else(|| JsonRpcError::invalid_params(format!("Graph not found: {}", graph_name)))
731    }
732
733    /// Tool: symbol_lookup.
734    fn tool_symbol_lookup(&self, id: Value, args: &Value) -> JsonRpcResponse {
735        let (_, graph) = match self.resolve_graph(args) {
736            Ok(g) => g,
737            Err(e) => return JsonRpcResponse::error(id, e),
738        };
739
740        let name = match args.get("name").and_then(|v| v.as_str()) {
741            Some(n) => n.to_string(),
742            None => {
743                return JsonRpcResponse::error(
744                    id,
745                    JsonRpcError::invalid_params("Missing 'name' argument"),
746                );
747            }
748        };
749
750        let mode_raw = args
751            .get("mode")
752            .and_then(|v| v.as_str())
753            .unwrap_or("prefix");
754        let mode = match mode_raw {
755            "exact" => MatchMode::Exact,
756            "prefix" => MatchMode::Prefix,
757            "contains" => MatchMode::Contains,
758            "fuzzy" => MatchMode::Fuzzy,
759            _ => {
760                return JsonRpcResponse::error(
761                    id,
762                    JsonRpcError::invalid_params(format!(
763                        "Invalid 'mode': {mode_raw}. Expected one of: exact, prefix, contains, fuzzy"
764                    )),
765                );
766            }
767        };
768
769        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
770
771        let params = SymbolLookupParams {
772            name,
773            mode,
774            limit,
775            ..SymbolLookupParams::default()
776        };
777
778        match self.engine.symbol_lookup(graph, params) {
779            Ok(units) => {
780                let results: Vec<Value> = units
781                    .iter()
782                    .map(|u| {
783                        json!({
784                            "id": u.id,
785                            "name": u.name,
786                            "qualified_name": u.qualified_name,
787                            "type": u.unit_type.label(),
788                            "file": u.file_path.display().to_string(),
789                            "language": u.language.name(),
790                            "complexity": u.complexity,
791                        })
792                    })
793                    .collect();
794                JsonRpcResponse::success(
795                    id,
796                    json!({
797                        "content": [{
798                            "type": "text",
799                            "text": serde_json::to_string_pretty(&results).unwrap_or_default()
800                        }]
801                    }),
802                )
803            }
804            Err(e) => JsonRpcResponse::error(id, JsonRpcError::internal_error(e.to_string())),
805        }
806    }
807
808    /// Tool: impact_analysis.
809    fn tool_impact_analysis(&self, id: Value, args: &Value) -> JsonRpcResponse {
810        let (_, graph) = match self.resolve_graph(args) {
811            Ok(g) => g,
812            Err(e) => return JsonRpcResponse::error(id, e),
813        };
814
815        let unit_id = match args.get("unit_id").and_then(|v| v.as_u64()) {
816            Some(uid) => uid,
817            None => {
818                return JsonRpcResponse::error(
819                    id,
820                    JsonRpcError::invalid_params("Missing 'unit_id' argument"),
821                );
822            }
823        };
824
825        let max_depth = match args.get("max_depth") {
826            None => 3,
827            Some(v) => {
828                let depth = match v.as_i64() {
829                    Some(d) => d,
830                    None => {
831                        return JsonRpcResponse::error(
832                            id,
833                            JsonRpcError::invalid_params("'max_depth' must be an integer >= 0"),
834                        );
835                    }
836                };
837                if depth < 0 {
838                    return JsonRpcResponse::error(
839                        id,
840                        JsonRpcError::invalid_params("'max_depth' must be >= 0"),
841                    );
842                }
843                depth as u32
844            }
845        };
846        let edge_types = vec![
847            EdgeType::Calls,
848            EdgeType::Imports,
849            EdgeType::Inherits,
850            EdgeType::Implements,
851            EdgeType::UsesType,
852            EdgeType::FfiBinds,
853            EdgeType::References,
854            EdgeType::Returns,
855            EdgeType::ParamType,
856            EdgeType::Overrides,
857            EdgeType::Contains,
858        ];
859
860        let params = ImpactParams {
861            unit_id,
862            max_depth,
863            edge_types,
864        };
865
866        match self.engine.impact_analysis(graph, params) {
867            Ok(result) => {
868                let impacted: Vec<Value> = result
869                    .impacted
870                    .iter()
871                    .map(|i| {
872                        json!({
873                            "unit_id": i.unit_id,
874                            "depth": i.depth,
875                            "risk_score": i.risk_score,
876                            "has_tests": i.has_tests,
877                        })
878                    })
879                    .collect();
880                JsonRpcResponse::success(
881                    id,
882                    json!({
883                        "content": [{
884                            "type": "text",
885                            "text": serde_json::to_string_pretty(&json!({
886                                "root_id": result.root_id,
887                                "overall_risk": result.overall_risk,
888                                "impacted_count": result.impacted.len(),
889                                "impacted": impacted,
890                                "recommendations": result.recommendations,
891                            })).unwrap_or_default()
892                        }]
893                    }),
894                )
895            }
896            Err(e) => JsonRpcResponse::error(id, JsonRpcError::internal_error(e.to_string())),
897        }
898    }
899
900    /// Tool: graph_stats.
901    fn tool_graph_stats(&self, id: Value, args: &Value) -> JsonRpcResponse {
902        let (name, graph) = match self.resolve_graph(args) {
903            Ok(g) => g,
904            Err(e) => return JsonRpcResponse::error(id, e),
905        };
906
907        let stats = graph.stats();
908        JsonRpcResponse::success(
909            id,
910            json!({
911                "content": [{
912                    "type": "text",
913                    "text": serde_json::to_string_pretty(&json!({
914                        "graph": name,
915                        "unit_count": stats.unit_count,
916                        "edge_count": stats.edge_count,
917                        "dimension": stats.dimension,
918                    })).unwrap_or_default()
919                }]
920            }),
921        )
922    }
923
924    /// Tool: list_units.
925    fn tool_list_units(&self, id: Value, args: &Value) -> JsonRpcResponse {
926        let (_, graph) = match self.resolve_graph(args) {
927            Ok(g) => g,
928            Err(e) => return JsonRpcResponse::error(id, e),
929        };
930
931        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
932        let unit_type_filter = match args.get("unit_type").and_then(|v| v.as_str()) {
933            Some(raw) => match Self::parse_unit_type(raw) {
934                Some(parsed) => Some(parsed),
935                None => {
936                    return JsonRpcResponse::error(
937                        id,
938                        JsonRpcError::invalid_params(format!(
939                            "Unknown unit_type '{}'. Expected one of: module, symbol, type, function, parameter, import, test, doc, config, pattern, trait, impl, macro.",
940                            raw
941                        )),
942                    );
943                }
944            },
945            None => None,
946        };
947
948        let units: Vec<Value> = graph
949            .units()
950            .iter()
951            .filter(|u| {
952                if let Some(expected) = unit_type_filter {
953                    u.unit_type == expected
954                } else {
955                    true
956                }
957            })
958            .take(limit)
959            .map(|u| {
960                json!({
961                    "id": u.id,
962                    "name": u.name,
963                    "type": u.unit_type.label(),
964                    "file": u.file_path.display().to_string(),
965                })
966            })
967            .collect();
968
969        JsonRpcResponse::success(
970            id,
971            json!({
972                "content": [{
973                    "type": "text",
974                    "text": serde_json::to_string_pretty(&units).unwrap_or_default()
975                }]
976            }),
977        )
978    }
979
980    /// Tool: analysis_log — record the intent/context behind a code analysis.
981    fn tool_analysis_log(&mut self, id: Value, args: &Value) -> JsonRpcResponse {
982        let intent = match args.get("intent").and_then(|v| v.as_str()) {
983            Some(i) if !i.trim().is_empty() => i,
984            _ => {
985                return JsonRpcResponse::error(
986                    id,
987                    JsonRpcError::invalid_params("'intent' is required and must not be empty"),
988                );
989            }
990        };
991
992        let finding = args.get("finding").and_then(|v| v.as_str());
993        let graph_name = args.get("graph").and_then(|v| v.as_str());
994        let topic = args.get("topic").and_then(|v| v.as_str());
995
996        let now = std::time::SystemTime::now()
997            .duration_since(std::time::UNIX_EPOCH)
998            .unwrap_or_default()
999            .as_secs();
1000
1001        let mut summary_parts = vec![format!("intent: {intent}")];
1002        if let Some(f) = finding {
1003            summary_parts.push(format!("finding: {f}"));
1004        }
1005        if let Some(t) = topic {
1006            summary_parts.push(format!("topic: {t}"));
1007        }
1008
1009        let record = OperationRecord {
1010            tool_name: "analysis_log".to_string(),
1011            summary: summary_parts.join(" | "),
1012            timestamp: now,
1013            graph_name: graph_name.map(String::from),
1014        };
1015
1016        let index = self.operation_log.len();
1017        self.operation_log.push(record);
1018
1019        JsonRpcResponse::success(
1020            id,
1021            json!({
1022                "content": [{
1023                    "type": "text",
1024                    "text": serde_json::to_string_pretty(&json!({
1025                        "log_index": index,
1026                        "message": "Analysis context logged"
1027                    })).unwrap_or_default()
1028                }]
1029            }),
1030        )
1031    }
1032
1033    /// Access the operation log.
1034    pub fn operation_log(&self) -> &[OperationRecord] {
1035        &self.operation_log
1036    }
1037
1038    /// Access the workspace manager.
1039    pub fn workspace_manager(&self) -> &WorkspaceManager {
1040        &self.workspace_manager
1041    }
1042
1043    /// Access the workspace manager mutably.
1044    pub fn workspace_manager_mut(&mut self) -> &mut WorkspaceManager {
1045        &mut self.workspace_manager
1046    }
1047
1048    // ========================================================================
1049    // Grounding tool implementations
1050    // ========================================================================
1051
1052    /// Tool: codebase_ground — verify a claim about code has graph evidence.
1053    fn tool_codebase_ground(&self, id: Value, args: &Value) -> JsonRpcResponse {
1054        let (_, graph) = match self.resolve_graph(args) {
1055            Ok(g) => g,
1056            Err(e) => return JsonRpcResponse::error(id, e),
1057        };
1058
1059        let claim = match args.get("claim").and_then(|v| v.as_str()) {
1060            Some(c) if !c.trim().is_empty() => c,
1061            _ => {
1062                return JsonRpcResponse::error(
1063                    id,
1064                    JsonRpcError::invalid_params("Missing or empty 'claim' argument"),
1065                );
1066            }
1067        };
1068
1069        let strict = args
1070            .get("strict")
1071            .and_then(|v| v.as_bool())
1072            .unwrap_or(false);
1073
1074        let engine = GroundingEngine::new(graph);
1075        let result = engine.ground_claim(claim);
1076
1077        // In strict mode, Partial is treated as Ungrounded.
1078        let result = if strict {
1079            match result {
1080                GroundingResult::Partial {
1081                    unsupported,
1082                    suggestions,
1083                    ..
1084                } => GroundingResult::Ungrounded {
1085                    claim: claim.to_string(),
1086                    suggestions: {
1087                        let mut s = unsupported;
1088                        s.extend(suggestions);
1089                        s
1090                    },
1091                },
1092                other => other,
1093            }
1094        } else {
1095            result
1096        };
1097
1098        let output = match &result {
1099            GroundingResult::Verified {
1100                evidence,
1101                confidence,
1102            } => json!({
1103                "status": "verified",
1104                "confidence": confidence,
1105                "evidence": evidence.iter().map(|e| json!({
1106                    "node_id": e.node_id,
1107                    "node_type": e.node_type,
1108                    "name": e.name,
1109                    "file_path": e.file_path,
1110                    "line_number": e.line_number,
1111                    "snippet": e.snippet,
1112                })).collect::<Vec<_>>(),
1113            }),
1114            GroundingResult::Partial {
1115                supported,
1116                unsupported,
1117                suggestions,
1118            } => json!({
1119                "status": "partial",
1120                "supported": supported,
1121                "unsupported": unsupported,
1122                "suggestions": suggestions,
1123            }),
1124            GroundingResult::Ungrounded {
1125                claim, suggestions, ..
1126            } => json!({
1127                "status": "ungrounded",
1128                "claim": claim,
1129                "suggestions": suggestions,
1130            }),
1131        };
1132
1133        JsonRpcResponse::success(
1134            id,
1135            json!({
1136                "content": [{
1137                    "type": "text",
1138                    "text": serde_json::to_string_pretty(&output).unwrap_or_default()
1139                }]
1140            }),
1141        )
1142    }
1143
1144    /// Tool: codebase_evidence — get graph evidence for a symbol name.
1145    fn tool_codebase_evidence(&self, id: Value, args: &Value) -> JsonRpcResponse {
1146        let (_, graph) = match self.resolve_graph(args) {
1147            Ok(g) => g,
1148            Err(e) => return JsonRpcResponse::error(id, e),
1149        };
1150
1151        let name = match args.get("name").and_then(|v| v.as_str()) {
1152            Some(n) if !n.trim().is_empty() => n,
1153            _ => {
1154                return JsonRpcResponse::error(
1155                    id,
1156                    JsonRpcError::invalid_params("Missing or empty 'name' argument"),
1157                );
1158            }
1159        };
1160
1161        let type_filters: Vec<String> = args
1162            .get("types")
1163            .and_then(|v| v.as_array())
1164            .map(|arr| {
1165                arr.iter()
1166                    .filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
1167                    .collect()
1168            })
1169            .unwrap_or_default();
1170
1171        let engine = GroundingEngine::new(graph);
1172        let mut evidence = engine.find_evidence(name);
1173
1174        // Apply type filters if provided.
1175        if !type_filters.is_empty() {
1176            evidence.retain(|e| type_filters.contains(&e.node_type.to_lowercase()));
1177        }
1178
1179        let output: Vec<Value> = evidence
1180            .iter()
1181            .map(|e| {
1182                json!({
1183                    "node_id": e.node_id,
1184                    "node_type": e.node_type,
1185                    "name": e.name,
1186                    "file_path": e.file_path,
1187                    "line_number": e.line_number,
1188                    "snippet": e.snippet,
1189                })
1190            })
1191            .collect();
1192
1193        JsonRpcResponse::success(
1194            id,
1195            json!({
1196                "content": [{
1197                    "type": "text",
1198                    "text": serde_json::to_string_pretty(&output).unwrap_or_default()
1199                }]
1200            }),
1201        )
1202    }
1203
1204    /// Tool: codebase_suggest — find symbols similar to a name.
1205    fn tool_codebase_suggest(&self, id: Value, args: &Value) -> JsonRpcResponse {
1206        let (_, graph) = match self.resolve_graph(args) {
1207            Ok(g) => g,
1208            Err(e) => return JsonRpcResponse::error(id, e),
1209        };
1210
1211        let name = match args.get("name").and_then(|v| v.as_str()) {
1212            Some(n) if !n.trim().is_empty() => n,
1213            _ => {
1214                return JsonRpcResponse::error(
1215                    id,
1216                    JsonRpcError::invalid_params("Missing or empty 'name' argument"),
1217                );
1218            }
1219        };
1220
1221        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
1222
1223        let engine = GroundingEngine::new(graph);
1224        let suggestions = engine.suggest_similar(name, limit);
1225
1226        JsonRpcResponse::success(
1227            id,
1228            json!({
1229                "content": [{
1230                    "type": "text",
1231                    "text": serde_json::to_string_pretty(&json!({
1232                        "query": name,
1233                        "suggestions": suggestions,
1234                    })).unwrap_or_default()
1235                }]
1236            }),
1237        )
1238    }
1239
1240    // ========================================================================
1241    // Workspace tool implementations
1242    // ========================================================================
1243
1244    /// Resolve a workspace ID from arguments. Accepts workspace ID directly
1245    /// or tries to match by name.
1246    fn resolve_workspace_id(&self, args: &Value) -> Result<String, JsonRpcError> {
1247        let raw = args.get("workspace").and_then(|v| v.as_str()).unwrap_or("");
1248        if raw.is_empty() {
1249            // Try active workspace.
1250            return self
1251                .workspace_manager
1252                .get_active()
1253                .map(|s| s.to_string())
1254                .ok_or_else(|| {
1255                    JsonRpcError::invalid_params("No workspace specified and none active")
1256                });
1257        }
1258
1259        // If it looks like a workspace ID (starts with "ws-"), use directly.
1260        if raw.starts_with("ws-") {
1261            // Validate it exists.
1262            self.workspace_manager
1263                .list(raw)
1264                .map(|_| raw.to_string())
1265                .map_err(JsonRpcError::invalid_params)
1266        } else {
1267            // Try to find by name — iterate all workspaces. We need to expose
1268            // this through the manager. For now, just treat it as an ID.
1269            self.workspace_manager
1270                .list(raw)
1271                .map(|_| raw.to_string())
1272                .map_err(JsonRpcError::invalid_params)
1273        }
1274    }
1275
1276    /// Tool: workspace_create.
1277    fn tool_workspace_create(&mut self, id: Value, args: &Value) -> JsonRpcResponse {
1278        let name = match args.get("name").and_then(|v| v.as_str()) {
1279            Some(n) if !n.trim().is_empty() => n,
1280            _ => {
1281                return JsonRpcResponse::error(
1282                    id,
1283                    JsonRpcError::invalid_params("Missing or empty 'name' argument"),
1284                );
1285            }
1286        };
1287
1288        let ws_id = self.workspace_manager.create(name);
1289
1290        JsonRpcResponse::success(
1291            id,
1292            json!({
1293                "content": [{
1294                    "type": "text",
1295                    "text": serde_json::to_string_pretty(&json!({
1296                        "workspace_id": ws_id,
1297                        "name": name,
1298                        "message": "Workspace created"
1299                    })).unwrap_or_default()
1300                }]
1301            }),
1302        )
1303    }
1304
1305    /// Tool: workspace_add — add a loaded graph as a context.
1306    fn tool_workspace_add(&mut self, id: Value, args: &Value) -> JsonRpcResponse {
1307        let ws_id = match self.resolve_workspace_id(args) {
1308            Ok(ws) => ws,
1309            Err(e) => return JsonRpcResponse::error(id, e),
1310        };
1311
1312        let graph_name = match args.get("graph").and_then(|v| v.as_str()) {
1313            Some(n) if !n.trim().is_empty() => n.to_string(),
1314            _ => {
1315                return JsonRpcResponse::error(
1316                    id,
1317                    JsonRpcError::invalid_params("Missing or empty 'graph' argument"),
1318                );
1319            }
1320        };
1321
1322        // Clone the graph to add to workspace (graphs remain available in the server).
1323        let graph = match self.graphs.get(&graph_name) {
1324            Some(g) => g.clone(),
1325            None => {
1326                return JsonRpcResponse::error(
1327                    id,
1328                    JsonRpcError::invalid_params(format!("Graph not found: {}", graph_name)),
1329                );
1330            }
1331        };
1332
1333        let role_str = args
1334            .get("role")
1335            .and_then(|v| v.as_str())
1336            .unwrap_or("source");
1337        let role = match ContextRole::parse_str(role_str) {
1338            Some(r) => r,
1339            None => {
1340                return JsonRpcResponse::error(
1341                    id,
1342                    JsonRpcError::invalid_params(format!(
1343                        "Invalid role '{}'. Expected: source, target, reference, comparison",
1344                        role_str
1345                    )),
1346                );
1347            }
1348        };
1349
1350        let path = args
1351            .get("path")
1352            .and_then(|v| v.as_str())
1353            .unwrap_or(&graph_name)
1354            .to_string();
1355        let language = args
1356            .get("language")
1357            .and_then(|v| v.as_str())
1358            .map(String::from);
1359
1360        match self
1361            .workspace_manager
1362            .add_context(&ws_id, &path, role, language, graph)
1363        {
1364            Ok(ctx_id) => JsonRpcResponse::success(
1365                id,
1366                json!({
1367                    "content": [{
1368                        "type": "text",
1369                        "text": serde_json::to_string_pretty(&json!({
1370                            "context_id": ctx_id,
1371                            "workspace_id": ws_id,
1372                            "graph": graph_name,
1373                            "message": "Context added to workspace"
1374                        })).unwrap_or_default()
1375                    }]
1376                }),
1377            ),
1378            Err(e) => JsonRpcResponse::error(id, JsonRpcError::invalid_params(e)),
1379        }
1380    }
1381
1382    /// Tool: workspace_list.
1383    fn tool_workspace_list(&self, id: Value, args: &Value) -> JsonRpcResponse {
1384        let ws_id = match self.resolve_workspace_id(args) {
1385            Ok(ws) => ws,
1386            Err(e) => return JsonRpcResponse::error(id, e),
1387        };
1388
1389        match self.workspace_manager.list(&ws_id) {
1390            Ok(workspace) => {
1391                let contexts: Vec<Value> = workspace
1392                    .contexts
1393                    .iter()
1394                    .map(|c| {
1395                        json!({
1396                            "id": c.id,
1397                            "role": c.role.label(),
1398                            "path": c.path,
1399                            "language": c.language,
1400                            "unit_count": c.graph.units().len(),
1401                        })
1402                    })
1403                    .collect();
1404
1405                JsonRpcResponse::success(
1406                    id,
1407                    json!({
1408                        "content": [{
1409                            "type": "text",
1410                            "text": serde_json::to_string_pretty(&json!({
1411                                "workspace_id": ws_id,
1412                                "name": workspace.name,
1413                                "context_count": workspace.contexts.len(),
1414                                "contexts": contexts,
1415                            })).unwrap_or_default()
1416                        }]
1417                    }),
1418                )
1419            }
1420            Err(e) => JsonRpcResponse::error(id, JsonRpcError::invalid_params(e)),
1421        }
1422    }
1423
1424    /// Tool: workspace_query.
1425    fn tool_workspace_query(&self, id: Value, args: &Value) -> JsonRpcResponse {
1426        let ws_id = match self.resolve_workspace_id(args) {
1427            Ok(ws) => ws,
1428            Err(e) => return JsonRpcResponse::error(id, e),
1429        };
1430
1431        let query = match args.get("query").and_then(|v| v.as_str()) {
1432            Some(q) if !q.trim().is_empty() => q,
1433            _ => {
1434                return JsonRpcResponse::error(
1435                    id,
1436                    JsonRpcError::invalid_params("Missing or empty 'query' argument"),
1437                );
1438            }
1439        };
1440
1441        let role_filters: Vec<String> = args
1442            .get("roles")
1443            .and_then(|v| v.as_array())
1444            .map(|arr| {
1445                arr.iter()
1446                    .filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
1447                    .collect()
1448            })
1449            .unwrap_or_default();
1450
1451        match self.workspace_manager.query_all(&ws_id, query) {
1452            Ok(results) => {
1453                let mut filtered = results;
1454                if !role_filters.is_empty() {
1455                    filtered.retain(|r| role_filters.contains(&r.context_role.label().to_string()));
1456                }
1457
1458                let output: Vec<Value> = filtered
1459                    .iter()
1460                    .map(|r| {
1461                        json!({
1462                            "context_id": r.context_id,
1463                            "role": r.context_role.label(),
1464                            "matches": r.matches.iter().map(|m| json!({
1465                                "unit_id": m.unit_id,
1466                                "name": m.name,
1467                                "qualified_name": m.qualified_name,
1468                                "type": m.unit_type,
1469                                "file": m.file_path,
1470                            })).collect::<Vec<_>>(),
1471                        })
1472                    })
1473                    .collect();
1474
1475                JsonRpcResponse::success(
1476                    id,
1477                    json!({
1478                        "content": [{
1479                            "type": "text",
1480                            "text": serde_json::to_string_pretty(&output).unwrap_or_default()
1481                        }]
1482                    }),
1483                )
1484            }
1485            Err(e) => JsonRpcResponse::error(id, JsonRpcError::invalid_params(e)),
1486        }
1487    }
1488
1489    /// Tool: workspace_compare.
1490    fn tool_workspace_compare(&self, id: Value, args: &Value) -> JsonRpcResponse {
1491        let ws_id = match self.resolve_workspace_id(args) {
1492            Ok(ws) => ws,
1493            Err(e) => return JsonRpcResponse::error(id, e),
1494        };
1495
1496        let symbol = match args.get("symbol").and_then(|v| v.as_str()) {
1497            Some(s) if !s.trim().is_empty() => s,
1498            _ => {
1499                return JsonRpcResponse::error(
1500                    id,
1501                    JsonRpcError::invalid_params("Missing or empty 'symbol' argument"),
1502                );
1503            }
1504        };
1505
1506        match self.workspace_manager.compare(&ws_id, symbol) {
1507            Ok(cmp) => {
1508                let contexts: Vec<Value> = cmp
1509                    .contexts
1510                    .iter()
1511                    .map(|c| {
1512                        json!({
1513                            "context_id": c.context_id,
1514                            "role": c.role.label(),
1515                            "found": c.found,
1516                            "unit_type": c.unit_type,
1517                            "signature": c.signature,
1518                            "file_path": c.file_path,
1519                        })
1520                    })
1521                    .collect();
1522
1523                JsonRpcResponse::success(
1524                    id,
1525                    json!({
1526                        "content": [{
1527                            "type": "text",
1528                            "text": serde_json::to_string_pretty(&json!({
1529                                "symbol": cmp.symbol,
1530                                "semantic_match": cmp.semantic_match,
1531                                "structural_diff": cmp.structural_diff,
1532                                "contexts": contexts,
1533                            })).unwrap_or_default()
1534                        }]
1535                    }),
1536                )
1537            }
1538            Err(e) => JsonRpcResponse::error(id, JsonRpcError::invalid_params(e)),
1539        }
1540    }
1541
1542    /// Tool: workspace_xref.
1543    fn tool_workspace_xref(&self, id: Value, args: &Value) -> JsonRpcResponse {
1544        let ws_id = match self.resolve_workspace_id(args) {
1545            Ok(ws) => ws,
1546            Err(e) => return JsonRpcResponse::error(id, e),
1547        };
1548
1549        let symbol = match args.get("symbol").and_then(|v| v.as_str()) {
1550            Some(s) if !s.trim().is_empty() => s,
1551            _ => {
1552                return JsonRpcResponse::error(
1553                    id,
1554                    JsonRpcError::invalid_params("Missing or empty 'symbol' argument"),
1555                );
1556            }
1557        };
1558
1559        match self.workspace_manager.cross_reference(&ws_id, symbol) {
1560            Ok(xref) => {
1561                let found: Vec<Value> = xref
1562                    .found_in
1563                    .iter()
1564                    .map(|(ctx_id, role)| json!({"context_id": ctx_id, "role": role.label()}))
1565                    .collect();
1566                let missing: Vec<Value> = xref
1567                    .missing_from
1568                    .iter()
1569                    .map(|(ctx_id, role)| json!({"context_id": ctx_id, "role": role.label()}))
1570                    .collect();
1571
1572                JsonRpcResponse::success(
1573                    id,
1574                    json!({
1575                        "content": [{
1576                            "type": "text",
1577                            "text": serde_json::to_string_pretty(&json!({
1578                                "symbol": xref.symbol,
1579                                "found_in": found,
1580                                "missing_from": missing,
1581                            })).unwrap_or_default()
1582                        }]
1583                    }),
1584                )
1585            }
1586            Err(e) => JsonRpcResponse::error(id, JsonRpcError::invalid_params(e)),
1587        }
1588    }
1589
1590    // ========================================================================
1591    // Translation tool implementations
1592    // ========================================================================
1593
1594    /// Tool: translation_record.
1595    fn tool_translation_record(&mut self, id: Value, args: &Value) -> JsonRpcResponse {
1596        let ws_id = match self.resolve_workspace_id(args) {
1597            Ok(ws) => ws,
1598            Err(e) => return JsonRpcResponse::error(id, e),
1599        };
1600
1601        let source_symbol = match args.get("source_symbol").and_then(|v| v.as_str()) {
1602            Some(s) if !s.trim().is_empty() => s,
1603            _ => {
1604                return JsonRpcResponse::error(
1605                    id,
1606                    JsonRpcError::invalid_params("Missing or empty 'source_symbol' argument"),
1607                );
1608            }
1609        };
1610
1611        let target_symbol = args.get("target_symbol").and_then(|v| v.as_str());
1612
1613        let status_str = args
1614            .get("status")
1615            .and_then(|v| v.as_str())
1616            .unwrap_or("not_started");
1617        let status = match TranslationStatus::parse_str(status_str) {
1618            Some(s) => s,
1619            None => {
1620                return JsonRpcResponse::error(
1621                    id,
1622                    JsonRpcError::invalid_params(format!(
1623                        "Invalid status '{}'. Expected: not_started, in_progress, ported, verified, skipped",
1624                        status_str
1625                    )),
1626                );
1627            }
1628        };
1629
1630        let notes = args.get("notes").and_then(|v| v.as_str()).map(String::from);
1631
1632        // Get or create translation map for this workspace.
1633        // Use workspace's first source and first target context IDs.
1634        let tmap = self
1635            .translation_maps
1636            .entry(ws_id.clone())
1637            .or_insert_with(|| {
1638                // Find source and target context IDs from the workspace.
1639                let (src, tgt) = if let Ok(ws) = self.workspace_manager.list(&ws_id) {
1640                    let src = ws
1641                        .contexts
1642                        .iter()
1643                        .find(|c| c.role == ContextRole::Source)
1644                        .map(|c| c.id.clone())
1645                        .unwrap_or_default();
1646                    let tgt = ws
1647                        .contexts
1648                        .iter()
1649                        .find(|c| c.role == ContextRole::Target)
1650                        .map(|c| c.id.clone())
1651                        .unwrap_or_default();
1652                    (src, tgt)
1653                } else {
1654                    (String::new(), String::new())
1655                };
1656                TranslationMap::new(src, tgt)
1657            });
1658
1659        tmap.record(source_symbol, target_symbol, status, notes);
1660
1661        JsonRpcResponse::success(
1662            id,
1663            json!({
1664                "content": [{
1665                    "type": "text",
1666                    "text": serde_json::to_string_pretty(&json!({
1667                        "source_symbol": source_symbol,
1668                        "target_symbol": target_symbol,
1669                        "status": status_str,
1670                        "message": "Translation mapping recorded"
1671                    })).unwrap_or_default()
1672                }]
1673            }),
1674        )
1675    }
1676
1677    /// Tool: translation_progress.
1678    fn tool_translation_progress(&self, id: Value, args: &Value) -> JsonRpcResponse {
1679        let ws_id = match self.resolve_workspace_id(args) {
1680            Ok(ws) => ws,
1681            Err(e) => return JsonRpcResponse::error(id, e),
1682        };
1683
1684        let progress = match self.translation_maps.get(&ws_id) {
1685            Some(tmap) => tmap.progress(),
1686            None => {
1687                // No translation map yet — return zeros.
1688                crate::workspace::TranslationProgress {
1689                    total: 0,
1690                    not_started: 0,
1691                    in_progress: 0,
1692                    ported: 0,
1693                    verified: 0,
1694                    skipped: 0,
1695                    percent_complete: 0.0,
1696                }
1697            }
1698        };
1699
1700        JsonRpcResponse::success(
1701            id,
1702            json!({
1703                "content": [{
1704                    "type": "text",
1705                    "text": serde_json::to_string_pretty(&json!({
1706                        "workspace": ws_id,
1707                        "total": progress.total,
1708                        "not_started": progress.not_started,
1709                        "in_progress": progress.in_progress,
1710                        "ported": progress.ported,
1711                        "verified": progress.verified,
1712                        "skipped": progress.skipped,
1713                        "percent_complete": progress.percent_complete,
1714                    })).unwrap_or_default()
1715                }]
1716            }),
1717        )
1718    }
1719
1720    /// Tool: translation_remaining.
1721    fn tool_translation_remaining(&self, id: Value, args: &Value) -> JsonRpcResponse {
1722        let ws_id = match self.resolve_workspace_id(args) {
1723            Ok(ws) => ws,
1724            Err(e) => return JsonRpcResponse::error(id, e),
1725        };
1726
1727        let module_filter = args
1728            .get("module")
1729            .and_then(|v| v.as_str())
1730            .map(|s| s.to_lowercase());
1731
1732        let remaining = match self.translation_maps.get(&ws_id) {
1733            Some(tmap) => {
1734                let mut items = tmap.remaining();
1735                if let Some(ref module) = module_filter {
1736                    items.retain(|m| m.source_symbol.to_lowercase().contains(module.as_str()));
1737                }
1738                items
1739                    .iter()
1740                    .map(|m| {
1741                        json!({
1742                            "source_symbol": m.source_symbol,
1743                            "status": m.status.label(),
1744                            "notes": m.notes,
1745                        })
1746                    })
1747                    .collect::<Vec<_>>()
1748            }
1749            None => Vec::new(),
1750        };
1751
1752        JsonRpcResponse::success(
1753            id,
1754            json!({
1755                "content": [{
1756                    "type": "text",
1757                    "text": serde_json::to_string_pretty(&json!({
1758                        "workspace": ws_id,
1759                        "remaining_count": remaining.len(),
1760                        "remaining": remaining,
1761                    })).unwrap_or_default()
1762                }]
1763            }),
1764        )
1765    }
1766}
1767
1768/// Truncate a JSON value to a short summary string.
1769fn truncate_json_summary(value: &Value, max_len: usize) -> String {
1770    let s = value.to_string();
1771    if s.len() <= max_len {
1772        s
1773    } else {
1774        format!("{}...", &s[..max_len])
1775    }
1776}
1777
1778impl Default for McpServer {
1779    fn default() -> Self {
1780        Self::new()
1781    }
1782}