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