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;
8
9use serde_json::{json, Value};
10
11use crate::engine::query::{ImpactParams, MatchMode, SymbolLookupParams};
12use crate::engine::QueryEngine;
13use crate::graph::CodeGraph;
14use crate::types::{CodeUnitType, EdgeType};
15
16use super::protocol::{JsonRpcError, JsonRpcRequest, JsonRpcResponse};
17
18/// MCP server capability information.
19const SERVER_NAME: &str = "agentic-codebase";
20/// MCP server version.
21const SERVER_VERSION: &str = "0.1.0";
22/// MCP protocol version supported.
23const PROTOCOL_VERSION: &str = "2024-11-05";
24
25/// Record of a tool call or analysis context entry.
26#[derive(Debug, Clone)]
27pub struct OperationRecord {
28    pub tool_name: String,
29    pub summary: String,
30    pub timestamp: u64,
31    pub graph_name: Option<String>,
32}
33
34/// A synchronous MCP server that handles JSON-RPC 2.0 messages.
35///
36/// Holds loaded code graphs and dispatches tool/resource/prompt requests
37/// to the appropriate handler.
38#[derive(Debug)]
39pub struct McpServer {
40    /// Loaded code graphs keyed by name.
41    graphs: HashMap<String, CodeGraph>,
42    /// Query engine for executing queries.
43    engine: QueryEngine,
44    /// Whether the server has been initialised.
45    initialized: bool,
46    /// Log of operations with context for this session.
47    operation_log: Vec<OperationRecord>,
48    /// Timestamp when this session started.
49    session_start_time: Option<u64>,
50}
51
52impl McpServer {
53    fn parse_unit_type(raw: &str) -> Option<CodeUnitType> {
54        match raw.trim().to_ascii_lowercase().as_str() {
55            "module" | "modules" => Some(CodeUnitType::Module),
56            "symbol" | "symbols" => Some(CodeUnitType::Symbol),
57            "type" | "types" => Some(CodeUnitType::Type),
58            "function" | "functions" => Some(CodeUnitType::Function),
59            "parameter" | "parameters" => Some(CodeUnitType::Parameter),
60            "import" | "imports" => Some(CodeUnitType::Import),
61            "test" | "tests" => Some(CodeUnitType::Test),
62            "doc" | "docs" | "document" | "documents" => Some(CodeUnitType::Doc),
63            "config" | "configs" => Some(CodeUnitType::Config),
64            "pattern" | "patterns" => Some(CodeUnitType::Pattern),
65            "trait" | "traits" => Some(CodeUnitType::Trait),
66            "impl" | "implementation" | "implementations" => Some(CodeUnitType::Impl),
67            "macro" | "macros" => Some(CodeUnitType::Macro),
68            _ => None,
69        }
70    }
71
72    /// Create a new MCP server with no loaded graphs.
73    pub fn new() -> Self {
74        Self {
75            graphs: HashMap::new(),
76            engine: QueryEngine::new(),
77            initialized: false,
78            operation_log: Vec::new(),
79            session_start_time: None,
80        }
81    }
82
83    /// Load a code graph into the server under the given name.
84    pub fn load_graph(&mut self, name: String, graph: CodeGraph) {
85        self.graphs.insert(name, graph);
86    }
87
88    /// Remove a loaded code graph.
89    pub fn unload_graph(&mut self, name: &str) -> Option<CodeGraph> {
90        self.graphs.remove(name)
91    }
92
93    /// Get a reference to a loaded graph by name.
94    pub fn get_graph(&self, name: &str) -> Option<&CodeGraph> {
95        self.graphs.get(name)
96    }
97
98    /// List all loaded graph names.
99    pub fn graph_names(&self) -> Vec<&str> {
100        self.graphs.keys().map(|s| s.as_str()).collect()
101    }
102
103    /// Check if the server has been initialised.
104    pub fn is_initialized(&self) -> bool {
105        self.initialized
106    }
107
108    /// Handle a raw JSON-RPC message string.
109    ///
110    /// Parses the message, dispatches to the appropriate handler, and
111    /// returns the serialised JSON-RPC response.
112    pub fn handle_raw(&mut self, raw: &str) -> String {
113        let response = match super::protocol::parse_request(raw) {
114            Ok(request) => {
115                if request.id.is_none() {
116                    self.handle_notification(&request.method, &request.params);
117                    return String::new();
118                }
119                self.handle_request(request)
120            }
121            Err(error_response) => error_response,
122        };
123        serde_json::to_string(&response).unwrap_or_else(|_| {
124            r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Serialization failed"}}"#
125                .to_string()
126        })
127    }
128
129    /// Handle a parsed JSON-RPC request.
130    pub fn handle_request(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
131        let id = request.id.clone().unwrap_or(Value::Null);
132        match request.method.as_str() {
133            "initialize" => self.handle_initialize(id, &request.params),
134            "shutdown" => self.handle_shutdown(id),
135            "tools/list" => self.handle_tools_list(id),
136            "tools/call" => self.handle_tools_call(id, &request.params),
137            "resources/list" => self.handle_resources_list(id),
138            "resources/read" => self.handle_resources_read(id, &request.params),
139            "prompts/list" => self.handle_prompts_list(id),
140            _ => JsonRpcResponse::error(id, JsonRpcError::method_not_found(&request.method)),
141        }
142    }
143
144    /// Handle JSON-RPC notifications (messages without an `id`).
145    ///
146    /// Notification methods intentionally produce no response frame.
147    fn handle_notification(&mut self, method: &str, _params: &Value) {
148        if method == "notifications/initialized" {
149            self.initialized = true;
150        }
151    }
152
153    // ========================================================================
154    // Method handlers
155    // ========================================================================
156
157    /// Handle the "initialize" method.
158    fn handle_initialize(&mut self, id: Value, _params: &Value) -> JsonRpcResponse {
159        self.initialized = true;
160        self.session_start_time = Some(
161            std::time::SystemTime::now()
162                .duration_since(std::time::UNIX_EPOCH)
163                .unwrap_or_default()
164                .as_secs(),
165        );
166        self.operation_log.clear();
167        JsonRpcResponse::success(
168            id,
169            json!({
170                "protocolVersion": PROTOCOL_VERSION,
171                "capabilities": {
172                    "tools": { "listChanged": false },
173                    "resources": { "subscribe": false, "listChanged": false },
174                    "prompts": { "listChanged": false }
175                },
176                "serverInfo": {
177                    "name": SERVER_NAME,
178                    "version": SERVER_VERSION
179                }
180            }),
181        )
182    }
183
184    /// Handle the "shutdown" method.
185    fn handle_shutdown(&mut self, id: Value) -> JsonRpcResponse {
186        self.initialized = false;
187        JsonRpcResponse::success(id, json!(null))
188    }
189
190    /// Handle "tools/list".
191    fn handle_tools_list(&self, id: Value) -> JsonRpcResponse {
192        JsonRpcResponse::success(
193            id,
194            json!({
195                "tools": [
196                    {
197                        "name": "symbol_lookup",
198                        "description": "Look up symbols by name in the code graph.",
199                        "inputSchema": {
200                            "type": "object",
201                            "properties": {
202                                "graph": { "type": "string", "description": "Graph name" },
203                                "name": { "type": "string", "description": "Symbol name to search for" },
204                                "mode": { "type": "string", "enum": ["exact", "prefix", "contains", "fuzzy"], "default": "prefix" },
205                                "limit": { "type": "integer", "minimum": 1, "default": 10 }
206                            },
207                            "required": ["name"]
208                        }
209                    },
210                    {
211                        "name": "impact_analysis",
212                        "description": "Analyse the impact of changing a code unit.",
213                        "inputSchema": {
214                            "type": "object",
215                            "properties": {
216                                "graph": { "type": "string", "description": "Graph name" },
217                                "unit_id": { "type": "integer", "description": "Code unit ID to analyse" },
218                                "max_depth": { "type": "integer", "minimum": 0, "default": 3 }
219                            },
220                            "required": ["unit_id"]
221                        }
222                    },
223                    {
224                        "name": "graph_stats",
225                        "description": "Get summary statistics about a loaded code graph.",
226                        "inputSchema": {
227                            "type": "object",
228                            "properties": {
229                                "graph": { "type": "string", "description": "Graph name" }
230                            }
231                        }
232                    },
233                    {
234                        "name": "list_units",
235                        "description": "List code units in a graph, optionally filtered by type.",
236                        "inputSchema": {
237                            "type": "object",
238                            "properties": {
239                                "graph": { "type": "string", "description": "Graph name" },
240                                "unit_type": {
241                                    "type": "string",
242                                    "description": "Filter by unit type",
243                                    "enum": [
244                                        "module", "symbol", "type", "function", "parameter", "import",
245                                        "test", "doc", "config", "pattern", "trait", "impl", "macro"
246                                    ]
247                                },
248                                "limit": { "type": "integer", "default": 50 }
249                            }
250                        }
251                    },
252                    {
253                        "name": "analysis_log",
254                        "description": "Log the intent and context behind a code analysis. Call this to record WHY you are performing a lookup or analysis.",
255                        "inputSchema": {
256                            "type": "object",
257                            "properties": {
258                                "intent": {
259                                    "type": "string",
260                                    "description": "Why you are analysing — the goal or reason for the code query"
261                                },
262                                "finding": {
263                                    "type": "string",
264                                    "description": "What you found or concluded from the analysis"
265                                },
266                                "graph": {
267                                    "type": "string",
268                                    "description": "Optional graph name this analysis relates to"
269                                },
270                                "topic": {
271                                    "type": "string",
272                                    "description": "Optional topic or category (e.g., 'refactoring', 'bug-hunt')"
273                                }
274                            },
275                            "required": ["intent"]
276                        }
277                    }
278                ]
279            }),
280        )
281    }
282
283    /// Handle "tools/call".
284    fn handle_tools_call(&mut self, id: Value, params: &Value) -> JsonRpcResponse {
285        let tool_name = match params.get("name").and_then(|v| v.as_str()) {
286            Some(name) => name,
287            None => {
288                return JsonRpcResponse::error(
289                    id,
290                    JsonRpcError::invalid_params("Missing 'name' field in tools/call params"),
291                );
292            }
293        };
294
295        let arguments = params
296            .get("arguments")
297            .cloned()
298            .unwrap_or(Value::Object(serde_json::Map::new()));
299
300        let result = match tool_name {
301            "symbol_lookup" => self.tool_symbol_lookup(id.clone(), &arguments),
302            "impact_analysis" => self.tool_impact_analysis(id.clone(), &arguments),
303            "graph_stats" => self.tool_graph_stats(id.clone(), &arguments),
304            "list_units" => self.tool_list_units(id.clone(), &arguments),
305            "analysis_log" => return self.tool_analysis_log(id, &arguments),
306            _ => {
307                return JsonRpcResponse::error(
308                    id,
309                    JsonRpcError::method_not_found(format!("Unknown tool: {}", tool_name)),
310                );
311            }
312        };
313
314        // Auto-log the tool call (skip analysis_log to avoid recursion).
315        let now = std::time::SystemTime::now()
316            .duration_since(std::time::UNIX_EPOCH)
317            .unwrap_or_default()
318            .as_secs();
319        let summary = truncate_json_summary(&arguments, 200);
320        let graph_name = arguments
321            .get("graph")
322            .and_then(|v| v.as_str())
323            .map(String::from);
324        self.operation_log.push(OperationRecord {
325            tool_name: tool_name.to_string(),
326            summary,
327            timestamp: now,
328            graph_name,
329        });
330
331        result
332    }
333
334    /// Handle "resources/list".
335    fn handle_resources_list(&self, id: Value) -> JsonRpcResponse {
336        let mut resources = Vec::new();
337
338        for name in self.graphs.keys() {
339            resources.push(json!({
340                "uri": format!("acb://graphs/{}/stats", name),
341                "name": format!("{} statistics", name),
342                "description": format!("Statistics for the {} code graph.", name),
343                "mimeType": "application/json"
344            }));
345            resources.push(json!({
346                "uri": format!("acb://graphs/{}/units", name),
347                "name": format!("{} units", name),
348                "description": format!("All code units in the {} graph.", name),
349                "mimeType": "application/json"
350            }));
351        }
352
353        JsonRpcResponse::success(id, json!({ "resources": resources }))
354    }
355
356    /// Handle "resources/read".
357    fn handle_resources_read(&self, id: Value, params: &Value) -> JsonRpcResponse {
358        let uri = match params.get("uri").and_then(|v| v.as_str()) {
359            Some(u) => u,
360            None => {
361                return JsonRpcResponse::error(
362                    id,
363                    JsonRpcError::invalid_params("Missing 'uri' field"),
364                );
365            }
366        };
367
368        // Parse URI: acb://graphs/{name}/stats or acb://graphs/{name}/units
369        if let Some(rest) = uri.strip_prefix("acb://graphs/") {
370            let parts: Vec<&str> = rest.splitn(2, '/').collect();
371            if parts.len() == 2 {
372                let graph_name = parts[0];
373                let resource = parts[1];
374
375                if let Some(graph) = self.graphs.get(graph_name) {
376                    return match resource {
377                        "stats" => {
378                            let stats = graph.stats();
379                            JsonRpcResponse::success(
380                                id,
381                                json!({
382                                    "contents": [{
383                                        "uri": uri,
384                                        "mimeType": "application/json",
385                                        "text": serde_json::to_string_pretty(&json!({
386                                            "unit_count": stats.unit_count,
387                                            "edge_count": stats.edge_count,
388                                            "dimension": stats.dimension,
389                                        })).unwrap_or_default()
390                                    }]
391                                }),
392                            )
393                        }
394                        "units" => {
395                            let units: Vec<Value> = graph
396                                .units()
397                                .iter()
398                                .map(|u| {
399                                    json!({
400                                        "id": u.id,
401                                        "name": u.name,
402                                        "type": u.unit_type.label(),
403                                        "file": u.file_path.display().to_string(),
404                                    })
405                                })
406                                .collect();
407                            JsonRpcResponse::success(
408                                id,
409                                json!({
410                                    "contents": [{
411                                        "uri": uri,
412                                        "mimeType": "application/json",
413                                        "text": serde_json::to_string_pretty(&units).unwrap_or_default()
414                                    }]
415                                }),
416                            )
417                        }
418                        _ => JsonRpcResponse::error(
419                            id,
420                            JsonRpcError::invalid_params(format!(
421                                "Unknown resource type: {}",
422                                resource
423                            )),
424                        ),
425                    };
426                } else {
427                    return JsonRpcResponse::error(
428                        id,
429                        JsonRpcError::invalid_params(format!("Graph not found: {}", graph_name)),
430                    );
431                }
432            }
433        }
434
435        JsonRpcResponse::error(
436            id,
437            JsonRpcError::invalid_params(format!("Invalid resource URI: {}", uri)),
438        )
439    }
440
441    /// Handle "prompts/list".
442    fn handle_prompts_list(&self, id: Value) -> JsonRpcResponse {
443        JsonRpcResponse::success(
444            id,
445            json!({
446                "prompts": [
447                    {
448                        "name": "analyse_unit",
449                        "description": "Analyse a code unit including its dependencies, stability, and test coverage.",
450                        "arguments": [
451                            {
452                                "name": "graph",
453                                "description": "Graph name",
454                                "required": false
455                            },
456                            {
457                                "name": "unit_name",
458                                "description": "Name of the code unit to analyse",
459                                "required": true
460                            }
461                        ]
462                    },
463                    {
464                        "name": "explain_coupling",
465                        "description": "Explain coupling between two code units.",
466                        "arguments": [
467                            {
468                                "name": "graph",
469                                "description": "Graph name",
470                                "required": false
471                            },
472                            {
473                                "name": "unit_a",
474                                "description": "First unit name",
475                                "required": true
476                            },
477                            {
478                                "name": "unit_b",
479                                "description": "Second unit name",
480                                "required": true
481                            }
482                        ]
483                    }
484                ]
485            }),
486        )
487    }
488
489    // ========================================================================
490    // Tool implementations
491    // ========================================================================
492
493    /// Resolve a graph name from arguments, defaulting to the first loaded graph.
494    fn resolve_graph<'a>(
495        &'a self,
496        args: &'a Value,
497    ) -> Result<(&'a str, &'a CodeGraph), JsonRpcError> {
498        let graph_name = args.get("graph").and_then(|v| v.as_str()).unwrap_or("");
499
500        if graph_name.is_empty() {
501            // Use the first graph if available.
502            if let Some((name, graph)) = self.graphs.iter().next() {
503                return Ok((name.as_str(), graph));
504            }
505            return Err(JsonRpcError::invalid_params("No graphs loaded"));
506        }
507
508        self.graphs
509            .get(graph_name)
510            .map(|g| (graph_name, g))
511            .ok_or_else(|| JsonRpcError::invalid_params(format!("Graph not found: {}", graph_name)))
512    }
513
514    /// Tool: symbol_lookup.
515    fn tool_symbol_lookup(&self, id: Value, args: &Value) -> JsonRpcResponse {
516        let (_, graph) = match self.resolve_graph(args) {
517            Ok(g) => g,
518            Err(e) => return JsonRpcResponse::error(id, e),
519        };
520
521        let name = match args.get("name").and_then(|v| v.as_str()) {
522            Some(n) => n.to_string(),
523            None => {
524                return JsonRpcResponse::error(
525                    id,
526                    JsonRpcError::invalid_params("Missing 'name' argument"),
527                );
528            }
529        };
530
531        let mode_raw = args
532            .get("mode")
533            .and_then(|v| v.as_str())
534            .unwrap_or("prefix");
535        let mode = match mode_raw {
536            "exact" => MatchMode::Exact,
537            "prefix" => MatchMode::Prefix,
538            "contains" => MatchMode::Contains,
539            "fuzzy" => MatchMode::Fuzzy,
540            _ => {
541                return JsonRpcResponse::error(
542                    id,
543                    JsonRpcError::invalid_params(format!(
544                        "Invalid 'mode': {mode_raw}. Expected one of: exact, prefix, contains, fuzzy"
545                    )),
546                );
547            }
548        };
549
550        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
551
552        let params = SymbolLookupParams {
553            name,
554            mode,
555            limit,
556            ..SymbolLookupParams::default()
557        };
558
559        match self.engine.symbol_lookup(graph, params) {
560            Ok(units) => {
561                let results: Vec<Value> = units
562                    .iter()
563                    .map(|u| {
564                        json!({
565                            "id": u.id,
566                            "name": u.name,
567                            "qualified_name": u.qualified_name,
568                            "type": u.unit_type.label(),
569                            "file": u.file_path.display().to_string(),
570                            "language": u.language.name(),
571                            "complexity": u.complexity,
572                        })
573                    })
574                    .collect();
575                JsonRpcResponse::success(
576                    id,
577                    json!({
578                        "content": [{
579                            "type": "text",
580                            "text": serde_json::to_string_pretty(&results).unwrap_or_default()
581                        }]
582                    }),
583                )
584            }
585            Err(e) => JsonRpcResponse::error(id, JsonRpcError::internal_error(e.to_string())),
586        }
587    }
588
589    /// Tool: impact_analysis.
590    fn tool_impact_analysis(&self, id: Value, args: &Value) -> JsonRpcResponse {
591        let (_, graph) = match self.resolve_graph(args) {
592            Ok(g) => g,
593            Err(e) => return JsonRpcResponse::error(id, e),
594        };
595
596        let unit_id = match args.get("unit_id").and_then(|v| v.as_u64()) {
597            Some(uid) => uid,
598            None => {
599                return JsonRpcResponse::error(
600                    id,
601                    JsonRpcError::invalid_params("Missing 'unit_id' argument"),
602                );
603            }
604        };
605
606        let max_depth = match args.get("max_depth") {
607            None => 3,
608            Some(v) => {
609                let depth = match v.as_i64() {
610                    Some(d) => d,
611                    None => {
612                        return JsonRpcResponse::error(
613                            id,
614                            JsonRpcError::invalid_params("'max_depth' must be an integer >= 0"),
615                        );
616                    }
617                };
618                if depth < 0 {
619                    return JsonRpcResponse::error(
620                        id,
621                        JsonRpcError::invalid_params("'max_depth' must be >= 0"),
622                    );
623                }
624                depth as u32
625            }
626        };
627        let edge_types = vec![
628            EdgeType::Calls,
629            EdgeType::Imports,
630            EdgeType::Inherits,
631            EdgeType::Implements,
632            EdgeType::UsesType,
633            EdgeType::FfiBinds,
634            EdgeType::References,
635            EdgeType::Returns,
636            EdgeType::ParamType,
637            EdgeType::Overrides,
638            EdgeType::Contains,
639        ];
640
641        let params = ImpactParams {
642            unit_id,
643            max_depth,
644            edge_types,
645        };
646
647        match self.engine.impact_analysis(graph, params) {
648            Ok(result) => {
649                let impacted: Vec<Value> = result
650                    .impacted
651                    .iter()
652                    .map(|i| {
653                        json!({
654                            "unit_id": i.unit_id,
655                            "depth": i.depth,
656                            "risk_score": i.risk_score,
657                            "has_tests": i.has_tests,
658                        })
659                    })
660                    .collect();
661                JsonRpcResponse::success(
662                    id,
663                    json!({
664                        "content": [{
665                            "type": "text",
666                            "text": serde_json::to_string_pretty(&json!({
667                                "root_id": result.root_id,
668                                "overall_risk": result.overall_risk,
669                                "impacted_count": result.impacted.len(),
670                                "impacted": impacted,
671                                "recommendations": result.recommendations,
672                            })).unwrap_or_default()
673                        }]
674                    }),
675                )
676            }
677            Err(e) => JsonRpcResponse::error(id, JsonRpcError::internal_error(e.to_string())),
678        }
679    }
680
681    /// Tool: graph_stats.
682    fn tool_graph_stats(&self, id: Value, args: &Value) -> JsonRpcResponse {
683        let (name, graph) = match self.resolve_graph(args) {
684            Ok(g) => g,
685            Err(e) => return JsonRpcResponse::error(id, e),
686        };
687
688        let stats = graph.stats();
689        JsonRpcResponse::success(
690            id,
691            json!({
692                "content": [{
693                    "type": "text",
694                    "text": serde_json::to_string_pretty(&json!({
695                        "graph": name,
696                        "unit_count": stats.unit_count,
697                        "edge_count": stats.edge_count,
698                        "dimension": stats.dimension,
699                    })).unwrap_or_default()
700                }]
701            }),
702        )
703    }
704
705    /// Tool: list_units.
706    fn tool_list_units(&self, id: Value, args: &Value) -> JsonRpcResponse {
707        let (_, graph) = match self.resolve_graph(args) {
708            Ok(g) => g,
709            Err(e) => return JsonRpcResponse::error(id, e),
710        };
711
712        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
713        let unit_type_filter = match args.get("unit_type").and_then(|v| v.as_str()) {
714            Some(raw) => match Self::parse_unit_type(raw) {
715                Some(parsed) => Some(parsed),
716                None => {
717                    return JsonRpcResponse::error(
718                        id,
719                        JsonRpcError::invalid_params(format!(
720                            "Unknown unit_type '{}'. Expected one of: module, symbol, type, function, parameter, import, test, doc, config, pattern, trait, impl, macro.",
721                            raw
722                        )),
723                    );
724                }
725            },
726            None => None,
727        };
728
729        let units: Vec<Value> = graph
730            .units()
731            .iter()
732            .filter(|u| {
733                if let Some(expected) = unit_type_filter {
734                    u.unit_type == expected
735                } else {
736                    true
737                }
738            })
739            .take(limit)
740            .map(|u| {
741                json!({
742                    "id": u.id,
743                    "name": u.name,
744                    "type": u.unit_type.label(),
745                    "file": u.file_path.display().to_string(),
746                })
747            })
748            .collect();
749
750        JsonRpcResponse::success(
751            id,
752            json!({
753                "content": [{
754                    "type": "text",
755                    "text": serde_json::to_string_pretty(&units).unwrap_or_default()
756                }]
757            }),
758        )
759    }
760
761    /// Tool: analysis_log — record the intent/context behind a code analysis.
762    fn tool_analysis_log(&mut self, id: Value, args: &Value) -> JsonRpcResponse {
763        let intent = match args.get("intent").and_then(|v| v.as_str()) {
764            Some(i) if !i.trim().is_empty() => i,
765            _ => {
766                return JsonRpcResponse::error(
767                    id,
768                    JsonRpcError::invalid_params("'intent' is required and must not be empty"),
769                );
770            }
771        };
772
773        let finding = args.get("finding").and_then(|v| v.as_str());
774        let graph_name = args.get("graph").and_then(|v| v.as_str());
775        let topic = args.get("topic").and_then(|v| v.as_str());
776
777        let now = std::time::SystemTime::now()
778            .duration_since(std::time::UNIX_EPOCH)
779            .unwrap_or_default()
780            .as_secs();
781
782        let mut summary_parts = vec![format!("intent: {intent}")];
783        if let Some(f) = finding {
784            summary_parts.push(format!("finding: {f}"));
785        }
786        if let Some(t) = topic {
787            summary_parts.push(format!("topic: {t}"));
788        }
789
790        let record = OperationRecord {
791            tool_name: "analysis_log".to_string(),
792            summary: summary_parts.join(" | "),
793            timestamp: now,
794            graph_name: graph_name.map(String::from),
795        };
796
797        let index = self.operation_log.len();
798        self.operation_log.push(record);
799
800        JsonRpcResponse::success(
801            id,
802            json!({
803                "content": [{
804                    "type": "text",
805                    "text": serde_json::to_string_pretty(&json!({
806                        "log_index": index,
807                        "message": "Analysis context logged"
808                    })).unwrap_or_default()
809                }]
810            }),
811        )
812    }
813
814    /// Access the operation log.
815    pub fn operation_log(&self) -> &[OperationRecord] {
816        &self.operation_log
817    }
818}
819
820/// Truncate a JSON value to a short summary string.
821fn truncate_json_summary(value: &Value, max_len: usize) -> String {
822    let s = value.to_string();
823    if s.len() <= max_len {
824        s
825    } else {
826        format!("{}...", &s[..max_len])
827    }
828}
829
830impl Default for McpServer {
831    fn default() -> Self {
832        Self::new()
833    }
834}