lean_ctx_server/tools/registered/
ctx_execute.rs1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7 McpTool, ShellOutcome, ToolContext, ToolOutput, get_int, get_str, require_resolved_path,
8};
9use lean_ctx::tool_defs::tool_def;
10
11pub struct CtxExecuteTool;
12
13impl McpTool for CtxExecuteTool {
14 fn name(&self) -> &'static str {
15 "ctx_execute"
16 }
17
18 fn tool_def(&self) -> Tool {
19 tool_def(
20 "ctx_execute",
21 "Run code in sandbox (11 languages) — use when conditionals, multi-line or cross-language transforms.\n\
22 ANTIPATTERN: for simple one-liners, prefer ctx_shell (lower overhead, auto-compressed).\n\
23 language=shell supports multi-line scripts but shares ctx_shell's security policy;\n\
24 other languages run only after the user approves them.\n\
25 action=code (default) for one-shot; action=batch for parallel multi-language;\n\
26 action=file to summarize admitted project text as data, without executing it.\n\
27 For code/batch, intent annotates large output; file records the intent alongside its preview. Languages: javascript,\n\
28 typescript, python, shell, ruby, go, rust, php, perl, r, elixir.",
29 json!({
30 "type": "object",
31 "properties": {
32 "language": {
33 "type": "string",
34 "description": "javascript|typescript|python|shell|ruby|go|rust|php|perl|r|elixir (for action=code)"
35 },
36 "code": {
37 "type": "string",
38 "description": "Source code for action=code. Set intent to filter large output."
39 },
40 "intent": {
41 "type": "string",
42 "description": "Focus intent; triggers filtering when output is large."
43 },
44 "timeout": {
45 "type": "integer",
46 "description": "Timeout in seconds (default: 30)"
47 },
48 "action": {
49 "type": "string",
50 "description": "code (default, run script) | batch (parallel) | file (project file)"
51 },
52 "items": {
53 "type": "string",
54 "description": "JSON array of [{language, code}] for batch action."
55 },
56 "path": {
57 "type": "string",
58 "description": "Project text file for action=file; content is inspected, never executed."
59 }
60 },
61 "oneOf": [
62 {
63 "properties": { "action": { "enum": ["code"] } },
64 "required": ["language", "code"]
65 },
66 {
67 "properties": { "action": { "const": "batch" } },
68 "required": ["action", "items"]
69 },
70 {
71 "properties": { "action": { "const": "file" } },
72 "required": ["action", "path"]
73 }
74 ]
75 }),
76 )
77 }
78
79 fn handle(
80 &self,
81 args: &Map<String, Value>,
82 ctx: &ToolContext,
83 ) -> Result<ToolOutput, ErrorData> {
84 let action = get_str(args, "action").unwrap_or_default();
85
86 if action != "file"
89 && let Some(message) = super::ctx_shell::shell_access_denial(ctx)
90 {
91 return Ok(ToolOutput {
92 shell_outcome: Some(crate::server::tool_trait::ShellOutcome::Blocked),
93 content_blocks: None,
94 ..ToolOutput::simple(message)
95 });
96 }
97 let (result, outcome) = if action == "batch" {
98 let items_str = get_str(args, "items")
99 .ok_or_else(|| ErrorData::invalid_params("items is required for batch", None))?;
100 let items: Vec<serde_json::Value> = serde_json::from_str(&items_str)
101 .map_err(|e| ErrorData::invalid_params(format!("Invalid items JSON: {e}"), None))?;
102 let batch: Vec<(String, String)> = items
103 .iter()
104 .filter_map(|item| {
105 let lang = item.get("language")?.as_str()?.to_string();
106 let code = item.get("code")?.as_str()?.to_string();
107 Some((lang, code))
108 })
109 .collect();
110 match crate::tools::ctx_execute::refuse_unapproved(
111 batch.iter().map(|(language, _)| language.as_str()),
112 ) {
113 Ok(()) => crate::tools::ctx_execute::handle_batch(&batch),
114 Err(message) => (message, ShellOutcome::Blocked),
115 }
116 } else if action == "file" {
117 let path = require_resolved_path(ctx, args, "path")?;
118 let project_root = if ctx.project_root.is_empty() {
119 None
120 } else {
121 Some(ctx.project_root.as_str())
122 };
123 let intent = get_str(args, "intent");
124 crate::tools::ctx_execute::handle_file(&path, intent.as_deref(), project_root)
125 } else {
126 let language = get_str(args, "language")
127 .ok_or_else(|| ErrorData::invalid_params("language is required", None))?;
128 let code = get_str(args, "code")
129 .ok_or_else(|| ErrorData::invalid_params("code is required", None))?;
130 let intent = get_str(args, "intent");
131 let timeout = get_int(args, "timeout").and_then(|t| u64::try_from(t).ok());
132 match crate::tools::ctx_execute::refuse_unapproved([language.as_str()]) {
133 Ok(()) => {
134 crate::tools::ctx_execute::handle(&language, &code, intent.as_deref(), timeout)
135 }
136 Err(message) => (message, ShellOutcome::Blocked),
137 }
138 };
139
140 let result = lean_ctx::core::redaction::redact_text_if_enabled(&result);
141 Ok(ToolOutput {
142 text: result,
143 original_tokens: 0,
144 saved_tokens: 0,
145 mode: Some(action),
146 path: None,
147 changed: false,
148 shell_outcome: Some(outcome),
149 content_blocks: None,
150 })
151 }
152
153 fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
158 args.and_then(|args| args.get("action"))
159 .and_then(Value::as_str)
160 == Some("file")
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::server::tool_trait::ShellOutcome;
168
169 fn args(pairs: &[(&str, &str)]) -> Map<String, Value> {
170 pairs
171 .iter()
172 .map(|(k, v)| ((*k).to_string(), Value::String((*v).to_string())))
173 .collect()
174 }
175
176 #[test]
179 fn restricted_clients_cannot_execute_code() {
180 let code = args(&[("language", "python"), ("code", "print(1)")]);
181 let batch = args(&[
182 ("action", "batch"),
183 ("items", r#"[{"language":"python","code":"print(1)"}]"#),
184 ]);
185 let contexts = [
186 ToolContext {
187 client_role: Some("untrusted".to_string()),
188 ..ToolContext::default()
189 },
190 ToolContext {
191 client_role: Some("readonly".to_string()),
192 ..ToolContext::default()
193 },
194 ToolContext {
195 shell_access: Some(false),
196 ..ToolContext::default()
197 },
198 ];
199 for ctx in &contexts {
200 for request in [&code, &batch] {
201 let output = CtxExecuteTool
202 .handle(request, ctx)
203 .expect("denial is a tool result");
204 assert_eq!(output.shell_outcome, Some(ShellOutcome::Blocked));
205 assert!(
206 output.text.contains("SHELL ACCESS DENIED"),
207 "{}",
208 output.text
209 );
210 }
211 }
212 }
213}