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