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