Skip to main content

rtk_mcp_agy/
lib.rs

1//! Shared logic for the `rtk-mcp-agy` plugin.
2//!
3//! Everything here is pure: JSON-RPC dispatch, response shaping, and the
4//! `PreInvocation` payload. The two binaries in `src/bin/` only move bytes and
5//! spawn processes.
6//!
7//! The split exists because the interesting behaviour is otherwise unreachable
8//! by a test. [`handle_line`] takes the rewriter and the executor as closures,
9//! so the `tools/call` path — which in production shells out to `rtk` and then
10//! to a shell — can be exercised on a machine that has neither installed, and
11//! its failure modes can be forced rather than waited for.
12//!
13//! The seam earned itself immediately: it is what made the hardcoded `pwsh`
14//! visible as a portability bug (see [`resolve_shell`]) rather than something
15//! only a Linux user would ever discover.
16
17use serde::Serialize;
18use serde_json::{json, Value};
19
20// The two names below are macros rather than plain `const`s only so that
21// `concat!` can join them at compile time; `concat!` accepts literals, not consts.
22/// The server key registered in `mcp_config.json`.
23macro_rules! server_name {
24    () => {
25        "rtkmcp"
26    };
27}
28/// The plugin ID in `plugin.json`, and the plugin's directory name in Antigravity.
29macro_rules! plugin_name {
30    () => {
31        "rtk-mcp-agy"
32    };
33}
34
35/// The server name an MCP client sees, and the key `mcp_config.json` registers.
36pub const SERVER_NAME: &str = server_name!();
37/// The plugin directory / ID in Antigravity.
38pub const PLUGIN_NAME: &str = plugin_name!();
39/// The namespaced server name Antigravity assigns to this plugin's MCP server.
40///
41/// Antigravity exposes a plugin's MCP server as `<plugin>_<server>`. Measured
42/// 2026-11-03 on a live Antigravity session: the server appears in its
43/// `<mcp_servers>` list as `rtk-mcp-agy_rtkmcp`, and the tool manifest lands on
44/// disk at `~/.gemini/antigravity-cli/mcp/rtk-mcp-agy_rtkmcp/rtk_run.json`.
45/// The e2e test `preinvocation_names_the_registered_server_and_tool` rebuilds
46/// this name from `plugin.json` and `mcp_config.json`, so it cannot drift here
47/// without a test going red.
48pub const AGY_SERVER_NAME: &str = concat!(plugin_name!(), "_", server_name!());
49/// Run an rtk-optimized command.
50pub const TOOL_NAME: &str = "rtk_run";
51/// Report rtk token savings.
52pub const TOOL_GAIN: &str = "rtk_gain";
53/// Show how rtk would rewrite a shell command without running it.
54pub const TOOL_CHECK: &str = "rtk_check";
55/// Analyze past session history for missed token savings.
56pub const TOOL_DISCOVER: &str = "rtk_discover";
57/// The MCP protocol revision spoken by this server.
58pub const PROTOCOL_VERSION: &str = "2024-11-05";
59/// JSON-RPC's "method not found".
60pub const METHOD_NOT_FOUND: i64 = -32601;
61
62/// The crate version, and the single source for every place one is reported.
63///
64/// Both binaries' `--version` and the `initialize` handshake read this. They
65/// used to compute it separately, and the handshake's copy was a literal that
66/// drifted to `0.1.0` while the crate was at `0.1.5`.
67pub fn version() -> &'static str {
68    env!("CARGO_PKG_VERSION")
69}
70
71// ── External programs ──────────────────────────────────────────────────
72
73/// The rtk executable, when `RTK_BIN` says nothing.
74pub const DEFAULT_RTK_BIN: &str = "rtk";
75/// The shell used to run commands on Windows.
76pub const DEFAULT_WINDOWS_SHELL: &str = "pwsh";
77/// The shell used to run commands everywhere else.
78///
79/// `sh` rather than `bash`: POSIX guarantees it exists, and the commands rtk
80/// produces do not need bash extensions.
81pub const DEFAULT_UNIX_SHELL: &str = "sh";
82
83/// Decide which rtk executable to invoke.
84///
85/// `RTK_BIN` is spelled without a plugin prefix on purpose: it is the same
86/// variable the sibling `rtk-mcp-qwen` and `rtk-mcp-cc` plugins honour, so a
87/// user who relocates rtk sets one variable for all three.
88pub fn resolve_rtk_bin(override_value: Option<String>) -> String {
89    non_blank(override_value).unwrap_or_else(|| DEFAULT_RTK_BIN.to_string())
90}
91
92/// Decide which shell runs the rewritten command.
93///
94/// This used to be a hardcoded `pwsh`, which meant the `tools/call` path could
95/// not execute anything at all on Linux or macOS — PowerShell 7 is rarely
96/// installed there. Windows keeps `pwsh` so existing installs are unaffected;
97/// everywhere else falls back to `sh`. `RTK_AGY_SHELL` overrides both.
98pub 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
108/// Trim a value, discarding it if nothing is left.
109///
110/// An environment variable set to the empty string is how a shell spells
111/// "unset" often enough that treating it as a real value would be a trap.
112fn 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// ── Execution ──────────────────────────────────────────────────────────
119
120/// The outcome of trying to run a command.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Execution {
123    /// The process ran. It may still have failed, hence `success`.
124    Ran {
125        stdout: String,
126        stderr: String,
127        success: bool,
128    },
129    /// The process could not be spawned at all; carries the OS error text.
130    NotRun(String),
131}
132
133/// Render an execution as the `tools/call` result body.
134///
135/// stdout and stderr are joined with a single newline, and the separator is
136/// omitted when either side is empty — so a command that writes only to stderr
137/// does not come back with a leading blank line.
138pub 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
163/// Interpret the stdout of `rtk rewrite`.
164///
165/// Empty output means rtk had no rewrite to offer, which is not an error: the
166/// caller falls back to the original command. Only known commands are covered,
167/// so most invocations legitimately land here.
168pub 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
177/// Pull `CommandLine` and `Cwd` out of a `tools/call` request.
178///
179/// Missing or non-string values fall back to `""` and `"."`. A malformed call
180/// therefore runs an empty command in the current directory rather than
181/// panicking the server.
182pub 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
203/// Quote a string safely for inclusion in a shell command line.
204///
205/// Wraps the string in single quotes, replacing any internal single quote `'`
206/// with `'\''`. Single-quoted strings are interpreted literally by POSIX shells
207/// and PowerShell, preventing command injection and shell metacharacter expansion.
208pub 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
225/// Extract the `Cwd` argument or fall back to `"."`.
226pub 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
234/// Build the command line for `rtk gain`.
235pub 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
261/// Build the command line for `rtk hook check`.
262///
263/// The dry-run inspection subcommand in `rtk` is `hook check` (not `check`).
264/// The command argument is safely single-quoted via [`shell_quote`] to prevent
265/// arbitrary command injection when executed via a shell.
266pub 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
278/// Build the command line for `rtk discover`.
279pub 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
310// ── Method results ─────────────────────────────────────────────────────
311
312/// The `initialize` result.
313pub fn server_info() -> Value {
314    json!({
315        "protocolVersion": PROTOCOL_VERSION,
316        "capabilities": { "tools": {} },
317        "serverInfo": { "name": SERVER_NAME, "version": version() }
318    })
319}
320
321/// The `tools/list` result advertising all 4 tools.
322pub 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
414/// The body used for an unrecognised method.
415pub fn method_not_found() -> Value {
416    json!({
417        "error": {
418            "code": METHOD_NOT_FOUND,
419            "message": "Method not found"
420        }
421    })
422}
423
424// ── Dispatch ───────────────────────────────────────────────────────────
425
426/// Dispatch a parsed JSON-RPC request to the appropriate method handler.
427pub 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    // A notification has no id and must not be answered.
433    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
488/// Handle one line of input, returning the response to write — or `None` when
489/// the server must stay silent.
490///
491/// Silence is the correct answer more often than it looks: blank lines,
492/// unparsable JSON, and notifications (which carry no `id`) all arrive on a
493/// healthy connection. `notifications/initialized` in particular is sent by
494/// every MCP client right after the handshake, and answering it would corrupt
495/// the stream.
496///
497/// `rewrite` maps a command to its rtk-optimized form, and `exec` runs the
498/// resulting command in a directory. Both are injected so the whole dispatch
499/// can be tested without spawning anything.
500pub 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
513/// Handle a parsed JSON-RPC request using default fallback closures.
514pub 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
522/// Wrap a result body in its JSON-RPC envelope.
523///
524/// An error body is hoisted to the top-level `error` member; anything else
525/// becomes `result`. A response carries one or the other, never both.
526fn 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// ── PreInvocation hook ─────────────────────────────────────────────────
534
535/// The payload the `PreInvocation` hook writes to stdout.
536#[derive(Debug, Serialize)]
537pub struct PreInvocationOutput {
538    #[serde(rename = "injectSteps")]
539    pub inject_steps: Vec<Value>,
540}
541
542/// Build the injected instruction that steers the model onto the MCP tool.
543///
544/// Antigravity's `PreToolUse` hook cannot rewrite tool arguments, so the model
545/// has to be told to call the MCP tool instead of its native `run_command`.
546/// The server and tool names are interpolated from the constants above rather
547/// than spelled out, so this message cannot drift from what the server actually
548/// advertises.
549pub 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    // Closures for the dispatch tests. `never` panics so a test proves a path
567    // was not taken, rather than merely not asserting on it.
568    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    // ── version ────────────────────────────────────────────────────
586
587    #[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        // The original defect: a literal here drifted to 0.1.0 while the crate
596        // was at 0.1.5, and the handshake is the only version a client sees.
597        assert_eq!(server_info()["serverInfo"]["version"], version());
598    }
599
600    // ── external program resolution ────────────────────────────────
601
602    #[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        // `RTK_BIN=` is how a shell commonly spells "unset"; taking it
619        // literally would try to spawn the empty string.
620        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        // The bug this fixes: a hardcoded `pwsh` made tools/call unable to run
628        // anything on Linux or macOS.
629        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    // ── parse_rewrite ──────────────────────────────────────────────
661
662    #[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    // ── call_arguments ─────────────────────────────────────────────
695
696    #[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        // A client sending the wrong type must not take the server down.
733        let req = json!({ "params": { "arguments": { "CommandLine": 42, "Cwd": ["/tmp"] } } });
734        assert_eq!(call_arguments(&req), (String::new(), ".".to_string()));
735    }
736
737    // ── format_execution ───────────────────────────────────────────
738
739    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    // ── method bodies ──────────────────────────────────────────────
808
809    #[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    // ── handle_line: silence ───────────────────────────────────────
864
865    #[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    // ── handle_line: answers ───────────────────────────────────────
914
915    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        // Clients correlate on the id, and JSON-RPC allows numbers or strings.
945        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        // `never_exec` and `never_rewrite` panic, so this passing proves the
955        // handshake path is free of side effects.
956        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    // ── handle_line: tools/call ────────────────────────────────────
963
964    /// Record what the executor was handed.
965    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        // rtk only covers known commands, so declining a rewrite is the common
1013        // case — `git`, `npm` and project scripts must still run.
1014        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        // A malformed call must produce a response, not a dead server.
1080        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    // ── analytics tools tests ─────────────────────────────────────
1089
1090    #[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    // ── PreInvocation payload ──────────────────────────────────────
1268
1269    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        // The names are interpolated from the same constants the server uses,
1286        // so this cannot drift — which is the point of asserting it.
1287        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        // The whole reason the hook exists: agy's PreToolUse cannot rewrite
1301        // tool arguments, so the model must be told not to use run_command.
1302        assert!(injected_message().contains("run_command"));
1303    }
1304}