Skip to main content

shell_mcp/
tools.rs

1//! MCP tool definitions and the underlying [`Engine`] that performs the
2//! reject/allow/execute pipeline.
3//!
4//! The engine is exposed publicly so integration tests can drive the same
5//! code path the MCP server uses without wiring up stdio.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use rmcp::handler::server::router::tool::ToolRouter;
11use rmcp::handler::server::wrapper::Parameters;
12use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
13use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
14use serde::{Deserialize, Serialize};
15
16use crate::config::{ConfigCache, ConfigError, LoadedConfig};
17use crate::exec::{execute, ExecError, ExecOptions, ExecOutcome};
18use crate::safety::{check_hard_denylist, check_metacharacters, resolve_cwd, tokenize, Rejection};
19
20/// The pure-Rust core: takes a command string + optional subdir, returns a
21/// structured result. No MCP types here — the [`ShellServer`] adapter wraps
22/// this in MCP responses.
23pub struct Engine {
24    root: PathBuf,
25    cache: ConfigCache,
26}
27
28impl Engine {
29    pub fn new(root: impl Into<PathBuf>) -> Self {
30        Self {
31            root: into_normal(root.into()),
32            cache: ConfigCache::new(),
33        }
34    }
35
36    pub fn root(&self) -> &Path {
37        &self.root
38    }
39
40    /// Resolve and return the merged config for `subdir` (or the root).
41    pub fn describe(&self, subdir: Option<&str>) -> Result<DescribeResult, EngineError> {
42        let cwd = resolve_cwd(&self.root, subdir).map_err(EngineError::Rejection)?;
43        let loaded = self.cache.get_or_load(&self.root, &cwd)?;
44        Ok(DescribeResult::from_loaded(loaded))
45    }
46
47    /// Run the full pipeline: metacharacter check → tokenize → hard deny →
48    /// resolve cwd → load config → allowlist → execute.
49    pub async fn exec(
50        &self,
51        command: &str,
52        subdir: Option<&str>,
53    ) -> Result<ExecResult, EngineError> {
54        check_metacharacters(command).map_err(EngineError::Rejection)?;
55        let tokens = tokenize(command).map_err(EngineError::Rejection)?;
56        check_hard_denylist(&tokens).map_err(EngineError::Rejection)?;
57        let cwd = resolve_cwd(&self.root, subdir).map_err(EngineError::Rejection)?;
58        let loaded = self.cache.get_or_load(&self.root, &cwd)?;
59        let matched =
60            loaded
61                .allowlist
62                .find_match(&tokens)
63                .ok_or_else(|| EngineError::NotAllowed {
64                    command: command.to_string(),
65                    sources: loaded.sources.clone(),
66                })?;
67        let matched_rule = matched.raw().to_string();
68        let matched_source = matched.source().to_string();
69        let outcome = execute(&tokens, &ExecOptions::new(cwd.clone())).await?;
70        Ok(ExecResult {
71            outcome,
72            cwd,
73            matched_rule,
74            matched_source,
75        })
76    }
77}
78
79/// Strip `.` and `..` lexically so the launch root is a stable string.
80fn into_normal(p: PathBuf) -> PathBuf {
81    let mut out = PathBuf::new();
82    for c in p.components() {
83        match c {
84            std::path::Component::ParentDir => {
85                out.pop();
86            }
87            std::path::Component::CurDir => {}
88            other => out.push(other.as_os_str()),
89        }
90    }
91    out
92}
93
94#[derive(Debug)]
95pub struct ExecResult {
96    pub outcome: ExecOutcome,
97    pub cwd: PathBuf,
98    pub matched_rule: String,
99    pub matched_source: String,
100}
101
102#[derive(Debug)]
103pub struct DescribeResult {
104    pub root: PathBuf,
105    pub cwd: PathBuf,
106    pub platform: &'static str,
107    pub defaults_included: bool,
108    pub rules: Vec<DescribedRule>,
109    pub sources: Vec<PathBuf>,
110}
111
112impl DescribeResult {
113    fn from_loaded(loaded: LoadedConfig) -> Self {
114        Self {
115            root: loaded.root,
116            cwd: loaded.cwd,
117            platform: platform_label(),
118            defaults_included: loaded.defaults_included,
119            rules: loaded
120                .allowlist
121                .rules()
122                .iter()
123                .map(|r| DescribedRule {
124                    pattern: r.raw().to_string(),
125                    source: r.source().to_string(),
126                })
127                .collect(),
128            sources: loaded.sources,
129        }
130    }
131}
132
133#[derive(Debug, Serialize)]
134pub struct DescribedRule {
135    pub pattern: String,
136    pub source: String,
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum EngineError {
141    #[error(transparent)]
142    Rejection(Rejection),
143
144    #[error(transparent)]
145    Config(#[from] ConfigError),
146
147    #[error(transparent)]
148    Exec(#[from] ExecError),
149
150    #[error("command not in allowlist: `{command}`. Loaded config files: {sources:?}. Use `shell_describe` to inspect the active rules.")]
151    NotAllowed {
152        command: String,
153        sources: Vec<PathBuf>,
154    },
155}
156
157fn platform_label() -> &'static str {
158    if cfg!(target_os = "macos") {
159        "macos"
160    } else if cfg!(target_os = "linux") {
161        "linux"
162    } else if cfg!(target_os = "windows") {
163        "windows"
164    } else {
165        "unknown"
166    }
167}
168
169// --------------------------------------------------------------------
170// MCP wire types
171// --------------------------------------------------------------------
172
173#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
174pub struct ShellExecRequest {
175    /// Shell command to execute. Pipelines and redirections are not
176    /// permitted in v0.1 — write a script and allowlist it instead.
177    pub command: String,
178    /// Optional subdirectory under the launch root in which to run the
179    /// command. Must remain inside the launch root.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub cwd: Option<String>,
182}
183
184#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
185pub struct ShellDescribeRequest {
186    /// Optional subdirectory under the launch root to introspect. Defaults
187    /// to the launch root itself.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub cwd: Option<String>,
190}
191
192#[derive(Debug, Serialize)]
193struct ExecResponse<'a> {
194    ok: bool,
195    cwd: String,
196    matched_rule: &'a str,
197    matched_rule_source: &'a str,
198    exit_code: Option<i32>,
199    truncated: bool,
200    timed_out: bool,
201    stdout: &'a str,
202    stderr: &'a str,
203}
204
205#[derive(Debug, Serialize)]
206struct RejectionResponse<'a> {
207    ok: bool,
208    rejection: RejectionPayload<'a>,
209}
210
211#[derive(Debug, Serialize)]
212struct RejectionPayload<'a> {
213    kind: &'a str,
214    message: String,
215}
216
217#[derive(Debug, Serialize)]
218struct DescribeResponse<'a> {
219    root: String,
220    cwd: String,
221    platform: &'a str,
222    defaults_included: bool,
223    rules: &'a [DescribedRule],
224    config_files_loaded: Vec<String>,
225}
226
227// --------------------------------------------------------------------
228// MCP server
229// --------------------------------------------------------------------
230
231#[derive(Clone)]
232pub struct ShellServer {
233    engine: Arc<Engine>,
234    #[allow(dead_code)] // read by `#[tool_handler]`-generated code
235    tool_router: ToolRouter<Self>,
236}
237
238impl ShellServer {
239    pub fn new(engine: Arc<Engine>) -> Self {
240        Self {
241            engine,
242            tool_router: Self::tool_router(),
243        }
244    }
245}
246
247#[tool_router]
248impl ShellServer {
249    #[tool(
250        description = "Execute a shell command from the merged allowlist and return stdout, stderr, exit code, and a truncation flag. Rejects shell metacharacters; rejects sudo and other hard-denied commands; rejects commands not in the allowlist. Always run shell_describe first to see the active rules."
251    )]
252    async fn shell_exec(
253        &self,
254        Parameters(req): Parameters<ShellExecRequest>,
255    ) -> Result<CallToolResult, McpError> {
256        match self.engine.exec(&req.command, req.cwd.as_deref()).await {
257            Ok(result) => {
258                let body = ExecResponse {
259                    ok: true,
260                    cwd: result.cwd.display().to_string(),
261                    matched_rule: &result.matched_rule,
262                    matched_rule_source: &result.matched_source,
263                    exit_code: result.outcome.exit_code,
264                    truncated: result.outcome.truncated,
265                    timed_out: result.outcome.timed_out,
266                    stdout: &result.outcome.stdout,
267                    stderr: &result.outcome.stderr,
268                };
269                Ok(CallToolResult::success(vec![Content::text(
270                    serde_json::to_string_pretty(&body).unwrap_or_else(|e| {
271                        format!("{{\"ok\":false,\"serialization_error\":\"{e}\"}}")
272                    }),
273                )]))
274            }
275            Err(EngineError::Rejection(r)) => {
276                let body = RejectionResponse {
277                    ok: false,
278                    rejection: RejectionPayload {
279                        kind: r.kind().as_str(),
280                        message: r.to_string(),
281                    },
282                };
283                Ok(CallToolResult::success(vec![Content::text(
284                    serde_json::to_string_pretty(&body).unwrap_or_default(),
285                )]))
286            }
287            Err(EngineError::NotAllowed { .. }) => {
288                let body = RejectionResponse {
289                    ok: false,
290                    rejection: RejectionPayload {
291                        kind: "not_allowlisted",
292                        message: format!(
293                            "{}",
294                            EngineError::NotAllowed {
295                                command: req.command.clone(),
296                                sources: vec![],
297                            }
298                        ),
299                    },
300                };
301                Ok(CallToolResult::success(vec![Content::text(
302                    serde_json::to_string_pretty(&body).unwrap_or_default(),
303                )]))
304            }
305            Err(other) => Err(McpError::internal_error(other.to_string(), None)),
306        }
307    }
308
309    #[tool(
310        description = "Return the merged allowlist for the given subdirectory (or the launch root), the resolved working directory, platform, and the list of TOML files that were loaded in merge order. Call this first in any new session."
311    )]
312    async fn shell_describe(
313        &self,
314        Parameters(req): Parameters<ShellDescribeRequest>,
315    ) -> Result<CallToolResult, McpError> {
316        match self.engine.describe(req.cwd.as_deref()) {
317            Ok(d) => {
318                let body = DescribeResponse {
319                    root: d.root.display().to_string(),
320                    cwd: d.cwd.display().to_string(),
321                    platform: d.platform,
322                    defaults_included: d.defaults_included,
323                    rules: &d.rules,
324                    config_files_loaded: d
325                        .sources
326                        .iter()
327                        .map(|p| p.display().to_string())
328                        .collect(),
329                };
330                Ok(CallToolResult::success(vec![Content::text(
331                    serde_json::to_string_pretty(&body).unwrap_or_default(),
332                )]))
333            }
334            Err(EngineError::Rejection(r)) => Err(McpError::invalid_params(r.to_string(), None)),
335            Err(other) => Err(McpError::internal_error(other.to_string(), None)),
336        }
337    }
338}
339
340#[tool_handler]
341impl ServerHandler for ShellServer {
342    fn get_info(&self) -> ServerInfo {
343        ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
344            "shell-mcp provides scoped, allowlisted shell access. Call `shell_describe` \
345             first to see the active rules and the resolved working directory, then \
346             `shell_exec` to run commands. Pipelines, redirections, and `sudo` are always \
347             rejected; write commands require an explicit per-directory `.shell-mcp.toml` \
348             allowlist.",
349        )
350    }
351}