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}