lean_ctx_server/tools/registered/shell_alias.rs
1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str};
7use lean_ctx::tool_defs::tool_def;
8
9/// A `shell` tool alias that transparently delegates to `ctx_shell`'s compression
10/// logic. Registered for all MCP clients (see `server::registry`); it exists for
11/// clients (like Codex Desktop) whose agent model prefers a tool named `shell` /
12/// `bash` over `ctx_shell` and would otherwise fall back to a native, uncompressed
13/// shell tool.
14///
15/// This solves the "Codex Desktop doesn't compress" issue (#337): the Desktop app
16/// loads the MCP server but the agent ignores `ctx_shell` and uses its native
17/// `Bash` tool instead. By providing a `shell` tool with a familiar interface,
18/// the model naturally routes commands through our compression pipeline.
19pub struct ShellAliasTool;
20
21impl McpTool for ShellAliasTool {
22 fn name(&self) -> &'static str {
23 "shell"
24 }
25
26 fn rights_name(&self) -> &'static str {
27 "ctx_shell"
28 }
29
30 fn tool_def(&self) -> Tool {
31 tool_def(
32 "shell",
33 "Shell command with auto-compression (~95 patterns). Alias for ctx_shell.\n\
34 Output is compressed for token savings. Big logs: narrow the command (grep, sed -n, head/tail).\n\
35 Use when your MCP client prefers shell/bash over ctx_shell — transparently\n\
36 delegates to ctx_shell internals.",
37 json!({
38 "type": "object",
39 "properties": {
40 "command": {
41 "type": "string",
42 "description": "Shell command"
43 },
44 "cwd": {
45 "type": "string",
46 "description": "Working dir"
47 },
48 "raw": {
49 "type": "boolean",
50 "description": "Exact bytes, uncompressed; only for diffs to apply, hashes, encoded output. Default false."
51 }
52 },
53 "required": ["command"]
54 }),
55 )
56 }
57
58 fn handle(
59 &self,
60 args: &Map<String, Value>,
61 ctx: &ToolContext,
62 ) -> Result<ToolOutput, ErrorData> {
63 let command = get_str(args, "command")
64 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
65
66 // #1811: the write-doctrine check is deliberately NOT repeated here.
67 // It judges where a relative redirect target lands, and only the
68 // delegate below knows that — it resolves the requested `cwd` through
69 // the project-root jail before deciding. Judging the raw argument here
70 // would answer for a directory the command may never run in, and a
71 // second copy of the rule is a second answer waiting to drift from the
72 // first. The delegate refuses, with its own message, before executing.
73 if let Err(msg) = lean_ctx_shell_policy::check_shell_allowlist(&command) {
74 return Ok(ToolOutput::simple(msg.to_string()));
75 }
76
77 tokio::task::block_in_place(|| {
78 let cwd = get_str(args, "cwd");
79 // Compressed by default (the point of this alias), but honor an explicit
80 // raw=true so clients restricted to "shell"/"bash" still have the verbatim
81 // escape the description advertises — no MCP-specific tool required.
82 let raw = args.get("raw").and_then(Value::as_bool).unwrap_or(false);
83 let mut shell_args = Map::new();
84 shell_args.insert("command".to_string(), Value::String(command));
85 if let Some(dir) = cwd {
86 shell_args.insert("cwd".to_string(), Value::String(dir));
87 }
88 shell_args.insert("raw".to_string(), Value::Bool(raw));
89
90 crate::tools::registered::ctx_shell::CtxShellTool.handle(&shell_args, ctx)
91 })
92 }
93}