1use serde::Serialize;
18use serde_json::{json, Value};
19
20macro_rules! server_name {
24 () => {
25 "rtkmcp"
26 };
27}
28macro_rules! plugin_name {
30 () => {
31 "rtk-mcp-agy"
32 };
33}
34
35pub const SERVER_NAME: &str = server_name!();
37pub const PLUGIN_NAME: &str = plugin_name!();
39pub const AGY_SERVER_NAME: &str = concat!(plugin_name!(), "_", server_name!());
49pub const TOOL_NAME: &str = "rtk_run";
51pub const TOOL_GAIN: &str = "rtk_gain";
53pub const TOOL_CHECK: &str = "rtk_check";
55pub const TOOL_DISCOVER: &str = "rtk_discover";
57pub const PROTOCOL_VERSION: &str = "2024-11-05";
59pub const METHOD_NOT_FOUND: i64 = -32601;
61
62pub fn version() -> &'static str {
68 env!("CARGO_PKG_VERSION")
69}
70
71pub const DEFAULT_RTK_BIN: &str = "rtk";
75pub const DEFAULT_WINDOWS_SHELL: &str = "pwsh";
77pub const DEFAULT_UNIX_SHELL: &str = "sh";
82
83pub fn resolve_rtk_bin(override_value: Option<String>) -> String {
89 non_blank(override_value).unwrap_or_else(|| DEFAULT_RTK_BIN.to_string())
90}
91
92pub fn resolve_shell(override_value: Option<String>, is_windows: bool) -> String {
99 non_blank(override_value).unwrap_or_else(|| {
100 if is_windows {
101 DEFAULT_WINDOWS_SHELL.to_string()
102 } else {
103 DEFAULT_UNIX_SHELL.to_string()
104 }
105 })
106}
107
108fn non_blank(value: Option<String>) -> Option<String> {
113 value
114 .map(|v| v.trim().to_string())
115 .filter(|v| !v.is_empty())
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Execution {
123 Ran {
125 stdout: String,
126 stderr: String,
127 success: bool,
128 },
129 NotRun(String),
131}
132
133pub fn format_execution(execution: &Execution) -> Value {
139 let (text, is_error) = match execution {
140 Execution::Ran {
141 stdout,
142 stderr,
143 success,
144 } => {
145 let mut text = stdout.clone();
146 if !stderr.is_empty() {
147 if !text.is_empty() {
148 text.push('\n');
149 }
150 text.push_str(stderr);
151 }
152 (text, !success)
153 }
154 Execution::NotRun(message) => (message.clone(), true),
155 };
156
157 json!({
158 "content": [{ "type": "text", "text": text }],
159 "isError": is_error
160 })
161}
162
163pub fn parse_rewrite(raw: &str) -> Option<String> {
169 let trimmed = raw.trim();
170 if trimmed.is_empty() {
171 None
172 } else {
173 Some(trimmed.to_string())
174 }
175}
176
177pub fn call_arguments(request: &Value) -> (String, String) {
183 let empty = json!({});
184 let arguments = request
185 .get("params")
186 .and_then(|p| p.get("arguments"))
187 .unwrap_or(&empty);
188
189 let command = arguments
190 .get("CommandLine")
191 .and_then(|c| c.as_str())
192 .unwrap_or("")
193 .to_string();
194 let cwd = arguments
195 .get("Cwd")
196 .and_then(|c| c.as_str())
197 .unwrap_or(".")
198 .to_string();
199
200 (command, cwd)
201}
202
203pub fn shell_quote(s: &str) -> String {
209 let escaped = s.replace('\'', "'\\''");
210 format!("'{escaped}'")
211}
212
213fn validate_format(value: &str, allowed: &[&str]) -> Result<String, String> {
214 let lowered = value.trim().to_ascii_lowercase();
215 if allowed.contains(&lowered.as_str()) {
216 Ok(lowered)
217 } else {
218 Err(format!(
219 "unsupported format {value:?}; expected one of {}",
220 allowed.join(", ")
221 ))
222 }
223}
224
225pub fn extract_cwd(arguments: &Value) -> String {
227 arguments
228 .get("Cwd")
229 .and_then(|c| c.as_str())
230 .unwrap_or(".")
231 .to_string()
232}
233
234pub fn build_gain_command(arguments: &Value) -> Result<String, String> {
236 let mut cmd = "rtk gain".to_string();
237 if arguments
238 .get("history")
239 .and_then(|v| v.as_bool())
240 .unwrap_or(false)
241 {
242 cmd.push_str(" --history");
243 }
244 if arguments
245 .get("project_only")
246 .and_then(|v| v.as_bool())
247 .unwrap_or(false)
248 {
249 cmd.push_str(" --project");
250 }
251 if let Some(format) = arguments.get("format").and_then(|v| v.as_str()) {
252 let trimmed = format.trim();
253 if !trimmed.is_empty() {
254 let fmt = validate_format(trimmed, &["text", "json", "csv"])?;
255 cmd.push_str(&format!(" --format {fmt}"));
256 }
257 }
258 Ok(cmd)
259}
260
261pub fn build_check_command(arguments: &Value) -> Result<String, String> {
267 let command = arguments
268 .get("command")
269 .and_then(|v| v.as_str())
270 .unwrap_or("")
271 .trim();
272 if command.is_empty() {
273 return Err("command must not be empty".to_string());
274 }
275 Ok(format!("rtk hook check -- {}", shell_quote(command)))
276}
277
278pub fn build_discover_command(arguments: &Value) -> Result<String, String> {
280 let mut cmd = "rtk discover".to_string();
281 if let Some(project) = arguments.get("project").and_then(|v| v.as_str()) {
282 let p = project.trim();
283 if !p.is_empty() {
284 cmd.push_str(&format!(" --project {}", shell_quote(p)));
285 }
286 }
287 if let Some(limit) = arguments.get("limit").and_then(|v| v.as_u64()) {
288 cmd.push_str(&format!(" --limit {limit}"));
289 }
290 if let Some(since) = arguments.get("since").and_then(|v| v.as_u64()) {
291 cmd.push_str(&format!(" --since {since}"));
292 }
293 if arguments
294 .get("all_projects")
295 .and_then(|v| v.as_bool())
296 .unwrap_or(false)
297 {
298 cmd.push_str(" --all");
299 }
300 if let Some(format) = arguments.get("format").and_then(|v| v.as_str()) {
301 let trimmed = format.trim();
302 if !trimmed.is_empty() {
303 let fmt = validate_format(trimmed, &["text", "json"])?;
304 cmd.push_str(&format!(" --format {fmt}"));
305 }
306 }
307 Ok(cmd)
308}
309
310pub fn server_info() -> Value {
314 json!({
315 "protocolVersion": PROTOCOL_VERSION,
316 "capabilities": { "tools": {} },
317 "serverInfo": { "name": SERVER_NAME, "version": version() }
318 })
319}
320
321pub fn tools_list() -> Value {
323 json!({
324 "tools": [
325 {
326 "name": TOOL_NAME,
327 "description": "Run an rtk optimized shell command",
328 "inputSchema": {
329 "type": "object",
330 "properties": {
331 "CommandLine": { "type": "string" },
332 "Cwd": { "type": "string" },
333 "WaitMsBeforeAsync": { "type": "integer" },
334 "toolAction": {
335 "type": "string",
336 "description": "Brief 2-5 word summary of what this tool is doing."
337 },
338 "toolSummary": {
339 "type": "string",
340 "description": "Brief 2-5 word noun phrase describing what this tool call is about."
341 }
342 },
343 "required": ["CommandLine", "Cwd", "toolAction", "toolSummary"]
344 }
345 },
346 {
347 "name": TOOL_GAIN,
348 "description": "Report rtk's token-savings statistics: how many tokens rtk has saved, and optionally the recent per-command history. Read-only.",
349 "inputSchema": {
350 "type": "object",
351 "properties": {
352 "history": {
353 "type": "boolean",
354 "description": "Include the recent per-command history rather than the summary alone."
355 },
356 "project_only": {
357 "type": "boolean",
358 "description": "Restrict the statistics to the current working project."
359 },
360 "format": {
361 "type": "string",
362 "description": "Output format: `text` (default), `json`, or `csv`."
363 }
364 }
365 }
366 },
367 {
368 "name": TOOL_CHECK,
369 "description": "Show how rtk would rewrite a given shell command, without running it. Use this to explain why a command was or was not optimized. Read-only: the command is analyzed, never executed.",
370 "inputSchema": {
371 "type": "object",
372 "properties": {
373 "command": {
374 "type": "string",
375 "description": "The shell command to analyze, e.g. `grep -rn foo src/`. It is never executed — this reports only how rtk would rewrite it."
376 }
377 },
378 "required": ["command"]
379 }
380 },
381 {
382 "name": TOOL_DISCOVER,
383 "description": "Analyze past session history for commands that rtk could have optimized but did not, i.e. missed token savings. Read-only.",
384 "inputSchema": {
385 "type": "object",
386 "properties": {
387 "project": {
388 "type": "string",
389 "description": "Filter to projects whose path contains this substring."
390 },
391 "limit": {
392 "type": "integer",
393 "description": "Maximum commands reported per section. rtk's default is 15."
394 },
395 "since": {
396 "type": "integer",
397 "description": "Only consider sessions from the last N days. rtk's default is 30."
398 },
399 "all_projects": {
400 "type": "boolean",
401 "description": "Scan every project rather than only the current one."
402 },
403 "format": {
404 "type": "string",
405 "description": "Output format: `text` (default) or `json`."
406 }
407 }
408 }
409 }
410 ]
411 })
412}
413
414pub fn method_not_found() -> Value {
416 json!({
417 "error": {
418 "code": METHOD_NOT_FOUND,
419 "message": "Method not found"
420 }
421 })
422}
423
424pub fn dispatch_request<R, E>(request: &Value, rewrite: &R, exec: &E) -> Option<Value>
428where
429 R: Fn(&str) -> Option<String>,
430 E: Fn(&str, &str) -> Execution,
431{
432 let id = request.get("id")?;
434 let method = request.get("method").and_then(|m| m.as_str())?;
435
436 let result = match method {
437 "initialize" => server_info(),
438 "tools/list" => tools_list(),
439 "tools/call" => {
440 let empty = json!({});
441 let params = request.get("params").unwrap_or(&empty);
442 let tool_name = params
443 .get("name")
444 .and_then(|n| n.as_str())
445 .unwrap_or(TOOL_NAME);
446 let arguments = params.get("arguments").unwrap_or(&empty);
447 let cwd = extract_cwd(arguments);
448
449 match tool_name {
450 TOOL_GAIN => match build_gain_command(arguments) {
451 Ok(cmd) => format_execution(&exec(&cmd, &cwd)),
452 Err(err) => json!({
453 "content": [{ "type": "text", "text": err }],
454 "isError": true
455 }),
456 },
457 TOOL_CHECK => match build_check_command(arguments) {
458 Ok(cmd) => format_execution(&exec(&cmd, &cwd)),
459 Err(err) => json!({
460 "content": [{ "type": "text", "text": err }],
461 "isError": true
462 }),
463 },
464 TOOL_DISCOVER => match build_discover_command(arguments) {
465 Ok(cmd) => format_execution(&exec(&cmd, &cwd)),
466 Err(err) => json!({
467 "content": [{ "type": "text", "text": err }],
468 "isError": true
469 }),
470 },
471 TOOL_NAME => {
472 let (command, cwd) = call_arguments(request);
473 let to_run = rewrite(&command).unwrap_or_else(|| command.clone());
474 format_execution(&exec(&to_run, &cwd))
475 }
476 _ => json!({
477 "content": [{ "type": "text", "text": format!("Unknown tool: {tool_name}") }],
478 "isError": true
479 }),
480 }
481 }
482 _ => method_not_found(),
483 };
484
485 Some(envelope(id, result))
486}
487
488pub fn handle_line<R, E>(line: &str, rewrite: &R, exec: &E) -> Option<Value>
501where
502 R: Fn(&str) -> Option<String>,
503 E: Fn(&str, &str) -> Execution,
504{
505 if line.trim().is_empty() {
506 return None;
507 }
508
509 let request: Value = serde_json::from_str(line).ok()?;
510 dispatch_request(&request, rewrite, exec)
511}
512
513pub fn handle_request(request: &Value) -> Option<Value> {
515 dispatch_request(request, &|_| None, &|_, _| Execution::Ran {
516 stdout: String::new(),
517 stderr: String::new(),
518 success: true,
519 })
520}
521
522fn envelope(id: &Value, result: Value) -> Value {
527 match result.get("error") {
528 Some(error) => json!({ "jsonrpc": "2.0", "id": id, "error": error }),
529 None => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
530 }
531}
532
533#[derive(Debug, Serialize)]
537pub struct PreInvocationOutput {
538 #[serde(rename = "injectSteps")]
539 pub inject_steps: Vec<Value>,
540}
541
542pub fn preinvocation_payload() -> PreInvocationOutput {
550 PreInvocationOutput {
551 inject_steps: vec![json!({
552 "ephemeralMessage": format!(
553 "CRITICAL: Do NOT use the native `run_command` tool. You MUST use the \
554 `call_mcp_tool` with ServerName='{AGY_SERVER_NAME}' and ToolName='{TOOL_NAME}' \
555 for all shell commands to ensure token-optimized execution."
556 )
557 })],
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use std::cell::RefCell;
565
566 fn no_rewrite(_: &str) -> Option<String> {
569 None
570 }
571 fn never_exec(_: &str, _: &str) -> Execution {
572 panic!("the executor must not run for this method")
573 }
574 fn never_rewrite(_: &str) -> Option<String> {
575 panic!("the rewriter must not run for this method")
576 }
577 fn ok_exec(_: &str, _: &str) -> Execution {
578 Execution::Ran {
579 stdout: "ok".to_string(),
580 stderr: String::new(),
581 success: true,
582 }
583 }
584
585 #[test]
588 fn version_matches_the_crate() {
589 assert_eq!(version(), env!("CARGO_PKG_VERSION"));
590 assert!(!version().is_empty());
591 }
592
593 #[test]
594 fn handshake_version_comes_from_the_crate() {
595 assert_eq!(server_info()["serverInfo"]["version"], version());
598 }
599
600 #[test]
603 fn rtk_bin_defaults_to_the_path_lookup() {
604 assert_eq!(resolve_rtk_bin(None), DEFAULT_RTK_BIN);
605 }
606
607 #[test]
608 fn rtk_bin_honours_an_override() {
609 assert_eq!(
610 resolve_rtk_bin(Some("/opt/rtk/bin/rtk".to_string())),
611 "/opt/rtk/bin/rtk"
612 );
613 assert_eq!(resolve_rtk_bin(Some(" rtk-dev ".to_string())), "rtk-dev");
614 }
615
616 #[test]
617 fn a_blank_rtk_bin_does_not_erase_the_default() {
618 for blank in ["", " ", "\t"] {
621 assert_eq!(resolve_rtk_bin(Some(blank.to_string())), DEFAULT_RTK_BIN);
622 }
623 }
624
625 #[test]
626 fn the_shell_defaults_per_platform() {
627 assert_eq!(resolve_shell(None, true), DEFAULT_WINDOWS_SHELL);
630 assert_eq!(resolve_shell(None, false), DEFAULT_UNIX_SHELL);
631 assert_ne!(
632 DEFAULT_UNIX_SHELL, DEFAULT_WINDOWS_SHELL,
633 "the whole point is that these differ"
634 );
635 }
636
637 #[test]
638 fn the_shell_override_wins_on_every_platform() {
639 for windows in [true, false] {
640 assert_eq!(
641 resolve_shell(Some("bash".to_string()), windows),
642 "bash",
643 "override must apply regardless of platform"
644 );
645 }
646 }
647
648 #[test]
649 fn a_blank_shell_override_falls_back_to_the_platform_default() {
650 assert_eq!(
651 resolve_shell(Some(" ".to_string()), true),
652 DEFAULT_WINDOWS_SHELL
653 );
654 assert_eq!(
655 resolve_shell(Some(String::new()), false),
656 DEFAULT_UNIX_SHELL
657 );
658 }
659
660 #[test]
663 fn parse_rewrite_reads_a_rewritten_command() {
664 assert_eq!(
665 parse_rewrite("rtk grep -rn foo src/\n"),
666 Some("rtk grep -rn foo src/".to_string())
667 );
668 }
669
670 #[test]
671 fn parse_rewrite_treats_empty_output_as_no_rewrite() {
672 assert_eq!(parse_rewrite(""), None);
673 assert_eq!(parse_rewrite(" "), None);
674 assert_eq!(parse_rewrite("\n\t \r\n"), None);
675 }
676
677 #[test]
678 fn parse_rewrite_trims_only_the_edges() {
679 assert_eq!(
680 parse_rewrite(" rg foo bar "),
681 Some("rg foo bar".to_string()),
682 "internal spacing is part of the command"
683 );
684 }
685
686 #[test]
687 fn parse_rewrite_keeps_multiline_output_whole() {
688 assert_eq!(
689 parse_rewrite("line one\nline two\n"),
690 Some("line one\nline two".to_string())
691 );
692 }
693
694 #[test]
697 fn call_arguments_reads_both_fields() {
698 let req = json!({ "params": { "arguments": { "CommandLine": "ls -la", "Cwd": "/tmp" } } });
699 assert_eq!(
700 call_arguments(&req),
701 ("ls -la".to_string(), "/tmp".to_string())
702 );
703 }
704
705 #[test]
706 fn call_arguments_defaults_the_working_directory() {
707 let req = json!({ "params": { "arguments": { "CommandLine": "ls" } } });
708 assert_eq!(call_arguments(&req).1, ".");
709 }
710
711 #[test]
712 fn call_arguments_defaults_a_missing_command_to_empty() {
713 let req = json!({ "params": { "arguments": { "Cwd": "/tmp" } } });
714 assert_eq!(call_arguments(&req).0, "");
715 }
716
717 #[test]
718 fn call_arguments_survives_a_missing_arguments_object() {
719 assert_eq!(
720 call_arguments(&json!({ "params": {} })),
721 (String::new(), ".".to_string())
722 );
723 }
724
725 #[test]
726 fn call_arguments_survives_missing_params() {
727 assert_eq!(call_arguments(&json!({})), (String::new(), ".".to_string()));
728 }
729
730 #[test]
731 fn call_arguments_ignores_non_string_values() {
732 let req = json!({ "params": { "arguments": { "CommandLine": 42, "Cwd": ["/tmp"] } } });
734 assert_eq!(call_arguments(&req), (String::new(), ".".to_string()));
735 }
736
737 fn text_of(value: &Value) -> String {
740 value["content"][0]["text"].as_str().unwrap().to_string()
741 }
742
743 #[test]
744 fn format_execution_returns_stdout_alone() {
745 let out = format_execution(&Execution::Ran {
746 stdout: "hello".to_string(),
747 stderr: String::new(),
748 success: true,
749 });
750 assert_eq!(text_of(&out), "hello");
751 assert_eq!(out["isError"], false);
752 assert_eq!(out["content"][0]["type"], "text");
753 }
754
755 #[test]
756 fn format_execution_returns_stderr_alone_without_a_leading_newline() {
757 let out = format_execution(&Execution::Ran {
758 stdout: String::new(),
759 stderr: "boom".to_string(),
760 success: false,
761 });
762 assert_eq!(text_of(&out), "boom", "no separator when stdout is empty");
763 assert_eq!(out["isError"], true);
764 }
765
766 #[test]
767 fn format_execution_joins_both_streams_with_one_newline() {
768 let out = format_execution(&Execution::Ran {
769 stdout: "out".to_string(),
770 stderr: "err".to_string(),
771 success: true,
772 });
773 assert_eq!(text_of(&out), "out\nerr");
774 }
775
776 #[test]
777 fn format_execution_handles_silence() {
778 let out = format_execution(&Execution::Ran {
779 stdout: String::new(),
780 stderr: String::new(),
781 success: true,
782 });
783 assert_eq!(text_of(&out), "");
784 assert_eq!(out["isError"], false);
785 }
786
787 #[test]
788 fn format_execution_marks_a_failed_exit() {
789 let out = format_execution(&Execution::Ran {
790 stdout: "partial".to_string(),
791 stderr: String::new(),
792 success: false,
793 });
794 assert_eq!(
795 out["isError"], true,
796 "a non-zero exit is an error even with output"
797 );
798 }
799
800 #[test]
801 fn format_execution_reports_a_spawn_failure() {
802 let out = format_execution(&Execution::NotRun("pwsh not found".to_string()));
803 assert_eq!(text_of(&out), "pwsh not found");
804 assert_eq!(out["isError"], true);
805 }
806
807 #[test]
810 fn server_info_declares_protocol_name_and_tools() {
811 let info = server_info();
812 assert_eq!(info["protocolVersion"], PROTOCOL_VERSION);
813 assert_eq!(info["serverInfo"]["name"], SERVER_NAME);
814 assert!(
815 info["capabilities"]["tools"].is_object(),
816 "without this a client never calls tools/list"
817 );
818 }
819
820 #[test]
821 fn tools_list_advertises_four_tools() {
822 let tools = tools_list();
823 let list = tools["tools"].as_array().unwrap();
824 assert_eq!(list.len(), 4);
825 let names: Vec<&str> = list.iter().map(|t| t["name"].as_str().unwrap()).collect();
826 assert_eq!(names, vec![TOOL_NAME, TOOL_GAIN, TOOL_CHECK, TOOL_DISCOVER]);
827 }
828
829 #[test]
830 fn tools_list_requires_the_four_mandatory_fields() {
831 let tools = tools_list();
832 let required: Vec<&str> = tools["tools"][0]["inputSchema"]["required"]
833 .as_array()
834 .unwrap()
835 .iter()
836 .map(|v| v.as_str().unwrap())
837 .collect();
838 assert_eq!(
839 required,
840 vec!["CommandLine", "Cwd", "toolAction", "toolSummary"]
841 );
842 }
843
844 #[test]
845 fn tools_list_offers_wait_ms_without_requiring_it() {
846 let tools = tools_list();
847 let schema = &tools["tools"][0]["inputSchema"];
848 assert!(schema["properties"]["WaitMsBeforeAsync"].is_object());
849 assert!(!schema["required"]
850 .as_array()
851 .unwrap()
852 .iter()
853 .any(|v| v == "WaitMsBeforeAsync"));
854 }
855
856 #[test]
857 fn method_not_found_uses_the_json_rpc_code() {
858 let body = method_not_found();
859 assert_eq!(body["error"]["code"], METHOD_NOT_FOUND);
860 assert_eq!(body["error"]["message"], "Method not found");
861 }
862
863 #[test]
866 fn blank_input_is_ignored() {
867 for line in ["", " ", "\t", "\r\n"] {
868 assert!(
869 handle_line(line, &never_rewrite, &never_exec).is_none(),
870 "expected silence for {line:?}"
871 );
872 }
873 }
874
875 #[test]
876 fn unparsable_input_is_ignored() {
877 for line in ["}{ not json", "{\"unterminated\":", "garbage"] {
878 assert!(
879 handle_line(line, &never_rewrite, &never_exec).is_none(),
880 "expected silence for {line:?}"
881 );
882 }
883 }
884
885 #[test]
886 fn non_object_json_is_ignored() {
887 for line in ["[1,2,3]", "\"a string\"", "42", "null"] {
888 assert!(
889 handle_line(line, &never_rewrite, &never_exec).is_none(),
890 "expected silence for {line:?}"
891 );
892 }
893 }
894
895 #[test]
896 fn a_notification_without_an_id_is_ignored() {
897 let line = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }).to_string();
898 assert!(handle_line(&line, &never_rewrite, &never_exec).is_none());
899 }
900
901 #[test]
902 fn a_request_without_a_method_is_ignored() {
903 let line = json!({ "jsonrpc": "2.0", "id": 1 }).to_string();
904 assert!(handle_line(&line, &never_rewrite, &never_exec).is_none());
905 }
906
907 #[test]
908 fn a_non_string_method_is_ignored() {
909 let line = json!({ "jsonrpc": "2.0", "id": 1, "method": 7 }).to_string();
910 assert!(handle_line(&line, &never_rewrite, &never_exec).is_none());
911 }
912
913 fn call(method: &str) -> Value {
916 let line = json!({ "jsonrpc": "2.0", "id": 1, "method": method }).to_string();
917 handle_line(&line, &no_rewrite, &ok_exec).expect("a response")
918 }
919
920 #[test]
921 fn initialize_returns_the_handshake() {
922 let response = call("initialize");
923 assert_eq!(response["result"]["serverInfo"]["name"], SERVER_NAME);
924 assert_eq!(response["jsonrpc"], "2.0");
925 }
926
927 #[test]
928 fn tools_list_is_dispatched() {
929 assert_eq!(call("tools/list")["result"]["tools"][0]["name"], TOOL_NAME);
930 }
931
932 #[test]
933 fn an_unknown_method_produces_an_error_envelope() {
934 let response = call("resources/list");
935 assert_eq!(response["error"]["code"], METHOD_NOT_FOUND);
936 assert!(
937 response.get("result").is_none(),
938 "a response carries error or result, never both: {response}"
939 );
940 }
941
942 #[test]
943 fn ids_are_echoed_verbatim() {
944 for id in [json!(7), json!("abc"), json!(null)] {
946 let line = json!({ "jsonrpc": "2.0", "id": id, "method": "initialize" }).to_string();
947 let response = handle_line(&line, &no_rewrite, &ok_exec).expect("a response");
948 assert_eq!(response["id"], id);
949 }
950 }
951
952 #[test]
953 fn methods_that_need_no_shell_never_touch_the_executor() {
954 for method in ["initialize", "tools/list", "unknown/method"] {
957 let line = json!({ "jsonrpc": "2.0", "id": 1, "method": method }).to_string();
958 handle_line(&line, &never_rewrite, &never_exec).expect("a response");
959 }
960 }
961
962 fn recording_exec(
966 log: &RefCell<Vec<(String, String)>>,
967 ) -> impl Fn(&str, &str) -> Execution + '_ {
968 move |command, cwd| {
969 log.borrow_mut()
970 .push((command.to_string(), cwd.to_string()));
971 Execution::Ran {
972 stdout: "done".to_string(),
973 stderr: String::new(),
974 success: true,
975 }
976 }
977 }
978
979 fn tools_call(arguments: Value) -> String {
980 json!({
981 "jsonrpc": "2.0",
982 "id": 1,
983 "method": "tools/call",
984 "params": { "name": TOOL_NAME, "arguments": arguments }
985 })
986 .to_string()
987 }
988
989 #[test]
990 fn tools_call_executes_the_rewritten_command() {
991 let log = RefCell::new(Vec::new());
992 let rewrite = |c: &str| Some(format!("rtk {c}"));
993
994 let response = handle_line(
995 &tools_call(json!({ "CommandLine": "cat README.md", "Cwd": "/work" })),
996 &rewrite,
997 &recording_exec(&log),
998 )
999 .expect("a response");
1000
1001 assert_eq!(
1002 log.borrow().as_slice(),
1003 &[("rtk cat README.md".to_string(), "/work".to_string())],
1004 "the rewritten command must be what actually runs"
1005 );
1006 assert_eq!(response["result"]["content"][0]["text"], "done");
1007 assert_eq!(response["result"]["isError"], false);
1008 }
1009
1010 #[test]
1011 fn tools_call_falls_back_to_the_original_command() {
1012 let log = RefCell::new(Vec::new());
1015
1016 handle_line(
1017 &tools_call(json!({ "CommandLine": "git status", "Cwd": "." })),
1018 &no_rewrite,
1019 &recording_exec(&log),
1020 )
1021 .expect("a response");
1022
1023 assert_eq!(
1024 log.borrow()[0].0,
1025 "git status",
1026 "an un-rewritable command must run unchanged"
1027 );
1028 }
1029
1030 #[test]
1031 fn tools_call_applies_the_default_working_directory() {
1032 let log = RefCell::new(Vec::new());
1033 handle_line(
1034 &tools_call(json!({ "CommandLine": "ls" })),
1035 &no_rewrite,
1036 &recording_exec(&log),
1037 )
1038 .expect("a response");
1039 assert_eq!(log.borrow()[0].1, ".");
1040 }
1041
1042 #[test]
1043 fn tools_call_surfaces_a_failed_command() {
1044 let failing = |_: &str, _: &str| Execution::Ran {
1045 stdout: String::new(),
1046 stderr: "no such file".to_string(),
1047 success: false,
1048 };
1049 let response = handle_line(
1050 &tools_call(json!({ "CommandLine": "cat missing" })),
1051 &no_rewrite,
1052 &failing,
1053 )
1054 .expect("a response");
1055
1056 assert_eq!(response["result"]["isError"], true);
1057 assert_eq!(response["result"]["content"][0]["text"], "no such file");
1058 }
1059
1060 #[test]
1061 fn tools_call_surfaces_a_spawn_failure() {
1062 let broken = |_: &str, _: &str| Execution::NotRun("program not found".to_string());
1063 let response = handle_line(
1064 &tools_call(json!({ "CommandLine": "anything" })),
1065 &no_rewrite,
1066 &broken,
1067 )
1068 .expect("a response");
1069
1070 assert_eq!(response["result"]["isError"], true);
1071 assert_eq!(
1072 response["result"]["content"][0]["text"],
1073 "program not found"
1074 );
1075 }
1076
1077 #[test]
1078 fn tools_call_without_arguments_still_answers() {
1079 let log = RefCell::new(Vec::new());
1081 let line = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call" }).to_string();
1082
1083 let response = handle_line(&line, &no_rewrite, &recording_exec(&log)).expect("a response");
1084 assert_eq!(log.borrow()[0], (String::new(), ".".to_string()));
1085 assert!(response["result"]["isError"].is_boolean());
1086 }
1087
1088 #[test]
1091 fn shell_quote_protects_against_injection() {
1092 assert_eq!(shell_quote("hello"), "'hello'");
1093 assert_eq!(shell_quote("hello world"), "'hello world'");
1094 assert_eq!(shell_quote("echo 'hi'"), "'echo '\\''hi'\\'''");
1095 assert_eq!(shell_quote("foo; rm -rf /"), "'foo; rm -rf /'");
1096 }
1097
1098 #[test]
1099 fn build_gain_command_handles_options() {
1100 assert_eq!(build_gain_command(&json!({})).unwrap(), "rtk gain");
1101 assert_eq!(
1102 build_gain_command(&json!({
1103 "history": true,
1104 "project_only": true,
1105 "format": "json"
1106 }))
1107 .unwrap(),
1108 "rtk gain --history --project --format json"
1109 );
1110 assert_eq!(
1111 build_gain_command(&json!({
1112 "history": false,
1113 "project_only": true,
1114 "format": "csv"
1115 }))
1116 .unwrap(),
1117 "rtk gain --project --format csv"
1118 );
1119 assert!(build_gain_command(&json!({ "format": "xml" })).is_err());
1120 }
1121
1122 #[test]
1123 fn build_check_command_formats_command() {
1124 assert_eq!(
1125 build_check_command(&json!({ "command": "grep -rn foo src/" })).unwrap(),
1126 "rtk hook check -- 'grep -rn foo src/'"
1127 );
1128 assert_eq!(
1129 build_check_command(&json!({ "command": "echo 'hi'; rm -rf /" })).unwrap(),
1130 "rtk hook check -- 'echo '\\''hi'\\''; rm -rf /'"
1131 );
1132 assert!(build_check_command(&json!({})).is_err());
1133 assert!(build_check_command(&json!({ "command": " " })).is_err());
1134 }
1135
1136 #[test]
1137 fn build_discover_command_handles_options() {
1138 assert_eq!(build_discover_command(&json!({})).unwrap(), "rtk discover");
1139 assert_eq!(
1140 build_discover_command(&json!({
1141 "project": "my project",
1142 "limit": 5,
1143 "since": 14,
1144 "all_projects": true,
1145 "format": "json"
1146 }))
1147 .unwrap(),
1148 "rtk discover --project 'my project' --limit 5 --since 14 --all --format json"
1149 );
1150 assert!(build_discover_command(&json!({ "format": "yaml" })).is_err());
1151 }
1152
1153 #[test]
1154 fn tools_call_dispatches_gain() {
1155 let log = RefCell::new(Vec::new());
1156 let line = json!({
1157 "jsonrpc": "2.0",
1158 "id": 1,
1159 "method": "tools/call",
1160 "params": {
1161 "name": TOOL_GAIN,
1162 "arguments": { "history": true, "Cwd": "/my/repo" }
1163 }
1164 })
1165 .to_string();
1166
1167 let response = handle_line(&line, &no_rewrite, &recording_exec(&log)).expect("a response");
1168 assert_eq!(
1169 log.borrow().as_slice(),
1170 &[("rtk gain --history".to_string(), "/my/repo".to_string())]
1171 );
1172 assert_eq!(response["result"]["content"][0]["text"], "done");
1173 assert_eq!(response["result"]["isError"], false);
1174 }
1175
1176 #[test]
1177 fn tools_call_dispatches_check() {
1178 let log = RefCell::new(Vec::new());
1179 let line = json!({
1180 "jsonrpc": "2.0",
1181 "id": 2,
1182 "method": "tools/call",
1183 "params": {
1184 "name": TOOL_CHECK,
1185 "arguments": { "command": "ls -la", "Cwd": "/my/repo" }
1186 }
1187 })
1188 .to_string();
1189
1190 let response = handle_line(&line, &no_rewrite, &recording_exec(&log)).expect("a response");
1191 assert_eq!(
1192 log.borrow().as_slice(),
1193 &[(
1194 "rtk hook check -- 'ls -la'".to_string(),
1195 "/my/repo".to_string()
1196 )]
1197 );
1198 assert_eq!(response["result"]["content"][0]["text"], "done");
1199 assert_eq!(response["result"]["isError"], false);
1200 }
1201
1202 #[test]
1203 fn tools_call_dispatches_discover() {
1204 let log = RefCell::new(Vec::new());
1205 let line = json!({
1206 "jsonrpc": "2.0",
1207 "id": 3,
1208 "method": "tools/call",
1209 "params": {
1210 "name": TOOL_DISCOVER,
1211 "arguments": { "all_projects": true }
1212 }
1213 })
1214 .to_string();
1215
1216 let response = handle_line(&line, &no_rewrite, &recording_exec(&log)).expect("a response");
1217 assert_eq!(
1218 log.borrow().as_slice(),
1219 &[("rtk discover --all".to_string(), ".".to_string())]
1220 );
1221 assert_eq!(response["result"]["content"][0]["text"], "done");
1222 assert_eq!(response["result"]["isError"], false);
1223 }
1224
1225 #[test]
1226 fn tools_call_rejects_unknown_tool() {
1227 let line = json!({
1228 "jsonrpc": "2.0",
1229 "id": 4,
1230 "method": "tools/call",
1231 "params": {
1232 "name": "not_a_real_tool",
1233 "arguments": {}
1234 }
1235 })
1236 .to_string();
1237
1238 let response = handle_line(&line, &no_rewrite, &never_exec).expect("a response");
1239 assert_eq!(response["result"]["isError"], true);
1240 assert_eq!(
1241 response["result"]["content"][0]["text"],
1242 "Unknown tool: not_a_real_tool"
1243 );
1244 }
1245
1246 #[test]
1247 fn tools_call_surfaces_validation_errors() {
1248 let line = json!({
1249 "jsonrpc": "2.0",
1250 "id": 5,
1251 "method": "tools/call",
1252 "params": {
1253 "name": TOOL_CHECK,
1254 "arguments": { "command": "" }
1255 }
1256 })
1257 .to_string();
1258
1259 let response = handle_line(&line, &no_rewrite, &never_exec).expect("a response");
1260 assert_eq!(response["result"]["isError"], true);
1261 assert_eq!(
1262 response["result"]["content"][0]["text"],
1263 "command must not be empty"
1264 );
1265 }
1266
1267 fn injected_message() -> String {
1270 let value = serde_json::to_value(preinvocation_payload()).unwrap();
1271 value["injectSteps"][0]["ephemeralMessage"]
1272 .as_str()
1273 .unwrap()
1274 .to_string()
1275 }
1276
1277 #[test]
1278 fn preinvocation_emits_one_step() {
1279 let value = serde_json::to_value(preinvocation_payload()).unwrap();
1280 assert_eq!(value["injectSteps"].as_array().unwrap().len(), 1);
1281 }
1282
1283 #[test]
1284 fn preinvocation_names_the_server_and_tool_it_advertises() {
1285 let message = injected_message();
1288 assert!(
1289 message.contains(&format!("ServerName='{AGY_SERVER_NAME}'")),
1290 "got: {message}"
1291 );
1292 assert!(
1293 message.contains(&format!("ToolName='{TOOL_NAME}'")),
1294 "got: {message}"
1295 );
1296 }
1297
1298 #[test]
1299 fn preinvocation_steers_away_from_the_native_tool() {
1300 assert!(injected_message().contains("run_command"));
1303 }
1304}