yggdrasil/mcp/tools/
runner.rs1#![cfg(feature = "server")]
13
14use rmcp::handler::server::tool::Extension;
15use rmcp::handler::server::wrapper::Parameters;
16use rmcp::model::{CallToolResult, ContentBlock, TextContent};
17use rmcp::{schemars, tool, tool_router, ErrorData as McpError};
18
19use super::common::require_admin;
20use serde::{Deserialize, Serialize};
21use std::time::Duration;
22
23use crate::api::code_runner::execute::RUNNER_SEMAPHORE;
24use crate::api::code_runner::languages::{is_supported_lang, normalize_lang, LANGUAGES};
25use crate::infra::docker::run_in_container;
26use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG};
27
28#[derive(Debug, Deserialize, schemars::JsonSchema)]
30pub struct RunCodeParams {
31 pub language: String,
34 pub source: String,
36}
37
38#[derive(Debug, Serialize)]
40struct RunResult {
41 status: &'static str,
43 exit_code: Option<i64>,
45 stdout: String,
47 stderr: String,
49 duration_ms: u64,
51 language: String,
53}
54
55#[tool_router(router = runner_router, vis = "pub")]
56impl crate::mcp::server::YggMcpServer {
57 #[tool(
59 description = "在 Docker 沙箱内执行代码(支持 python/node/go/rust/bun),返回 stdout/stderr。需要 admin 作用域。"
60 )]
61 async fn run_code(
62 &self,
63 Parameters(RunCodeParams { language, source }): Parameters<RunCodeParams>,
64 Extension(parts): Extension<http::request::Parts>,
65 ) -> Result<CallToolResult, McpError> {
66 require_admin(&parts, "run_code")?;
67
68 if !is_supported_lang(&language) {
70 return Err(McpError::invalid_request(
71 "unsupported language: use one of python/node/go/rust/bun (js/rs/ts aliases accepted)",
72 None,
73 ));
74 }
75
76 if source.len() > RUNNER_CONFIG.max_source_bytes as usize {
78 return Err(McpError::invalid_request(
79 format!(
80 "source too large: {} bytes > limit {}",
81 source.len(),
82 RUNNER_CONFIG.max_source_bytes
83 ),
84 None,
85 ));
86 }
87
88 let result = execute_in_container(&language, &source)
89 .await
90 .map_err(|e| McpError::internal_error(format!("code execution failed: {e}"), None))?;
91
92 let text = serde_json::to_string_pretty(&result)
93 .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?;
94 Ok(CallToolResult::success(vec![ContentBlock::Text(
95 TextContent::new(text),
96 )]))
97 }
98}
99
100async fn execute_in_container(language: &str, source: &str) -> Result<RunResult, String> {
105 let lang_key = normalize_lang(language);
106
107 let lang_def = LANGUAGES
108 .get(&lang_key)
109 .ok_or_else(|| format!("language not registered: {lang_key}"))?;
110
111 let ticket = tokio::time::timeout(
113 Duration::from_secs(RUNNER_CONFIG.queue_timeout_secs),
114 RUNNER_SEMAPHORE.acquire(),
115 )
116 .await
117 .map_err(|_| "container queue timeout: too many concurrent executions".to_string())?
118 .map_err(|e| format!("semaphore acquire: {e}"))?;
119
120 let final_limits = clamp_limits(lang_def.default_limits.clone(), lang_def.allow_network);
121
122 let start_time = chrono::Utc::now();
123 let res = run_in_container(
124 &lang_def.image,
125 &lang_def.run_cmd,
126 source,
127 &lang_def.extension,
128 final_limits,
129 lang_def.cache_volume.as_ref(),
130 )
131 .await;
132 let duration_ms = (chrono::Utc::now() - start_time).num_milliseconds().max(0) as u64;
133
134 drop(ticket); match res {
137 Ok((exit_code, stdout, stderr, oom_killed)) => {
138 let status = if oom_killed {
139 "oom"
140 } else if exit_code == Some(0) {
141 "success"
142 } else {
143 "error"
144 };
145 Ok(RunResult {
146 status,
147 exit_code,
148 stdout,
149 stderr,
150 duration_ms,
151 language: lang_key,
152 })
153 }
154 Err(e) => {
155 let s = e.to_string();
157 let is_timeout = s.contains("TimedOut");
158 tracing::error!(error = ?e, "MCP container execution failed");
159 let is_daemon_down = s.contains("Docker daemon") || s.contains("NotFound");
161 let status = if is_daemon_down {
162 "unavailable"
163 } else if is_timeout {
164 "timeout"
165 } else {
166 "error"
167 };
168 let stderr = if is_daemon_down {
169 "code runner unavailable: Docker daemon not running".to_string()
170 } else if is_timeout {
171 "execution timed out".to_string()
172 } else {
173 "code runner temporarily unavailable".to_string()
174 };
175 Ok(RunResult {
176 status,
177 exit_code: None,
178 stdout: String::new(),
179 stderr,
180 duration_ms,
181 language: lang_key,
182 })
183 }
184 }
185}