1#![forbid(unsafe_code)]
2
3use rpi_plugin_sdk::handles::ToolHandles;
4use std::ffi::c_void;
5use std::io::{BufRead, BufReader, Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Child, ChildStdin, Command, Stdio};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{mpsc, Arc, Mutex};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use rpi_plugin_sdk::{
14 FreeStringFn, StbString, StbStringRef, StepHandle, StepResult, ToolPartialCb,
15};
16use serde_json::{json, Value};
17
18const SESSION_TIMEOUT: Duration = Duration::from_secs(20);
19const POLL_INTERVAL: Duration = Duration::from_millis(50);
20const MAX_OUTPUT_CHARS: usize = 25_000;
21const MAX_DIAGNOSTIC_CHARS: usize = 1_000;
22
23static HANDLES: ToolHandles<Drive> = ToolHandles::new();
24
25struct Drive {
26 tool: &'static str,
27 params: Value,
28 completed: bool,
29}
30
31struct ToolSpec {
32 name: &'static str,
33 description: &'static str,
34 parameters: String,
35 execute: extern "C" fn(StbStringRef, StbString, Option<FreeStringFn>) -> StepHandle,
36}
37
38fn project_root(params: &Value) -> Result<PathBuf, String> {
39 let requested = params
40 .get("projectPath")
41 .and_then(Value::as_str)
42 .filter(|value| !value.trim().is_empty());
43 let root = match requested {
44 Some(value) => PathBuf::from(value.trim()),
45 None => std::env::current_dir()
46 .map_err(|err| format!("cannot resolve current directory: {err}"))?,
47 };
48 if !root.is_absolute() {
49 return Err("CodeGraph projectPath must be an absolute path".into());
50 }
51 if !root.is_dir() {
52 return Err(format!(
53 "CodeGraph projectPath is not an accessible directory: {}",
54 root.display()
55 ));
56 }
57 Ok(root)
58}
59
60#[cfg(windows)]
64fn resolve_codegraph_cmd() -> Result<Command, String> {
65 let output = Command::new("powershell.exe")
66 .args([
67 "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
68 "-Command",
69 "(Get-Command codegraph -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source",
70 ])
71 .output()
72 .map_err(|e| format!("failed to locate codegraph: {e}"))?;
73 if !output.status.success() {
74 return Err("codegraph command not found; install @colbymchenry/codegraph".into());
75 }
76 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
77 if path.is_empty() {
78 return Err("codegraph command not found; install @colbymchenry/codegraph".into());
79 }
80 Ok(Command::new(path))
81}
82
83#[cfg(not(windows))]
84fn resolve_codegraph_cmd() -> Result<Command, String> {
85 Ok(Command::new("codegraph"))
86}
87
88fn ensure_initialized(root: &Path) -> Result<(), String> {
91 if root.join(".codegraph").is_dir() {
92 return Ok(());
93 }
94 let mut cmd = resolve_codegraph_cmd()?;
95 let output = cmd
96 .args(["init"])
97 .arg(root)
98 .current_dir(root)
99 .stdin(Stdio::null())
100 .stdout(Stdio::piped())
101 .stderr(Stdio::piped())
102 .output()
103 .map_err(|e| format!("failed to run codegraph init: {e}"))?;
104 if !output.status.success() {
105 let stderr = String::from_utf8_lossy(&output.stderr);
106 return Err(format!("codegraph init failed: {stderr}"));
107 }
108 Ok(())
109}
110
111fn sync_index(root: &Path) -> Result<(), String> {
113 let mut cmd = resolve_codegraph_cmd()?;
114 let output = cmd
115 .args(["sync"])
116 .arg(root)
117 .current_dir(root)
118 .stdin(Stdio::null())
119 .stdout(Stdio::piped())
120 .stderr(Stdio::piped())
121 .output()
122 .map_err(|e| format!("failed to run codegraph sync: {e}"))?;
123 if !output.status.success() {
124 let stderr = String::from_utf8_lossy(&output.stderr);
125 return Err(format!("codegraph sync failed: {stderr}"));
126 }
127 Ok(())
128}
129
130#[cfg(windows)]
131fn spawn_server(root: &Path) -> Result<Child, String> {
132 const SCRIPT: &str = concat!(
135 "& { param([string]$ProjectPath) ",
136 "$ErrorActionPreference = 'Stop'; ",
137 "$cmd = Get-Command codegraph -CommandType Application -ErrorAction Stop | Select-Object -First 1; ",
138 "if (-not $cmd) { throw 'codegraph command not found'; }; ",
139 "& $cmd.Source serve --mcp --path $ProjectPath; exit $LASTEXITCODE; }"
140 );
141 Command::new("powershell.exe")
142 .args([
143 "-NoProfile",
144 "-NonInteractive",
145 "-ExecutionPolicy",
146 "Bypass",
147 "-Command",
148 SCRIPT,
149 ])
150 .arg(root)
151 .current_dir(root)
152 .stdin(Stdio::piped())
153 .stdout(Stdio::piped())
154 .stderr(Stdio::piped())
155 .spawn()
156 .map_err(|err| format!("failed to start CodeGraph MCP server: {err}"))
157}
158
159#[cfg(not(windows))]
160fn spawn_server(root: &Path) -> Result<Child, String> {
161 Command::new("codegraph")
162 .args(["serve", "--mcp", "--path"])
163 .arg(root)
164 .current_dir(root)
165 .stdin(Stdio::piped())
166 .stdout(Stdio::piped())
167 .stderr(Stdio::piped())
168 .spawn()
169 .map_err(|err| {
170 format!("failed to start CodeGraph MCP server: {err}; install @colbymchenry/codegraph")
171 })
172}
173
174fn write_rpc(stdin: &mut ChildStdin, payload: &Value) -> Result<(), String> {
175 serde_json::to_writer(&mut *stdin, payload)
176 .map_err(|err| format!("failed to encode CodeGraph request: {err}"))?;
177 stdin
178 .write_all(b"\n")
179 .and_then(|_| stdin.flush())
180 .map_err(|err| format!("failed to write to CodeGraph MCP server: {err}"))
181}
182
183fn wait_for_response(
184 receiver: &mpsc::Receiver<String>,
185 id: u64,
186 deadline: Instant,
187 cancelled: &AtomicBool,
188) -> Result<Value, String> {
189 loop {
190 if cancelled.load(Ordering::SeqCst) {
191 return Err("codegraph request cancelled".into());
192 }
193 let now = Instant::now();
194 if now >= deadline {
195 return Err(format!(
196 "CodeGraph MCP session timed out after {} seconds; try `codegraph unlock`",
197 SESSION_TIMEOUT.as_secs()
198 ));
199 }
200 let wait = POLL_INTERVAL.min(deadline.saturating_duration_since(now));
201 match receiver.recv_timeout(wait) {
202 Ok(line) => {
203 let Ok(message) = serde_json::from_str::<Value>(&line) else {
204 continue;
205 };
206 if message.get("id").and_then(Value::as_u64) != Some(id) {
207 continue;
208 }
209 if let Some(error) = message.get("error") {
210 let text = error
211 .get("message")
212 .and_then(Value::as_str)
213 .map(str::to_owned)
214 .unwrap_or_else(|| error.to_string());
215 return Err(text);
216 }
217 return Ok(message.get("result").cloned().unwrap_or(Value::Null));
218 }
219 Err(mpsc::RecvTimeoutError::Timeout) => continue,
220 Err(mpsc::RecvTimeoutError::Disconnected) => {
221 return Err("CodeGraph MCP process closed before responding".into())
222 }
223 }
224 }
225}
226
227fn response_text(result: &Value) -> Result<String, String> {
228 let text = result
229 .get("content")
230 .and_then(Value::as_array)
231 .into_iter()
232 .flatten()
233 .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
234 .filter_map(|part| part.get("text").and_then(Value::as_str))
235 .collect::<Vec<_>>()
236 .join("\n");
237 if result.get("isError").and_then(Value::as_bool) == Some(true) {
238 return Err(if text.is_empty() {
239 "CodeGraph tool failed".into()
240 } else {
241 text
242 });
243 }
244 let output = if text.is_empty() {
245 result.to_string()
246 } else {
247 text
248 };
249 Ok(truncate_chars(&output, MAX_OUTPUT_CHARS))
250}
251
252fn truncate_chars(text: &str, max_chars: usize) -> String {
253 if text.chars().count() <= max_chars {
254 return text.to_owned();
255 }
256 let byte_end = text
257 .char_indices()
258 .nth(max_chars)
259 .map(|(index, _)| index)
260 .unwrap_or(text.len());
261 let prefix = &text[..byte_end];
262 let cut = prefix
263 .rfind('\n')
264 .filter(|index| *index >= byte_end.saturating_mul(4) / 5)
265 .unwrap_or(byte_end);
266 format!(
267 "{}\n\n... (output truncated to {max_chars} characters)",
268 &text[..cut]
269 )
270}
271
272fn sanitize_diagnostic(text: &str) -> String {
273 let mut cleaned = String::with_capacity(text.len().min(MAX_DIAGNOSTIC_CHARS));
274 for raw in text.lines() {
275 let mut line = raw.replace('\u{1b}', "");
276 for marker in ["TOKEN=", "SECRET=", "PASSWORD=", "API_KEY=", "APIKEY="] {
277 if let Some(start) = line.to_ascii_uppercase().find(marker) {
278 let value_start = start + marker.len();
279 let value_end = line[value_start..]
280 .find(char::is_whitespace)
281 .map(|offset| value_start + offset)
282 .unwrap_or(line.len());
283 line.replace_range(value_start..value_end, "[redacted]");
284 }
285 }
286 if let Some(start) = line.to_ascii_lowercase().find("bearer ") {
287 let value_start = start + "bearer ".len();
288 let value_end = line[value_start..]
289 .find(char::is_whitespace)
290 .map(|offset| value_start + offset)
291 .unwrap_or(line.len());
292 line.replace_range(value_start..value_end, "[redacted]");
293 }
294 if !cleaned.is_empty() {
295 cleaned.push('\n');
296 }
297 cleaned.push_str(&line);
298 if cleaned.chars().count() >= MAX_DIAGNOSTIC_CHARS {
299 return truncate_chars(&cleaned, MAX_DIAGNOSTIC_CHARS);
300 }
301 }
302 cleaned
303}
304
305fn call_codegraph(tool: &str, params: &Value, cancelled: &AtomicBool) -> Result<String, String> {
306 let root = project_root(params)?;
307 ensure_initialized(&root)?;
309 sync_index(&root)?;
310 let mut child = spawn_server(&root)?;
311 let mut stdin = child
312 .stdin
313 .take()
314 .ok_or_else(|| "CodeGraph MCP stdin is unavailable".to_string())?;
315 let stdout = child
316 .stdout
317 .take()
318 .ok_or_else(|| "CodeGraph MCP stdout is unavailable".to_string())?;
319 let stderr = child
320 .stderr
321 .take()
322 .ok_or_else(|| "CodeGraph MCP stderr is unavailable".to_string())?;
323
324 let (sender, receiver) = mpsc::channel();
325 thread::spawn(move || {
326 for line in BufReader::new(stdout).lines().map_while(Result::ok) {
327 if sender.send(line).is_err() {
328 break;
329 }
330 }
331 });
332 let diagnostic = Arc::new(Mutex::new(String::new()));
333 let diagnostic_writer = Arc::clone(&diagnostic);
334 thread::spawn(move || {
335 let mut reader = BufReader::new(stderr).take(16 * 1024);
336 let mut text = String::new();
337 let _ = reader.read_to_string(&mut text);
338 if let Ok(mut destination) = diagnostic_writer.lock() {
339 *destination = text;
340 }
341 });
342
343 let deadline = Instant::now() + SESSION_TIMEOUT;
344 let operation = (|| {
345 let path = root.to_string_lossy().replace('\\', "/");
346 let root_uri = if cfg!(windows) {
347 format!("file:///{}", path.trim_start_matches('/'))
348 } else {
349 format!("file://{path}")
350 };
351 write_rpc(
352 &mut stdin,
353 &json!({
354 "jsonrpc": "2.0",
355 "id": 1,
356 "method": "initialize",
357 "params": {
358 "protocolVersion": "2024-11-05",
359 "rootUri": root_uri.clone(),
360 "workspaceFolders": [{"uri": root_uri, "name": root.file_name().and_then(|v| v.to_str()).unwrap_or("project")}],
361 "capabilities": {},
362 "clientInfo": {"name": "rpi-codegraph", "version": env!("CARGO_PKG_VERSION")}
363 }
364 }),
365 )?;
366 wait_for_response(&receiver, 1, deadline, cancelled)?;
367 write_rpc(
368 &mut stdin,
369 &json!({"jsonrpc":"2.0","method":"initialized","params":{}}),
370 )?;
371 write_rpc(
372 &mut stdin,
373 &json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":params}}),
374 )?;
375 let result = wait_for_response(&receiver, 2, deadline, cancelled)?;
376 response_text(&result)
377 })();
378
379 drop(stdin);
380 let _ = child.kill();
381 let _ = child.wait();
382
383 operation.map_err(|message| {
384 let stderr = diagnostic
385 .lock()
386 .ok()
387 .map(|value| sanitize_diagnostic(&value))
388 .unwrap_or_default();
389 if stderr.is_empty() || message.contains(&stderr) {
390 message
391 } else {
392 format!("{message}: {stderr}")
393 }
394 })
395}
396
397fn execute_tool(tool: &'static str, params: StbString, free: Option<FreeStringFn>) -> StepHandle {
398 let text = params.to_string_lossy();
399 params.free_with(free);
400 HANDLES.insert(Drive {
401 tool,
402 params: serde_json::from_str(&text).unwrap_or(Value::Null),
403 completed: false,
404 })
405}
406
407macro_rules! tool_execute {
408 ($function:ident, $tool:literal) => {
409 extern "C" fn $function(
410 _: StbStringRef,
411 params: StbString,
412 free: Option<FreeStringFn>,
413 ) -> StepHandle {
414 execute_tool($tool, params, free)
415 }
416 };
417}
418
419tool_execute!(execute_search, "codegraph_search");
420tool_execute!(execute_callers, "codegraph_callers");
421tool_execute!(execute_callees, "codegraph_callees");
422tool_execute!(execute_impact, "codegraph_impact");
423tool_execute!(execute_explore, "codegraph_explore");
424tool_execute!(execute_node, "codegraph_node");
425tool_execute!(execute_status, "codegraph_status");
426tool_execute!(execute_files, "codegraph_files");
427
428extern "C" fn poll(handle: StepHandle, _: Option<ToolPartialCb>, _: *mut c_void) -> StepResult {
429 if handle.is_null() {
430 return StepResult::err(StbString::from_string("null codegraph handle".into()));
431 }
432 let Some(entry) = HANDLES.get(handle) else {
433 return StepResult::err(StbString::from_string(
434 "invalid or destroyed tool handle".into(),
435 ));
436 };
437 let mut drive = entry.lock();
438 if entry.cancelled.load(Ordering::SeqCst) {
439 return StepResult::err(StbString::from_string("codegraph request cancelled".into()));
440 }
441 if drive.completed {
442 return StepResult::err(StbString::from_string(
443 "codegraph polled after completion".into(),
444 ));
445 }
446 drive.completed = true;
447 match call_codegraph(drive.tool, &drive.params, &entry.cancelled) {
448 Ok(text) => StepResult::done(StbString::from_string(
449 json!({"content":[{"type":"text","text":text}]}).to_string(),
450 )),
451 Err(error) => StepResult::err(StbString::from_string(error)),
452 }
453}
454
455extern "C" fn cancel(handle: StepHandle) {
456 HANDLES.cancel(handle);
457}
458
459extern "C" fn destroy(handle: StepHandle) {
460 drop(HANDLES.remove(handle));
461}
462
463use rpi_plugin_sdk::callbacks::free_string;
464
465fn parameters(mut properties: serde_json::Map<String, Value>, required: &[&str]) -> String {
466 properties.insert(
467 "projectPath".into(),
468 json!({
469 "type": "string",
470 "description": "Absolute path to an initialized CodeGraph project. Defaults to the current directory."
471 }),
472 );
473 let mut schema = json!({"type":"object", "properties": properties});
474 if !required.is_empty() {
475 schema["required"] = json!(required);
476 }
477 schema.to_string()
478}
479
480fn tool_specs() -> Vec<ToolSpec> {
481 vec![
482 ToolSpec {
483 name: "codegraph_search",
484 description: "Search indexed code symbols by name. Returns compact locations only.",
485 parameters: parameters(json!({
486 "query":{"type":"string"},
487 "kind":{"type":"string","enum":["function","method","class","interface","type","variable","route","component"]},
488 "limit":{"type":"integer","default":10}
489 }).as_object().unwrap().clone(), &["query"]),
490 execute: execute_search,
491 },
492 ToolSpec {
493 name: "codegraph_callers",
494 description: "Find functions or methods that call an indexed symbol.",
495 parameters: parameters(json!({
496 "symbol":{"type":"string"}, "file":{"type":"string"},
497 "limit":{"type":"integer","default":20}
498 }).as_object().unwrap().clone(), &["symbol"]),
499 execute: execute_callers,
500 },
501 ToolSpec {
502 name: "codegraph_callees",
503 description: "Find functions or methods called by an indexed symbol.",
504 parameters: parameters(json!({
505 "symbol":{"type":"string"}, "file":{"type":"string"},
506 "limit":{"type":"integer","default":20}
507 }).as_object().unwrap().clone(), &["symbol"]),
508 execute: execute_callees,
509 },
510 ToolSpec {
511 name: "codegraph_impact",
512 description: "Analyze the dependency impact radius of changing an indexed symbol.",
513 parameters: parameters(json!({
514 "symbol":{"type":"string"}, "file":{"type":"string"},
515 "depth":{"type":"integer","default":2}
516 }).as_object().unwrap().clone(), &["symbol"]),
517 execute: execute_impact,
518 },
519 ToolSpec {
520 name: "codegraph_explore",
521 description: "Explore a code area by question, symbol, file, or flow; returns bounded relevant source grouped by file.",
522 parameters: parameters(json!({
523 "query":{"type":"string"}, "maxFiles":{"type":"integer","default":8}
524 }).as_object().unwrap().clone(), &["query"]),
525 execute: execute_explore,
526 },
527 ToolSpec {
528 name: "codegraph_node",
529 description: "Get one symbol with source and relationships, or read one indexed file with optional line bounds.",
530 parameters: parameters(json!({
531 "symbol":{"type":"string"}, "includeCode":{"type":"boolean","default":false},
532 "file":{"type":"string"}, "offset":{"type":"integer"}, "limit":{"type":"integer"},
533 "symbolsOnly":{"type":"boolean","default":false}, "line":{"type":"integer"}
534 }).as_object().unwrap().clone(), &[]),
535 execute: execute_node,
536 },
537 ToolSpec {
538 name: "codegraph_status",
539 description: "Report CodeGraph index health, counts, and pending synchronization.",
540 parameters: parameters(serde_json::Map::new(), &[]),
541 execute: execute_status,
542 },
543 ToolSpec {
544 name: "codegraph_files",
545 description: "Get the indexed project file tree with optional path, glob, format, metadata, and depth filters.",
546 parameters: parameters(json!({
547 "path":{"type":"string"}, "pattern":{"type":"string"},
548 "format":{"type":"string","enum":["tree","flat","grouped"],"default":"tree"},
549 "includeMetadata":{"type":"boolean","default":true}, "maxDepth":{"type":"integer"}
550 }).as_object().unwrap().clone(), &[]),
551 execute: execute_files,
552 },
553 ]
554}
555rpi_plugin_sdk::export_plugin!(|api| {
556 let Some(register) = api.register_tool else {
557 return 1;
558 };
559 for spec in tool_specs() {
560 let schema = Box::new(rpi_plugin_sdk::StableToolSchema {
561 name: StbString::from_string(spec.name.into()),
562 description: StbString::from_string(spec.description.into()),
563 parameters: StbString::from_string(spec.parameters),
564 });
565 let rc = register(&*schema, spec.execute, poll, cancel, destroy, free_string);
566 drop(schema);
567 if rc != 0 {
568 return rc;
569 }
570 }
571 0
572});
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn schemas_are_valid_json() {
580 for spec in tool_specs() {
581 serde_json::from_str::<Value>(&spec.parameters)
582 .unwrap_or_else(|error| panic!("invalid schema for {}: {error}", spec.name));
583 }
584 }
585
586 #[test]
587 fn extracts_text_and_rejects_mcp_errors() {
588 let result = json!({"content":[{"type":"text","text":"one"},{"type":"image","data":"x"},{"type":"text","text":"two"}]});
589 assert_eq!(response_text(&result).unwrap(), "one\ntwo");
590 assert!(
591 response_text(&json!({"isError":true,"content":[{"type":"text","text":"bad"}]}))
592 .is_err()
593 );
594 }
595
596 #[test]
597 fn truncates_on_unicode_boundaries() {
598 let text = "你".repeat(30);
599 let output = truncate_chars(&text, 10);
600 assert!(output.starts_with(&"你".repeat(10)));
601 assert!(output.contains("output truncated"));
602 }
603
604 #[test]
605 fn redacts_common_secrets() {
606 let output = sanitize_diagnostic("failed TOKEN=abc Bearer xyz API_KEY=hidden");
607 assert!(!output.contains("abc"));
608 assert!(!output.contains("xyz"));
609 assert!(!output.contains("hidden"));
610 }
611}