1use 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
20pub 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 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 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
79fn 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#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
174pub struct ShellExecRequest {
175 pub command: String,
178 #[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 #[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#[derive(Clone)]
232pub struct ShellServer {
233 engine: Arc<Engine>,
234 #[allow(dead_code)] 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}