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/// A synchronous MCP server that handles JSON-RPC 2.0 messages.
26///
27/// Holds loaded code graphs and dispatches tool/resource/prompt requests
28/// to the appropriate handler.
29#[derive(Debug)]
30pub struct McpServer {
31    /// Loaded code graphs keyed by name.
32    graphs: HashMap<String, CodeGraph>,
33    /// Query engine for executing queries.
34    engine: QueryEngine,
35    /// Whether the server has been initialised.
36    initialized: bool,
37}
38
39impl McpServer {
40    fn parse_unit_type(raw: &str) -> Option<CodeUnitType> {
41        match raw.trim().to_ascii_lowercase().as_str() {
42            "module" | "modules" => Some(CodeUnitType::Module),
43            "symbol" | "symbols" => Some(CodeUnitType::Symbol),
44            "type" | "types" => Some(CodeUnitType::Type),
45            "function" | "functions" => Some(CodeUnitType::Function),
46            "parameter" | "parameters" => Some(CodeUnitType::Parameter),
47            "import" | "imports" => Some(CodeUnitType::Import),
48            "test" | "tests" => Some(CodeUnitType::Test),
49            "doc" | "docs" | "document" | "documents" => Some(CodeUnitType::Doc),
50            "config" | "configs" => Some(CodeUnitType::Config),
51            "pattern" | "patterns" => Some(CodeUnitType::Pattern),
52            "trait" | "traits" => Some(CodeUnitType::Trait),
53            "impl" | "implementation" | "implementations" => Some(CodeUnitType::Impl),
54            "macro" | "macros" => Some(CodeUnitType::Macro),
55            _ => None,
56        }
57    }
58
59    /// Create a new MCP server with no loaded graphs.
60    pub fn new() -> Self {
61        Self {
62            graphs: HashMap::new(),
63            engine: QueryEngine::new(),
64            initialized: false,
65        }
66    }
67
68    /// Load a code graph into the server under the given name.
69    pub fn load_graph(&mut self, name: String, graph: CodeGraph) {
70        self.graphs.insert(name, graph);
71    }
72
73    /// Remove a loaded code graph.
74    pub fn unload_graph(&mut self, name: &str) -> Option<CodeGraph> {
75        self.graphs.remove(name)
76    }
77
78    /// Get a reference to a loaded graph by name.
79    pub fn get_graph(&self, name: &str) -> Option<&CodeGraph> {
80        self.graphs.get(name)
81    }
82
83    /// List all loaded graph names.
84    pub fn graph_names(&self) -> Vec<&str> {
85        self.graphs.keys().map(|s| s.as_str()).collect()
86    }
87
88    /// Check if the server has been initialised.
89    pub fn is_initialized(&self) -> bool {
90        self.initialized
91    }
92
93    /// Handle a raw JSON-RPC message string.
94    ///
95    /// Parses the message, dispatches to the appropriate handler, and
96    /// returns the serialised JSON-RPC response.
97    pub fn handle_raw(&mut self, raw: &str) -> String {
98        let response = match super::protocol::parse_request(raw) {
99            Ok(request) => {
100                if request.id.is_none() {
101                    self.handle_notification(&request.method, &request.params);
102                    return String::new();
103                }
104                self.handle_request(request)
105            }
106            Err(error_response) => error_response,
107        };
108        serde_json::to_string(&response).unwrap_or_else(|_| {
109            r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Serialization failed"}}"#
110                .to_string()
111        })
112    }
113
114    /// Handle a parsed JSON-RPC request.
115    pub fn handle_request(&mut self, request: JsonRpcRequest) -> JsonRpcResponse {
116        let id = request.id.clone().unwrap_or(Value::Null);
117        match request.method.as_str() {
118            "initialize" => self.handle_initialize(id, &request.params),
119            "shutdown" => self.handle_shutdown(id),
120            "tools/list" => self.handle_tools_list(id),
121            "tools/call" => self.handle_tools_call(id, &request.params),
122            "resources/list" => self.handle_resources_list(id),
123            "resources/read" => self.handle_resources_read(id, &request.params),
124            "prompts/list" => self.handle_prompts_list(id),
125            _ => JsonRpcResponse::error(id, JsonRpcError::method_not_found(&request.method)),
126        }
127    }
128
129    /// Handle JSON-RPC notifications (messages without an `id`).
130    ///
131    /// Notification methods intentionally produce no response frame.
132    fn handle_notification(&mut self, method: &str, _params: &Value) {
133        if method == "notifications/initialized" {
134            self.initialized = true;
135        }
136    }
137
138    // ========================================================================
139    // Method handlers
140    // ========================================================================
141
142    /// Handle the "initialize" method.
143    fn handle_initialize(&mut self, id: Value, _params: &Value) -> JsonRpcResponse {
144        self.initialized = true;
145        JsonRpcResponse::success(
146            id,
147            json!({
148                "protocolVersion": PROTOCOL_VERSION,
149                "capabilities": {
150                    "tools": { "listChanged": false },
151                    "resources": { "subscribe": false, "listChanged": false },
152                    "prompts": { "listChanged": false }
153                },
154                "serverInfo": {
155                    "name": SERVER_NAME,
156                    "version": SERVER_VERSION
157                }
158            }),
159        )
160    }
161
162    /// Handle the "shutdown" method.
163    fn handle_shutdown(&mut self, id: Value) -> JsonRpcResponse {
164        self.initialized = false;
165        JsonRpcResponse::success(id, json!(null))
166    }
167
168    /// Handle "tools/list".
169    fn handle_tools_list(&self, id: Value) -> JsonRpcResponse {
170        JsonRpcResponse::success(
171            id,
172            json!({
173                "tools": [
174                    {
175                        "name": "symbol_lookup",
176                        "description": "Look up symbols by name in the code graph.",
177                        "inputSchema": {
178                            "type": "object",
179                            "properties": {
180                                "graph": { "type": "string", "description": "Graph name" },
181                                "name": { "type": "string", "description": "Symbol name to search for" },
182                                "mode": { "type": "string", "enum": ["exact", "prefix", "contains", "fuzzy"], "default": "prefix" },
183                                "limit": { "type": "integer", "default": 10 }
184                            },
185                            "required": ["name"]
186                        }
187                    },
188                    {
189                        "name": "impact_analysis",
190                        "description": "Analyse the impact of changing a code unit.",
191                        "inputSchema": {
192                            "type": "object",
193                            "properties": {
194                                "graph": { "type": "string", "description": "Graph name" },
195                                "unit_id": { "type": "integer", "description": "Code unit ID to analyse" },
196                                "max_depth": { "type": "integer", "default": 3 }
197                            },
198                            "required": ["unit_id"]
199                        }
200                    },
201                    {
202                        "name": "graph_stats",
203                        "description": "Get summary statistics about a loaded code graph.",
204                        "inputSchema": {
205                            "type": "object",
206                            "properties": {
207                                "graph": { "type": "string", "description": "Graph name" }
208                            }
209                        }
210                    },
211                    {
212                        "name": "list_units",
213                        "description": "List code units in a graph, optionally filtered by type.",
214                        "inputSchema": {
215                            "type": "object",
216                            "properties": {
217                                "graph": { "type": "string", "description": "Graph name" },
218                                "unit_type": {
219                                    "type": "string",
220                                    "description": "Filter by unit type",
221                                    "enum": [
222                                        "module", "symbol", "type", "function", "parameter", "import",
223                                        "test", "doc", "config", "pattern", "trait", "impl", "macro"
224                                    ]
225                                },
226                                "limit": { "type": "integer", "default": 50 }
227                            }
228                        }
229                    }
230                ]
231            }),
232        )
233    }
234
235    /// Handle "tools/call".
236    fn handle_tools_call(&self, id: Value, params: &Value) -> JsonRpcResponse {
237        let tool_name = match params.get("name").and_then(|v| v.as_str()) {
238            Some(name) => name,
239            None => {
240                return JsonRpcResponse::error(
241                    id,
242                    JsonRpcError::invalid_params("Missing 'name' field in tools/call params"),
243                );
244            }
245        };
246
247        let arguments = params
248            .get("arguments")
249            .cloned()
250            .unwrap_or(Value::Object(serde_json::Map::new()));
251
252        match tool_name {
253            "symbol_lookup" => self.tool_symbol_lookup(id, &arguments),
254            "impact_analysis" => self.tool_impact_analysis(id, &arguments),
255            "graph_stats" => self.tool_graph_stats(id, &arguments),
256            "list_units" => self.tool_list_units(id, &arguments),
257            _ => JsonRpcResponse::error(
258                id,
259                JsonRpcError::method_not_found(format!("Unknown tool: {}", tool_name)),
260            ),
261        }
262    }
263
264    /// Handle "resources/list".
265    fn handle_resources_list(&self, id: Value) -> JsonRpcResponse {
266        let mut resources = Vec::new();
267
268        for name in self.graphs.keys() {
269            resources.push(json!({
270                "uri": format!("acb://graphs/{}/stats", name),
271                "name": format!("{} statistics", name),
272                "description": format!("Statistics for the {} code graph.", name),
273                "mimeType": "application/json"
274            }));
275            resources.push(json!({
276                "uri": format!("acb://graphs/{}/units", name),
277                "name": format!("{} units", name),
278                "description": format!("All code units in the {} graph.", name),
279                "mimeType": "application/json"
280            }));
281        }
282
283        JsonRpcResponse::success(id, json!({ "resources": resources }))
284    }
285
286    /// Handle "resources/read".
287    fn handle_resources_read(&self, id: Value, params: &Value) -> JsonRpcResponse {
288        let uri = match params.get("uri").and_then(|v| v.as_str()) {
289            Some(u) => u,
290            None => {
291                return JsonRpcResponse::error(
292                    id,
293                    JsonRpcError::invalid_params("Missing 'uri' field"),
294                );
295            }
296        };
297
298        // Parse URI: acb://graphs/{name}/stats or acb://graphs/{name}/units
299        if let Some(rest) = uri.strip_prefix("acb://graphs/") {
300            let parts: Vec<&str> = rest.splitn(2, '/').collect();
301            if parts.len() == 2 {
302                let graph_name = parts[0];
303                let resource = parts[1];
304
305                if let Some(graph) = self.graphs.get(graph_name) {
306                    return match resource {
307                        "stats" => {
308                            let stats = graph.stats();
309                            JsonRpcResponse::success(
310                                id,
311                                json!({
312                                    "contents": [{
313                                        "uri": uri,
314                                        "mimeType": "application/json",
315                                        "text": serde_json::to_string_pretty(&json!({
316                                            "unit_count": stats.unit_count,
317                                            "edge_count": stats.edge_count,
318                                            "dimension": stats.dimension,
319                                        })).unwrap_or_default()
320                                    }]
321                                }),
322                            )
323                        }
324                        "units" => {
325                            let units: Vec<Value> = graph
326                                .units()
327                                .iter()
328                                .map(|u| {
329                                    json!({
330                                        "id": u.id,
331                                        "name": u.name,
332                                        "type": u.unit_type.label(),
333                                        "file": u.file_path.display().to_string(),
334                                    })
335                                })
336                                .collect();
337                            JsonRpcResponse::success(
338                                id,
339                                json!({
340                                    "contents": [{
341                                        "uri": uri,
342                                        "mimeType": "application/json",
343                                        "text": serde_json::to_string_pretty(&units).unwrap_or_default()
344                                    }]
345                                }),
346                            )
347                        }
348                        _ => JsonRpcResponse::error(
349                            id,
350                            JsonRpcError::invalid_params(format!(
351                                "Unknown resource type: {}",
352                                resource
353                            )),
354                        ),
355                    };
356                } else {
357                    return JsonRpcResponse::error(
358                        id,
359                        JsonRpcError::invalid_params(format!("Graph not found: {}", graph_name)),
360                    );
361                }
362            }
363        }
364
365        JsonRpcResponse::error(
366            id,
367            JsonRpcError::invalid_params(format!("Invalid resource URI: {}", uri)),
368        )
369    }
370
371    /// Handle "prompts/list".
372    fn handle_prompts_list(&self, id: Value) -> JsonRpcResponse {
373        JsonRpcResponse::success(
374            id,
375            json!({
376                "prompts": [
377                    {
378                        "name": "analyse_unit",
379                        "description": "Analyse a code unit including its dependencies, stability, and test coverage.",
380                        "arguments": [
381                            {
382                                "name": "graph",
383                                "description": "Graph name",
384                                "required": false
385                            },
386                            {
387                                "name": "unit_name",
388                                "description": "Name of the code unit to analyse",
389                                "required": true
390                            }
391                        ]
392                    },
393                    {
394                        "name": "explain_coupling",
395                        "description": "Explain coupling between two code units.",
396                        "arguments": [
397                            {
398                                "name": "graph",
399                                "description": "Graph name",
400                                "required": false
401                            },
402                            {
403                                "name": "unit_a",
404                                "description": "First unit name",
405                                "required": true
406                            },
407                            {
408                                "name": "unit_b",
409                                "description": "Second unit name",
410                                "required": true
411                            }
412                        ]
413                    }
414                ]
415            }),
416        )
417    }
418
419    // ========================================================================
420    // Tool implementations
421    // ========================================================================
422
423    /// Resolve a graph name from arguments, defaulting to the first loaded graph.
424    fn resolve_graph<'a>(
425        &'a self,
426        args: &'a Value,
427    ) -> Result<(&'a str, &'a CodeGraph), JsonRpcError> {
428        let graph_name = args.get("graph").and_then(|v| v.as_str()).unwrap_or("");
429
430        if graph_name.is_empty() {
431            // Use the first graph if available.
432            if let Some((name, graph)) = self.graphs.iter().next() {
433                return Ok((name.as_str(), graph));
434            }
435            return Err(JsonRpcError::invalid_params("No graphs loaded"));
436        }
437
438        self.graphs
439            .get(graph_name)
440            .map(|g| (graph_name, g))
441            .ok_or_else(|| JsonRpcError::invalid_params(format!("Graph not found: {}", graph_name)))
442    }
443
444    /// Tool: symbol_lookup.
445    fn tool_symbol_lookup(&self, id: Value, args: &Value) -> JsonRpcResponse {
446        let (_, graph) = match self.resolve_graph(args) {
447            Ok(g) => g,
448            Err(e) => return JsonRpcResponse::error(id, e),
449        };
450
451        let name = match args.get("name").and_then(|v| v.as_str()) {
452            Some(n) => n.to_string(),
453            None => {
454                return JsonRpcResponse::error(
455                    id,
456                    JsonRpcError::invalid_params("Missing 'name' argument"),
457                );
458            }
459        };
460
461        let mode = match args
462            .get("mode")
463            .and_then(|v| v.as_str())
464            .unwrap_or("prefix")
465        {
466            "exact" => MatchMode::Exact,
467            "prefix" => MatchMode::Prefix,
468            "contains" => MatchMode::Contains,
469            "fuzzy" => MatchMode::Fuzzy,
470            _ => MatchMode::Prefix,
471        };
472
473        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
474
475        let params = SymbolLookupParams {
476            name,
477            mode,
478            limit,
479            ..SymbolLookupParams::default()
480        };
481
482        match self.engine.symbol_lookup(graph, params) {
483            Ok(units) => {
484                let results: Vec<Value> = units
485                    .iter()
486                    .map(|u| {
487                        json!({
488                            "id": u.id,
489                            "name": u.name,
490                            "qualified_name": u.qualified_name,
491                            "type": u.unit_type.label(),
492                            "file": u.file_path.display().to_string(),
493                            "language": u.language.name(),
494                            "complexity": u.complexity,
495                        })
496                    })
497                    .collect();
498                JsonRpcResponse::success(
499                    id,
500                    json!({
501                        "content": [{
502                            "type": "text",
503                            "text": serde_json::to_string_pretty(&results).unwrap_or_default()
504                        }]
505                    }),
506                )
507            }
508            Err(e) => JsonRpcResponse::error(id, JsonRpcError::internal_error(e.to_string())),
509        }
510    }
511
512    /// Tool: impact_analysis.
513    fn tool_impact_analysis(&self, id: Value, args: &Value) -> JsonRpcResponse {
514        let (_, graph) = match self.resolve_graph(args) {
515            Ok(g) => g,
516            Err(e) => return JsonRpcResponse::error(id, e),
517        };
518
519        let unit_id = match args.get("unit_id").and_then(|v| v.as_u64()) {
520            Some(uid) => uid,
521            None => {
522                return JsonRpcResponse::error(
523                    id,
524                    JsonRpcError::invalid_params("Missing 'unit_id' argument"),
525                );
526            }
527        };
528
529        let max_depth = args.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(3) as u32;
530        let edge_types = vec![
531            EdgeType::Calls,
532            EdgeType::Imports,
533            EdgeType::Inherits,
534            EdgeType::Implements,
535            EdgeType::UsesType,
536            EdgeType::FfiBinds,
537            EdgeType::References,
538            EdgeType::Returns,
539            EdgeType::ParamType,
540            EdgeType::Overrides,
541            EdgeType::Contains,
542        ];
543
544        let params = ImpactParams {
545            unit_id,
546            max_depth,
547            edge_types,
548        };
549
550        match self.engine.impact_analysis(graph, params) {
551            Ok(result) => {
552                let impacted: Vec<Value> = result
553                    .impacted
554                    .iter()
555                    .map(|i| {
556                        json!({
557                            "unit_id": i.unit_id,
558                            "depth": i.depth,
559                            "risk_score": i.risk_score,
560                            "has_tests": i.has_tests,
561                        })
562                    })
563                    .collect();
564                JsonRpcResponse::success(
565                    id,
566                    json!({
567                        "content": [{
568                            "type": "text",
569                            "text": serde_json::to_string_pretty(&json!({
570                                "root_id": result.root_id,
571                                "overall_risk": result.overall_risk,
572                                "impacted_count": result.impacted.len(),
573                                "impacted": impacted,
574                                "recommendations": result.recommendations,
575                            })).unwrap_or_default()
576                        }]
577                    }),
578                )
579            }
580            Err(e) => JsonRpcResponse::error(id, JsonRpcError::internal_error(e.to_string())),
581        }
582    }
583
584    /// Tool: graph_stats.
585    fn tool_graph_stats(&self, id: Value, args: &Value) -> JsonRpcResponse {
586        let (name, graph) = match self.resolve_graph(args) {
587            Ok(g) => g,
588            Err(e) => return JsonRpcResponse::error(id, e),
589        };
590
591        let stats = graph.stats();
592        JsonRpcResponse::success(
593            id,
594            json!({
595                "content": [{
596                    "type": "text",
597                    "text": serde_json::to_string_pretty(&json!({
598                        "graph": name,
599                        "unit_count": stats.unit_count,
600                        "edge_count": stats.edge_count,
601                        "dimension": stats.dimension,
602                    })).unwrap_or_default()
603                }]
604            }),
605        )
606    }
607
608    /// Tool: list_units.
609    fn tool_list_units(&self, id: Value, args: &Value) -> JsonRpcResponse {
610        let (_, graph) = match self.resolve_graph(args) {
611            Ok(g) => g,
612            Err(e) => return JsonRpcResponse::error(id, e),
613        };
614
615        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
616        let unit_type_filter = match args.get("unit_type").and_then(|v| v.as_str()) {
617            Some(raw) => match Self::parse_unit_type(raw) {
618                Some(parsed) => Some(parsed),
619                None => {
620                    return JsonRpcResponse::error(
621                        id,
622                        JsonRpcError::invalid_params(format!(
623                            "Unknown unit_type '{}'. Expected one of: module, symbol, type, function, parameter, import, test, doc, config, pattern, trait, impl, macro.",
624                            raw
625                        )),
626                    );
627                }
628            },
629            None => None,
630        };
631
632        let units: Vec<Value> = graph
633            .units()
634            .iter()
635            .filter(|u| {
636                if let Some(expected) = unit_type_filter {
637                    u.unit_type == expected
638                } else {
639                    true
640                }
641            })
642            .take(limit)
643            .map(|u| {
644                json!({
645                    "id": u.id,
646                    "name": u.name,
647                    "type": u.unit_type.label(),
648                    "file": u.file_path.display().to_string(),
649                })
650            })
651            .collect();
652
653        JsonRpcResponse::success(
654            id,
655            json!({
656                "content": [{
657                    "type": "text",
658                    "text": serde_json::to_string_pretty(&units).unwrap_or_default()
659                }]
660            }),
661        )
662    }
663}
664
665impl Default for McpServer {
666    fn default() -> Self {
667        Self::new()
668    }
669}