Skip to main content

tessera_codegraph/
mcp.rs

1use std::io::{self, BufRead, Write};
2use std::path::Path;
3
4use anyhow::Result;
5use rusqlite::Connection;
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8
9use crate::db;
10use crate::query;
11use crate::types::{Language, SearchOptions, UnusedOptions};
12
13#[derive(Debug, Deserialize)]
14pub struct JsonRpcRequest {
15    id: Option<Value>,
16    method: String,
17    #[serde(default)]
18    params: Value,
19}
20
21#[derive(Debug, Serialize)]
22pub struct JsonRpcResponse {
23    jsonrpc: &'static str,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    id: Option<Value>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    result: Option<Value>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    error: Option<JsonRpcError>,
30}
31
32#[derive(Debug, Serialize)]
33struct JsonRpcError {
34    code: i64,
35    message: String,
36}
37
38pub fn serve_stdio(db_path: &Path) -> Result<()> {
39    let conn = db::open_existing(db_path)?;
40    let stdin = io::stdin();
41    let mut stdout = io::stdout();
42
43    for line in stdin.lock().lines() {
44        let line = line?;
45        if line.trim().is_empty() {
46            continue;
47        }
48        let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
49            Ok(request) => handle_request(&conn, db_path, request),
50            Err(error) => JsonRpcResponse {
51                jsonrpc: "2.0",
52                id: None,
53                result: None,
54                error: Some(JsonRpcError {
55                    code: -32700,
56                    message: error.to_string(),
57                }),
58            },
59        };
60        writeln!(stdout, "{}", serde_json::to_string(&response)?)?;
61        stdout.flush()?;
62    }
63    Ok(())
64}
65
66pub fn handle_json_rpc(conn: &Connection, db_path: &Path, body: &str) -> JsonRpcResponse {
67    match serde_json::from_str::<JsonRpcRequest>(body) {
68        Ok(request) => handle_request(conn, db_path, request),
69        Err(error) => JsonRpcResponse {
70            jsonrpc: "2.0",
71            id: None,
72            result: None,
73            error: Some(JsonRpcError {
74                code: -32700,
75                message: error.to_string(),
76            }),
77        },
78    }
79}
80
81fn handle_request(conn: &Connection, db_path: &Path, request: JsonRpcRequest) -> JsonRpcResponse {
82    let id = request.id.clone();
83    let result = match request.method.as_str() {
84        "initialize" => Ok(json!({
85            "protocolVersion": "2024-11-05",
86            "serverInfo": { "name": "tessera", "version": env!("CARGO_PKG_VERSION") },
87            "capabilities": { "tools": {} }
88        })),
89        "notifications/initialized" => Ok(json!({})),
90        "tools/list" => Ok(json!({ "tools": tools() })),
91        "tools/call" => call_tool(conn, db_path, &request.params),
92        _ => Err(format!("unknown method: {}", request.method)),
93    };
94
95    match result {
96        Ok(value) => JsonRpcResponse {
97            jsonrpc: "2.0",
98            id,
99            result: Some(value),
100            error: None,
101        },
102        Err(message) => JsonRpcResponse {
103            jsonrpc: "2.0",
104            id,
105            result: None,
106            error: Some(JsonRpcError {
107                code: -32000,
108                message,
109            }),
110        },
111    }
112}
113
114fn call_tool(
115    conn: &Connection,
116    db_path: &Path,
117    params: &Value,
118) -> std::result::Result<Value, String> {
119    let name = params
120        .get("name")
121        .and_then(Value::as_str)
122        .ok_or_else(|| "tools/call params.name is required".to_string())?;
123    let args = params
124        .get("arguments")
125        .cloned()
126        .unwrap_or_else(|| json!({}));
127
128    let result = match name {
129        "find_definition" => {
130            let symbol = arg_string(&args, "symbol")?;
131            serde_json::to_value(query::find_definition_conn(conn, &symbol).map_err(to_string)?)
132        }
133        "find_references" => {
134            let symbol = arg_string(&args, "symbol")?;
135            serde_json::to_value(query::find_references_conn(conn, &symbol).map_err(to_string)?)
136        }
137        "get_outline" => {
138            let path = arg_string(&args, "path")?;
139            serde_json::to_value(
140                query::get_outline_conn(conn, Path::new(&path)).map_err(to_string)?,
141            )
142        }
143        "expand_symbol" => {
144            let symbol = arg_string(&args, "symbol")?;
145            serde_json::to_value(query::expand_symbol_conn(conn, &symbol).map_err(to_string)?)
146        }
147        "impact" => {
148            let symbol = arg_string(&args, "symbol")?;
149            let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(4) as usize;
150            serde_json::to_value(query::impact_conn(conn, &symbol, depth).map_err(to_string)?)
151        }
152        "validate" => {
153            let symbol = arg_string(&args, "symbol")?;
154            serde_json::to_value(query::validate_conn(conn, &symbol).map_err(to_string)?)
155        }
156        "validate_snippet" => {
157            let code = arg_string(&args, "code")?;
158            let language = args
159                .get("language")
160                .and_then(Value::as_str)
161                .and_then(Language::from_name)
162                .ok_or_else(|| {
163                    "argument `language` is required (typescript|tsx|javascript|python|go|rust|java|c|cpp|csharp|ruby|php)"
164                        .to_string()
165                })?;
166            serde_json::to_value(
167                query::validate_snippet_conn(conn, &code, language).map_err(to_string)?,
168            )
169        }
170        "stats" => serde_json::to_value(query::stats_conn(conn, db_path).map_err(to_string)?),
171        "tests_for" => {
172            let symbol = arg_string(&args, "symbol")?;
173            serde_json::to_value(query::tests_for_conn(conn, &symbol).map_err(to_string)?)
174        }
175        "connect" => {
176            let from = arg_string(&args, "from")?;
177            let to = arg_string(&args, "to")?;
178            let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(8) as usize;
179            serde_json::to_value(query::connect_conn(conn, &from, &to, depth).map_err(to_string)?)
180        }
181        "export" => {
182            let format = args
183                .get("format")
184                .and_then(Value::as_str)
185                .unwrap_or("mermaid");
186            let from = args.get("from").and_then(Value::as_str);
187            let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(3) as usize;
188            let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(800) as usize;
189            let mut options = query::ExportOptions::new(format, from, depth, limit);
190            options.group_by = match args
191                .get("group_by")
192                .and_then(Value::as_str)
193                .unwrap_or("none")
194            {
195                "file" => query::ExportGroupBy::File,
196                "directory" => query::ExportGroupBy::Directory,
197                "language" => query::ExportGroupBy::Language,
198                _ => query::ExportGroupBy::None,
199            };
200            options.collapse_tests = args
201                .get("collapse_tests")
202                .and_then(Value::as_bool)
203                .unwrap_or(false);
204            options.exported_only = args
205                .get("exported_only")
206                .and_then(Value::as_bool)
207                .unwrap_or(false);
208            serde_json::to_value(query::export_conn_with_options(conn, options).map_err(to_string)?)
209        }
210        "context_pack" => {
211            let symbol = arg_string(&args, "symbol")?;
212            let budget = args
213                .get("budget")
214                .and_then(Value::as_u64)
215                .map(|n| n as usize)
216                .unwrap_or(1500);
217            serde_json::to_value(
218                query::context_pack_conn(conn, &symbol, budget).map_err(to_string)?,
219            )
220        }
221        "plan_query" => {
222            let task = arg_string(&args, "task")?;
223            let symbol = args.get("symbol").and_then(Value::as_str);
224            serde_json::to_value(query::plan_query(&task, symbol))
225        }
226        "edit_prep" => {
227            let symbol = arg_string(&args, "symbol")?;
228            let budget = args
229                .get("budget")
230                .and_then(Value::as_u64)
231                .map(|n| n as usize)
232                .unwrap_or(1800);
233            serde_json::to_value(query::edit_prep_conn(conn, &symbol, budget).map_err(to_string)?)
234        }
235        "diff_impact" => {
236            let from = arg_string(&args, "from")?;
237            let to = args
238                .get("to")
239                .and_then(Value::as_str)
240                .map(ToOwned::to_owned);
241            let depth = args
242                .get("depth")
243                .and_then(Value::as_u64)
244                .map(|n| n as usize)
245                .unwrap_or(3);
246            serde_json::to_value(
247                query::diff_impact_conn(conn, &from, to.as_deref(), depth).map_err(to_string)?,
248            )
249        }
250        "imports" => {
251            let path = arg_string(&args, "path")?;
252            serde_json::to_value(query::imports_conn(conn, &path).map_err(to_string)?)
253        }
254        "imported_by" => {
255            let source = arg_string(&args, "source")?;
256            serde_json::to_value(query::imported_by_conn(conn, &source).map_err(to_string)?)
257        }
258        "signature" => {
259            let symbol = arg_string(&args, "symbol")?;
260            serde_json::to_value(query::signature_conn(conn, &symbol).map_err(to_string)?)
261        }
262        "siblings" => {
263            let symbol = arg_string(&args, "symbol")?;
264            serde_json::to_value(query::siblings_conn(conn, &symbol).map_err(to_string)?)
265        }
266        "search" => {
267            let pattern = arg_string(&args, "pattern")?;
268            let kinds = string_array(&args, "kinds");
269            let languages = string_array(&args, "languages");
270            let exported = args.get("exported").and_then(Value::as_bool);
271            let path_prefix = args
272                .get("path_prefix")
273                .and_then(Value::as_str)
274                .map(ToOwned::to_owned);
275            let limit = args
276                .get("limit")
277                .and_then(Value::as_u64)
278                .map(|n| n as usize)
279                .unwrap_or(50);
280            let options = SearchOptions {
281                kinds,
282                languages,
283                exported,
284                path_prefix,
285                limit,
286            };
287            serde_json::to_value(query::search_conn(conn, &pattern, options).map_err(to_string)?)
288        }
289        "unused" => {
290            let kinds = string_array(&args, "kinds");
291            let languages = string_array(&args, "languages");
292            let exported = args.get("exported").and_then(Value::as_bool);
293            let path_prefix = args
294                .get("path_prefix")
295                .and_then(Value::as_str)
296                .map(ToOwned::to_owned);
297            let limit = args
298                .get("limit")
299                .and_then(Value::as_u64)
300                .map(|n| n as usize)
301                .unwrap_or(50);
302            let options = UnusedOptions {
303                kinds,
304                languages,
305                exported,
306                path_prefix,
307                limit,
308            };
309            serde_json::to_value(query::unused_conn(conn, options).map_err(to_string)?)
310        }
311        _ => return Err(format!("unknown tool: {name}")),
312    }
313    .map_err(to_string)?;
314
315    Ok(json!({
316        "content": [
317            {
318                "type": "text",
319                "text": serde_json::to_string_pretty(&result).map_err(to_string)?
320            }
321        ],
322        "structuredContent": result
323    }))
324}
325
326fn arg_string(args: &Value, key: &str) -> std::result::Result<String, String> {
327    args.get(key)
328        .and_then(Value::as_str)
329        .map(ToOwned::to_owned)
330        .ok_or_else(|| format!("argument `{key}` is required"))
331}
332
333fn string_array(args: &Value, key: &str) -> Vec<String> {
334    args.get(key)
335        .and_then(Value::as_array)
336        .map(|arr| {
337            arr.iter()
338                .filter_map(|v| v.as_str().map(ToOwned::to_owned))
339                .collect()
340        })
341        .unwrap_or_default()
342}
343
344fn tools() -> Value {
345    json!([
346        {
347            "name": "find_definition",
348            "description": "Find exact file/line definitions and signatures for a symbol.",
349            "inputSchema": {
350                "type": "object",
351                "properties": { "symbol": { "type": "string" } },
352                "required": ["symbol"]
353            }
354        },
355        {
356            "name": "find_references",
357            "description": "Find callers or reference sites for a symbol with one-line context.",
358            "inputSchema": {
359                "type": "object",
360                "properties": { "symbol": { "type": "string" } },
361                "required": ["symbol"]
362            }
363        },
364        {
365            "name": "get_outline",
366            "description": "Return a semantic outline for a file or directory without function bodies.",
367            "inputSchema": {
368                "type": "object",
369                "properties": { "path": { "type": "string" } },
370                "required": ["path"]
371            }
372        },
373        {
374            "name": "expand_symbol",
375            "description": "Return a symbol body plus immediate dependencies.",
376            "inputSchema": {
377                "type": "object",
378                "properties": { "symbol": { "type": "string" } },
379                "required": ["symbol"]
380            }
381        },
382        {
383            "name": "impact",
384            "description": "Return transitive callers ranked by personalised PageRank with a criticality breakdown.",
385            "inputSchema": {
386                "type": "object",
387                "properties": {
388                    "symbol": { "type": "string" },
389                    "depth": { "type": "integer", "minimum": 1, "maximum": 10 }
390                },
391                "required": ["symbol"]
392            }
393        },
394        {
395            "name": "validate",
396            "description": "Check whether a symbol exists in the graph; return near-miss candidates with confidence scores when it doesn't.",
397            "inputSchema": {
398                "type": "object",
399                "properties": { "symbol": { "type": "string" } },
400                "required": ["symbol"]
401            }
402        },
403        {
404            "name": "validate_snippet",
405            "description": "Parse a code snippet and check every call against the graph. Returns per-call resolution status plus near-miss suggestions for unresolved calls.",
406            "inputSchema": {
407                "type": "object",
408                "properties": {
409                    "code": { "type": "string" },
410                    "language": {
411                        "type": "string",
412                        "enum": ["typescript", "tsx", "javascript", "python", "go", "rust", "java", "c", "cpp", "csharp", "ruby", "php"]
413                    }
414                },
415                "required": ["code", "language"]
416            }
417        },
418        {
419            "name": "stats",
420            "description": "Summary statistics about the index: counts, languages, kinds, top fan-out symbols.",
421            "inputSchema": { "type": "object", "properties": {} }
422        },
423        {
424            "name": "tests_for",
425            "description": "Return the minimal set of tests whose call graph transitively touches the given symbol.",
426            "inputSchema": {
427                "type": "object",
428                "properties": { "symbol": { "type": "string" } },
429                "required": ["symbol"]
430            }
431        },
432        {
433            "name": "context_pack",
434            "description": "Bundle a symbol's body + immediate-dep signatures + top caller signatures + relevant tests into a single token-budgeted response. Replaces 3-5 round trips an agent would otherwise make to prep an edit.",
435            "inputSchema": {
436                "type": "object",
437                "properties": {
438                    "symbol": { "type": "string" },
439                    "budget": { "type": "integer", "minimum": 200, "maximum": 8000, "description": "Approximate token budget for the entire response. Default 1500." }
440                },
441                "required": ["symbol"]
442            }
443        },
444        {
445            "name": "plan_query",
446            "description": "Given a natural-language coding task, recommend the cheapest ordered Tessera tool sequence an agent should run before spending broader context.",
447            "inputSchema": {
448                "type": "object",
449                "properties": {
450                    "task": { "type": "string", "description": "Task shape, for example `edit findById safely` or `review this PR`." },
451                    "symbol": { "type": "string", "description": "Optional known symbol to substitute into commands." }
452                },
453                "required": ["task"]
454            }
455        },
456        {
457            "name": "edit_prep",
458            "description": "One-call pre-edit bundle: validate the target, return its signature, related sibling symbols, budgeted context pack, relevant tests, and next recommended checks.",
459            "inputSchema": {
460                "type": "object",
461                "properties": {
462                    "symbol": { "type": "string" },
463                    "budget": { "type": "integer", "minimum": 400, "maximum": 8000, "description": "Approximate token budget for embedded context. Default 1800." }
464                },
465                "required": ["symbol"]
466            }
467        },
468        {
469            "name": "diff_impact",
470            "description": "Given a git ref range, return the symbols that changed plus the PageRank-impacted callers downstream. The PR-review token saver.",
471            "inputSchema": {
472                "type": "object",
473                "properties": {
474                    "from": { "type": "string", "description": "Base ref (e.g. main, origin/main, HEAD~3)" },
475                    "to": { "type": "string", "description": "Tip ref. Defaults to HEAD." },
476                    "depth": { "type": "integer", "minimum": 1, "maximum": 6 }
477                },
478                "required": ["from"]
479            }
480        },
481        {
482            "name": "imports",
483            "description": "List the imports declared in a file or directory (use, import, require, etc.).",
484            "inputSchema": {
485                "type": "object",
486                "properties": { "path": { "type": "string" } },
487                "required": ["path"]
488            }
489        },
490        {
491            "name": "imported_by",
492            "description": "List files that import a given module / source path. Inverse of `imports`.",
493            "inputSchema": {
494                "type": "object",
495                "properties": { "source": { "type": "string" } },
496                "required": ["source"]
497            }
498        },
499        {
500            "name": "signature",
501            "description": "Ultra-cheap signature lookup. For class/struct/interface/trait/enum/record/impl, also returns child member signatures (no bodies).",
502            "inputSchema": {
503                "type": "object",
504                "properties": { "symbol": { "type": "string" } },
505                "required": ["symbol"]
506            }
507        },
508        {
509            "name": "siblings",
510            "description": "Symbols that share callers with the target, ranked by overlap count. Useful for finding the cluster of related abstractions to refactor together.",
511            "inputSchema": {
512                "type": "object",
513                "properties": { "symbol": { "type": "string" } },
514                "required": ["symbol"]
515            }
516        },
517        {
518            "name": "search",
519            "description": "Fuzzy / glob search across indexed symbols. Supports `*` wildcards and substring/identifier matching. Filterable by kind, language, exported, and path prefix. Use this instead of running grep + read on file contents.",
520            "inputSchema": {
521                "type": "object",
522                "properties": {
523                    "pattern": { "type": "string", "description": "Substring, identifier, or `glob*` pattern" },
524                    "kinds": { "type": "array", "items": { "type": "string" } },
525                    "languages": { "type": "array", "items": { "type": "string" } },
526                    "exported": { "type": "boolean" },
527                    "path_prefix": { "type": "string" },
528                    "limit": { "type": "integer", "minimum": 1, "maximum": 500 }
529                },
530                "required": ["pattern"]
531            }
532        },
533        {
534            "name": "connect",
535            "description": "Find the shortest call path from one symbol to another (does A transitively call B, and how?). Deterministic graph traversal — returns the ordered chain of calls or reports no path.",
536            "inputSchema": {
537                "type": "object",
538                "properties": {
539                    "from": { "type": "string", "description": "Source symbol (the caller side)." },
540                    "to": { "type": "string", "description": "Target symbol to reach." },
541                    "depth": { "type": "integer", "minimum": 1, "maximum": 12, "description": "Max hops to search. Default 8." }
542                },
543                "required": ["from", "to"]
544            }
545        },
546        {
547            "name": "export",
548            "description": "Export the call graph as Graphviz DOT or Mermaid. Whole graph by default, or the forward call subgraph rooted at `from`. Useful for visualising structure or embedding a diagram.",
549            "inputSchema": {
550                "type": "object",
551                "properties": {
552                    "format": { "type": "string", "enum": ["mermaid", "dot"] },
553                    "from": { "type": "string", "description": "Root the export at this symbol's forward call subgraph." },
554                    "depth": { "type": "integer", "minimum": 1, "maximum": 12, "description": "Traversal depth when `from` is set. Default 3." },
555                    "limit": { "type": "integer", "minimum": 1, "maximum": 5000, "description": "Max edges to emit. Default 800." },
556                    "group_by": { "type": "string", "enum": ["none", "file", "directory", "language"], "description": "Group rendered nodes into Mermaid subgraphs or DOT clusters." },
557                    "collapse_tests": { "type": "boolean", "description": "Hide test/spec nodes and their edges." },
558                    "exported_only": { "type": "boolean", "description": "Only include edges whose endpoints are exported symbols." }
559                }
560            }
561        },
562        {
563            "name": "unused",
564            "description": "Find indexed symbols with no inbound references or call edges. Test files are excluded from the report.",
565            "inputSchema": {
566                "type": "object",
567                "properties": {
568                    "kinds": { "type": "array", "items": { "type": "string" }, "description": "Optional symbol kind filters such as function, method, class, struct." },
569                    "languages": { "type": "array", "items": { "type": "string" }, "description": "Optional language filters such as typescript, rust, java." },
570                    "exported": { "type": "boolean", "description": "When set, only include symbols whose exported flag matches this value." },
571                    "path_prefix": { "type": "string", "description": "Only include symbols whose file path starts with this prefix." },
572                    "limit": { "type": "integer", "minimum": 1, "maximum": 500, "description": "Maximum number of symbols to return. Default 50." }
573                }
574            }
575        }
576    ])
577}
578
579fn to_string(error: impl std::fmt::Display) -> String {
580    error.to_string()
581}