1pub mod ipc;
44pub mod jsonrpc;
45pub mod tools;
46
47use serde::{Deserialize, Serialize};
48use std::io::BufRead;
49use std::sync::OnceLock;
50
51use jsonrpc::{IncomingMessage, method_not_found, parse_error, send_response, success_response};
52use tools::{handle_tools_call, handle_tools_list};
53
54pub(crate) const PROTOCOL_VERSION: &str = "2024-11-05";
60
61pub(crate) const SERVER_NAME: &str = "par-term";
63
64static APP_VERSION: OnceLock<String> = OnceLock::new();
67
68pub fn set_app_version(version: impl Into<String>) {
71 let _ = APP_VERSION.set(version.into());
72}
73
74pub(crate) fn get_app_version() -> &'static str {
76 APP_VERSION
77 .get()
78 .map(|s| s.as_str())
79 .unwrap_or(env!("CARGO_PKG_VERSION"))
80}
81
82fn handle_initialize() -> serde_json::Value {
84 serde_json::json!({
85 "protocolVersion": PROTOCOL_VERSION,
86 "capabilities": {
87 "tools": {}
88 },
89 "serverInfo": {
90 "name": SERVER_NAME,
91 "version": get_app_version()
92 }
93 })
94}
95
96pub const CONFIG_UPDATE_PATH_ENV: &str = "PAR_TERM_CONFIG_UPDATE_PATH";
98pub const SCREENSHOT_REQUEST_PATH_ENV: &str = "PAR_TERM_SCREENSHOT_REQUEST_PATH";
100pub const SCREENSHOT_RESPONSE_PATH_ENV: &str = "PAR_TERM_SCREENSHOT_RESPONSE_PATH";
102pub const SHADER_DIAGNOSTICS_REQUEST_PATH_ENV: &str = "PAR_TERM_SHADER_DIAGNOSTICS_REQUEST_PATH";
104pub const SHADER_DIAGNOSTICS_RESPONSE_PATH_ENV: &str = "PAR_TERM_SHADER_DIAGNOSTICS_RESPONSE_PATH";
106pub const SCREENSHOT_FALLBACK_PATH_ENV: &str = "PAR_TERM_SCREENSHOT_FALLBACK_PATH";
109
110pub const CONFIG_UPDATE_FILENAME: &str = ".config-update.json";
112pub const SCREENSHOT_REQUEST_FILENAME: &str = ".screenshot-request.json";
114pub const SCREENSHOT_RESPONSE_FILENAME: &str = ".screenshot-response.json";
116pub const SHADER_DIAGNOSTICS_REQUEST_FILENAME: &str = ".shader-diagnostics-request.json";
118pub const SHADER_DIAGNOSTICS_RESPONSE_FILENAME: &str = ".shader-diagnostics-response.json";
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct TerminalScreenshotRequest {
124 pub request_id: String,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct TerminalScreenshotResponse {
130 pub request_id: String,
131 pub ok: bool,
132 #[serde(default)]
133 pub error: Option<String>,
134 #[serde(default)]
135 pub mime_type: Option<String>,
136 #[serde(default)]
137 pub data_base64: Option<String>,
138 #[serde(default)]
139 pub width: Option<u32>,
140 #[serde(default)]
141 pub height: Option<u32>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ShaderDiagnosticsRequest {
147 pub request_id: String,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct ShaderDiagnosticsEntry {
153 pub shader: Option<String>,
154 pub enabled: bool,
155 pub last_error: Option<String>,
156 pub wgsl_path: Option<String>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ShaderDiagnostics {
162 pub background: ShaderDiagnosticsEntry,
163 pub cursor: ShaderDiagnosticsEntry,
164 pub shaders_dir: String,
165 pub wrapped_glsl_path: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ShaderDiagnosticsResponse {
171 pub request_id: String,
172 pub ok: bool,
173 #[serde(default)]
174 pub error: Option<String>,
175 #[serde(default)]
176 pub diagnostics: Option<ShaderDiagnostics>,
177}
178
179pub use ipc::{
181 screenshot_request_path, screenshot_response_path, shader_diagnostics_request_path,
182 shader_diagnostics_response_path,
183};
184
185pub fn run_mcp_server() {
189 let version = get_app_version();
190 eprintln!("[mcp-server] Starting par-term MCP server v{version}");
191
192 let stdin = std::io::stdin();
193 let mut stdout = std::io::stdout();
194 let reader = stdin.lock();
195
196 for line in reader.lines() {
197 let line = match line {
198 Ok(l) => l,
199 Err(e) => {
200 eprintln!("[mcp-server] Error reading stdin: {e}");
201 break;
202 }
203 };
204
205 let trimmed = line.trim();
206 if trimmed.is_empty() {
207 continue;
208 }
209
210 eprintln!("[mcp-server] <- {trimmed}");
211
212 let msg: IncomingMessage = match serde_json::from_str(trimmed) {
213 Ok(m) => m,
214 Err(e) => {
215 eprintln!("[mcp-server] Parse error: {e}");
216 send_response(&mut stdout, &parse_error());
217 continue;
218 }
219 };
220
221 let method = match &msg.method {
222 Some(m) => m.as_str(),
223 None => {
224 eprintln!("[mcp-server] Ignoring message without method");
226 continue;
227 }
228 };
229
230 let id = match msg.id {
232 Some(id) => id,
233 None => {
234 eprintln!("[mcp-server] Notification: {method}");
235 continue;
237 }
238 };
239
240 let response = match method {
242 "initialize" => success_response(id, handle_initialize()),
243 "tools/list" => success_response(id, handle_tools_list()),
244 "tools/call" => success_response(id, handle_tools_call(msg.params)),
245 _ => method_not_found(id, method),
246 };
247
248 eprintln!(
249 "[mcp-server] -> {}",
250 serde_json::to_string(&response).unwrap_or_else(|_| "<serialization error>".into())
251 );
252
253 send_response(&mut stdout, &response);
254 }
255
256 eprintln!("[mcp-server] stdin closed, exiting");
257}
258
259#[cfg(test)]
264mod tests {
265 use super::*;
266 use ipc::{config_update_path, set_ipc_file_permissions, write_json_atomic};
267 use jsonrpc::{IncomingMessage, method_not_found, parse_error, success_response};
268 use std::path::PathBuf;
269 use tools::config_update::write_config_updates;
270 use tools::diagnostics::diagnostics_tool_result;
271 use tools::screenshot::image_tool_result_from_file;
272
273 #[test]
274 fn test_handle_initialize() {
275 let result = handle_initialize();
276 assert_eq!(result["protocolVersion"], PROTOCOL_VERSION);
277 assert!(result["capabilities"]["tools"].is_object());
278 assert_eq!(result["serverInfo"]["name"], SERVER_NAME);
279 }
280
281 #[test]
282 fn test_handle_tools_list() {
283 let result = handle_tools_list();
284 let tools = result["tools"].as_array().unwrap();
285 assert_eq!(tools.len(), 3);
286 let names: Vec<_> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
287 assert!(names.contains(&"config_update"));
288 assert!(names.contains(&"terminal_screenshot"));
289 assert!(names.contains(&"shader_diagnostics"));
290 for tool in tools {
291 assert!(tool["inputSchema"].is_object());
292 }
293 }
294
295 #[test]
296 fn test_handle_tools_call_unknown_tool() {
297 let params = serde_json::json!({
298 "name": "nonexistent_tool",
299 "arguments": {}
300 });
301 let result = handle_tools_call(Some(params));
302 assert_eq!(result["isError"], true);
303 assert!(
304 result["content"][0]["text"]
305 .as_str()
306 .unwrap()
307 .contains("Unknown tool")
308 );
309 }
310
311 #[test]
312 fn test_handle_tools_call_missing_params() {
313 let result = handle_tools_call(None);
314 assert_eq!(result["isError"], true);
315 }
316
317 #[test]
318 fn test_handle_config_update_missing_updates() {
319 let params = serde_json::json!({
320 "name": "config_update",
321 "arguments": {}
322 });
323 let result = handle_tools_call(Some(params));
324 assert_eq!(result["isError"], true);
325 assert!(
326 result["content"][0]["text"]
327 .as_str()
328 .unwrap()
329 .contains("Missing 'updates'")
330 );
331 }
332
333 #[test]
334 fn test_handle_config_update_invalid_updates_type() {
335 let params = serde_json::json!({
336 "name": "config_update",
337 "arguments": {
338 "updates": "not an object"
339 }
340 });
341 let result = handle_tools_call(Some(params));
342 assert_eq!(result["isError"], true);
343 assert!(
344 result["content"][0]["text"]
345 .as_str()
346 .unwrap()
347 .contains("must be a JSON object")
348 );
349 }
350
351 #[test]
352 fn test_handle_config_update_success() {
353 let dir = tempfile::tempdir().unwrap();
355 let update_path = dir.path().join("test-update.json");
356
357 let updates = serde_json::json!({
358 "font_size": 14.0,
359 "custom_shader_enabled": true
360 });
361 let result = write_config_updates(&updates, &update_path);
362
363 assert!(result.get("isError").is_none());
365 assert!(
366 result["content"][0]["text"]
367 .as_str()
368 .unwrap()
369 .contains("Successfully")
370 );
371
372 let written: serde_json::Value =
374 serde_json::from_str(&std::fs::read_to_string(&update_path).unwrap()).unwrap();
375 assert_eq!(written["font_size"], 14.0);
376 assert_eq!(written["custom_shader_enabled"], true);
377 }
378
379 #[test]
380 fn test_success_response_format() {
381 let resp = success_response(
382 serde_json::Value::Number(1.into()),
383 serde_json::json!({"ok": true}),
384 );
385 let json = serde_json::to_value(&resp).unwrap();
386 assert_eq!(json["jsonrpc"], "2.0");
387 assert_eq!(json["id"], 1);
388 assert_eq!(json["result"]["ok"], true);
389 assert!(json.get("error").is_none());
390 }
391
392 #[test]
393 fn test_method_not_found_response() {
394 let resp = method_not_found(serde_json::Value::Number(5.into()), "bogus/method");
395 let json = serde_json::to_value(&resp).unwrap();
396 assert_eq!(json["jsonrpc"], "2.0");
397 assert_eq!(json["id"], 5);
398 assert_eq!(json["error"]["code"], -32601);
399 assert!(
400 json["error"]["message"]
401 .as_str()
402 .unwrap()
403 .contains("bogus/method")
404 );
405 }
406
407 #[test]
408 fn test_parse_error_response() {
409 let resp = parse_error();
410 let json = serde_json::to_value(&resp).unwrap();
411 assert_eq!(json["jsonrpc"], "2.0");
412 assert!(json["id"].is_null());
413 assert_eq!(json["error"]["code"], -32700);
414 }
415
416 #[test]
417 fn test_config_update_path_env_override_and_default() {
418 unsafe {
427 std::env::set_var(CONFIG_UPDATE_PATH_ENV, "/tmp/test-par-term-update.json");
428 }
429 let path = config_update_path();
430 assert_eq!(path, PathBuf::from("/tmp/test-par-term-update.json"));
431
432 unsafe {
435 std::env::remove_var(CONFIG_UPDATE_PATH_ENV);
436 }
437 let path = config_update_path();
438 let path_str = path.to_str().unwrap();
439 assert!(
440 path_str.contains("par-term"),
441 "Expected path to contain 'par-term', got: {path_str}"
442 );
443 assert!(
444 path_str.ends_with(CONFIG_UPDATE_FILENAME),
445 "Expected path to end with '{CONFIG_UPDATE_FILENAME}', got: {path_str}"
446 );
447 }
448
449 #[test]
450 fn test_shader_diagnostics_paths_env_override_and_default() {
451 unsafe {
455 std::env::set_var(
456 SHADER_DIAGNOSTICS_REQUEST_PATH_ENV,
457 "/tmp/test-par-term-shader-diag-req.json",
458 );
459 std::env::set_var(
460 SHADER_DIAGNOSTICS_RESPONSE_PATH_ENV,
461 "/tmp/test-par-term-shader-diag-resp.json",
462 );
463 }
464 assert_eq!(
465 shader_diagnostics_request_path(),
466 PathBuf::from("/tmp/test-par-term-shader-diag-req.json")
467 );
468 assert_eq!(
469 shader_diagnostics_response_path(),
470 PathBuf::from("/tmp/test-par-term-shader-diag-resp.json")
471 );
472
473 unsafe {
475 std::env::remove_var(SHADER_DIAGNOSTICS_REQUEST_PATH_ENV);
476 std::env::remove_var(SHADER_DIAGNOSTICS_RESPONSE_PATH_ENV);
477 }
478 assert!(
479 shader_diagnostics_request_path()
480 .to_string_lossy()
481 .ends_with(SHADER_DIAGNOSTICS_REQUEST_FILENAME)
482 );
483 assert!(
484 shader_diagnostics_response_path()
485 .to_string_lossy()
486 .ends_with(SHADER_DIAGNOSTICS_RESPONSE_FILENAME)
487 );
488 }
489
490 #[test]
491 fn test_diagnostics_tool_result_includes_shader_errors_and_paths() {
492 let response = ShaderDiagnosticsResponse {
493 request_id: "req-1".to_string(),
494 ok: true,
495 error: None,
496 diagnostics: Some(ShaderDiagnostics {
497 background: ShaderDiagnosticsEntry {
498 shader: Some("bad.glsl".to_string()),
499 enabled: true,
500 last_error: Some("naga validation failed".to_string()),
501 wgsl_path: Some("/tmp/par_term_bad_shader.wgsl".to_string()),
502 },
503 cursor: ShaderDiagnosticsEntry {
504 shader: None,
505 enabled: false,
506 last_error: None,
507 wgsl_path: None,
508 },
509 shaders_dir: "/Users/example/.config/par-term/shaders".to_string(),
510 wrapped_glsl_path: "/tmp/par_term_debug_wrapped.glsl".to_string(),
511 }),
512 };
513
514 let result = diagnostics_tool_result(response);
515
516 assert!(result.get("isError").is_none());
517 let text = result["content"][0]["text"].as_str().unwrap();
518 assert!(text.contains("bad.glsl"));
519 assert!(text.contains("naga validation failed"));
520 assert!(text.contains("/tmp/par_term_bad_shader.wgsl"));
521 assert!(text.contains("shader_diagnostics"));
522 }
523
524 #[test]
525 fn test_screenshot_paths_env_override_and_default() {
526 unsafe {
533 std::env::set_var(
534 SCREENSHOT_REQUEST_PATH_ENV,
535 "/tmp/test-par-term-shot-req.json",
536 );
537 std::env::set_var(
538 SCREENSHOT_RESPONSE_PATH_ENV,
539 "/tmp/test-par-term-shot-resp.json",
540 );
541 }
542 assert_eq!(
543 screenshot_request_path(),
544 PathBuf::from("/tmp/test-par-term-shot-req.json")
545 );
546 assert_eq!(
547 screenshot_response_path(),
548 PathBuf::from("/tmp/test-par-term-shot-resp.json")
549 );
550
551 unsafe {
553 std::env::remove_var(SCREENSHOT_REQUEST_PATH_ENV);
554 std::env::remove_var(SCREENSHOT_RESPONSE_PATH_ENV);
555 }
556 assert!(
557 screenshot_request_path()
558 .to_string_lossy()
559 .ends_with(SCREENSHOT_REQUEST_FILENAME)
560 );
561 assert!(
562 screenshot_response_path()
563 .to_string_lossy()
564 .ends_with(SCREENSHOT_RESPONSE_FILENAME)
565 );
566 }
567
568 #[test]
569 fn test_image_tool_result_from_file_missing() {
570 let result = image_tool_result_from_file(std::path::Path::new(
571 "/tmp/does-not-exist-terminal-screenshot.png",
572 ));
573 assert_eq!(result["isError"], true);
574 }
575
576 #[test]
577 fn test_incoming_message_notification() {
578 let msg: IncomingMessage =
579 serde_json::from_str(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
580 .unwrap();
581 assert!(msg.id.is_none());
582 assert_eq!(msg.method.as_deref(), Some("notifications/initialized"));
583 }
584
585 #[test]
586 fn test_incoming_message_request() {
587 let msg: IncomingMessage =
588 serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#)
589 .unwrap();
590 assert!(msg.id.is_some());
591 assert_eq!(msg.method.as_deref(), Some("initialize"));
592 }
593
594 #[cfg(unix)]
595 #[test]
596 fn test_set_ipc_file_permissions() {
597 use std::os::unix::fs::PermissionsExt;
598
599 let dir = tempfile::tempdir().unwrap();
600 let path = dir.path().join("ipc-test.json");
601 std::fs::write(&path, "{}").unwrap();
602
603 set_ipc_file_permissions(&path).unwrap();
604
605 let metadata = std::fs::metadata(&path).unwrap();
606 let mode = metadata.permissions().mode() & 0o777;
607 assert_eq!(
608 mode, 0o600,
609 "IPC file should have mode 0o600, got {mode:#o}"
610 );
611 }
612
613 #[cfg(unix)]
614 #[test]
615 fn test_write_config_updates_sets_restrictive_permissions() {
616 use std::os::unix::fs::PermissionsExt;
617
618 let dir = tempfile::tempdir().unwrap();
619 let update_path = dir.path().join("config-update.json");
620
621 let updates = serde_json::json!({"font_size": 14.0});
622 let result = write_config_updates(&updates, &update_path);
623 assert!(result.get("isError").is_none(), "Expected success result");
624
625 let metadata = std::fs::metadata(&update_path).unwrap();
626 let mode = metadata.permissions().mode() & 0o777;
627 assert_eq!(
628 mode, 0o600,
629 "Config update IPC file should have mode 0o600, got {mode:#o}"
630 );
631 }
632
633 #[cfg(unix)]
634 #[test]
635 fn test_write_json_atomic_sets_restrictive_permissions() {
636 use std::os::unix::fs::PermissionsExt;
637
638 let dir = tempfile::tempdir().unwrap();
639 let path = dir.path().join("atomic-test.json");
640
641 let payload = serde_json::json!({"request_id": "test-123"});
642 write_json_atomic(&payload, &path).unwrap();
643
644 let metadata = std::fs::metadata(&path).unwrap();
645 let mode = metadata.permissions().mode() & 0o777;
646 assert_eq!(
647 mode, 0o600,
648 "Atomically written IPC file should have mode 0o600, got {mode:#o}"
649 );
650 }
651}