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