Skip to main content

mcp_common/
process_compat.rs

1//! 跨平台进程管理兼容层
2//!
3//! 提供统一的进程管理抽象,减少平台特定代码的侵入性。
4//!
5//! # 使用方法
6//!
7//! ## 命令检测
8//!
9//! ```ignore
10//! use mcp_common::process_compat::check_windows_command;
11//!
12//! check_windows_command(&config.command);
13//! ```
14//!
15//! ## 进程包装宏
16//!
17//! process-wrap 8.x (TokioCommandWrap):
18//! ```ignore
19//! use mcp_common::process_compat::wrap_process_v8;
20//!
21//! let mut wrapped_cmd = TokioCommandWrap::with_new(...);
22//! wrap_process_v8!(wrapped_cmd);
23//! wrapped_cmd.wrap(KillOnDrop);
24//! ```
25//!
26//! process-wrap 9.x (CommandWrap):
27//! ```ignore
28//! use mcp_common::process_compat::wrap_process_v9;
29//!
30//! let mut wrapped_cmd = CommandWrap::with_new(...);
31//! wrap_process_v9!(wrapped_cmd);
32//! wrapped_cmd.wrap(KillOnDrop);
33//! ```
34
35#[cfg(windows)]
36use tracing::{info, warn};
37
38/// 检测 Windows 平台上可能导致弹窗的命令格式
39///
40/// 在 Windows 上,运行 `.cmd`、`.bat` 文件或 `npx` 命令可能会弹出 CMD 窗口。
41/// 此函数会检测这些情况并输出警告,建议用户使用替代方案。
42///
43/// # Arguments
44///
45/// * `command` - 要执行的命令字符串
46///
47/// # Example
48///
49/// ```ignore
50/// use mcp_common::process_compat::check_windows_command;
51///
52/// check_windows_command("npx some-server");
53/// check_windows_command("mcp-server.cmd");
54/// ```
55#[cfg(windows)]
56pub fn check_windows_command(command: &str) {
57    use std::path::Path;
58
59    let cmd_ext = Path::new(command)
60        .extension()
61        .and_then(|e| e.to_str())
62        .map(|s| s.to_ascii_lowercase());
63
64    match cmd_ext.as_deref() {
65        Some("cmd" | "bat") => {
66            warn!(
67                "[MCP] Windows detected .cmd/.bat command: {} - CMD window may pop up!",
68                command
69            );
70            warn!(
71                "[MCP] It is recommended to use node.exe to run the JS file directly, or use the full path in the configuration"
72            );
73        }
74        None => {
75            // 无扩展名,检查是否是 npx 命令
76            if command.contains("npx") {
77                warn!(
78                    "[MCP] Windows detects npx command: {} - CMD window may pop up!",
79                    command
80                );
81                warn!("[MCP] It is recommended to use node.exe to run JS files directly");
82            }
83        }
84        _ => {
85            info!("[MCP] Windows detected command format: {}", command);
86        }
87    }
88}
89
90/// Unix/macOS 平台的空实现
91#[cfg(not(windows))]
92pub fn check_windows_command(_command: &str) {
93    // 非 Windows 平台无需检测
94}
95
96/// Windows 上解析命令路径,自动添加扩展名
97///
98/// 在 Windows 上,命令如 `npx` 实际上是 `npx.cmd` 批处理文件。
99/// `std::process::Command` 不会自动查找 `.cmd` 扩展名,需要手动指定。
100/// 此函数尝试在 PATH 中查找命令,并返回带扩展名的完整路径或原始命令。
101///
102/// # Arguments
103///
104/// * `command` - 要解析的命令字符串
105///
106/// # Returns
107///
108/// 如果找到,返回带扩展名的命令;否则返回原始命令
109///
110/// # Example
111///
112/// ```ignore
113/// use mcp_common::process_compat::resolve_windows_command;
114///
115/// let resolved = resolve_windows_command("npx");
116/// // 返回 "npx.cmd" 或 "C:\Program Files\nodejs\npx.cmd"
117/// ```
118#[cfg(target_os = "windows")]
119pub fn resolve_windows_command(command: &str) -> String {
120    use std::path::Path;
121
122    // 如果已经有扩展名,直接返回
123    if Path::new(command).extension().is_some() {
124        return command.to_string();
125    }
126
127    // 如果是绝对路径,直接返回
128    if Path::new(command).is_absolute() {
129        return command.to_string();
130    }
131
132    // 获取 PATH 环境变量
133    let path_env = match std::env::var("PATH") {
134        Ok(p) => p,
135        Err(_) => return command.to_string(),
136    };
137
138    // Windows 可执行文件扩展名(按优先级)
139    let extensions = [".cmd", ".exe", ".bat", ".ps1"];
140
141    // 遍历 PATH 中的每个目录
142    for dir in path_env.split(';') {
143        let dir = dir.trim();
144        if dir.is_empty() {
145            continue;
146        }
147
148        // 尝试每个扩展名
149        for ext in &extensions {
150            let full_path = Path::new(dir).join(format!("{}{}", command, ext));
151            if full_path.exists() {
152                tracing::debug!(
153                    "[MCP] Windows command analysis: {} -> {}",
154                    command,
155                    full_path.display()
156                );
157                // 返回带扩展名的命令(不是完整路径,保持简洁)
158                return format!("{}{}", command, ext);
159            }
160        }
161    }
162
163    // 未找到,返回原始命令
164    command.to_string()
165}
166
167/// 非 Windows 平台的空实现
168#[cfg(not(target_os = "windows"))]
169pub fn resolve_windows_command(command: &str) -> String {
170    command.to_string()
171}
172
173/// 确保应用内置运行时路径(NUWAX_APP_RUNTIME_PATH)在 PATH 最前面。
174///
175/// 当应用捆绑了 node/uv 等运行时时,通过 `NUWAX_APP_RUNTIME_PATH` 传递其路径。
176/// 此函数将这些路径插入到给定 PATH 的最前面,确保优先使用应用内置版本,
177/// 即使用户在 MCP 配置的 `env` 中指定了自定义 PATH。
178///
179/// **按段去重**:将 runtime_path 和现有 PATH 拆分为独立条目,
180/// 先放 runtime 段,再追加 PATH 中不在 runtime 里的段,彻底避免重复。
181///
182/// 如果 `NUWAX_APP_RUNTIME_PATH` 未设置或为空,直接返回原始 PATH。
183pub fn ensure_runtime_path(path: &str) -> String {
184    if let Ok(runtime_path) = std::env::var("NUWAX_APP_RUNTIME_PATH") {
185        let runtime_path = runtime_path.trim();
186        if !runtime_path.is_empty() {
187            let sep = if cfg!(windows) { ";" } else { ":" };
188
189            // 将 runtime_path 拆成各段
190            let runtime_segments: Vec<&str> =
191                runtime_path.split(sep).filter(|s| !s.is_empty()).collect();
192
193            // 将现有 PATH 拆成各段,去掉已在 runtime 中的
194            let existing_segments: Vec<&str> = path
195                .split(sep)
196                .filter(|s| !s.is_empty() && !runtime_segments.contains(s))
197                .collect();
198
199            let merged: Vec<&str> = runtime_segments
200                .iter()
201                .copied()
202                .chain(existing_segments)
203                .collect();
204
205            let result = merged.join(sep);
206            if result != path {
207                tracing::info!(
208                    "[ProcessCompat] Front-end application built-in runtime to PATH: {}",
209                    runtime_path
210                );
211            }
212            return result;
213        }
214    }
215    path.to_string()
216}
217
218/// 为 process-wrap 8.x 的 TokioCommandWrap 应用平台特定的包装
219///
220/// 此宏会根据目标平台自动应用正确的进程包装:
221/// - Windows: `CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)` + `JobObject`
222/// - Unix: `ProcessGroup::leader()`
223///
224/// # Arguments
225///
226/// * `$cmd` - 可变的 TokioCommandWrap 实例
227///
228/// # Example
229///
230/// ```ignore
231/// use process_wrap::tokio::{TokioCommandWrap, KillOnDrop};
232/// use mcp_common::process_compat::wrap_process_v8;
233///
234/// let mut wrapped_cmd = TokioCommandWrap::with_new("node", |cmd| {
235///     cmd.arg("server.js");
236/// });
237/// wrap_process_v8!(wrapped_cmd);
238/// wrapped_cmd.wrap(KillOnDrop);
239/// ```
240#[cfg(unix)]
241#[macro_export]
242macro_rules! wrap_process_v8 {
243    ($cmd:expr) => {{
244        use process_wrap::tokio::ProcessGroup;
245        $cmd.wrap(ProcessGroup::leader());
246    }};
247}
248
249#[cfg(windows)]
250#[macro_export]
251macro_rules! wrap_process_v8 {
252    ($cmd:expr) => {{
253        use process_wrap::tokio::{CreationFlags, JobObject};
254        use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
255        $cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
256        $cmd.wrap(JobObject);
257    }};
258}
259
260/// 为 process-wrap 9.x 的 CommandWrap 应用平台特定的包装
261///
262/// 此宏会根据目标平台自动应用正确的进程包装:
263/// - Windows: `CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)` + `JobObject`
264/// - Unix: `ProcessGroup::leader()`
265///
266/// # Arguments
267///
268/// * `$cmd` - 可变的 CommandWrap 实例
269///
270/// # Example
271///
272/// ```ignore
273/// use process_wrap::tokio::{CommandWrap, KillOnDrop};
274/// use mcp_common::process_compat::wrap_process_v9;
275///
276/// let mut wrapped_cmd = CommandWrap::with_new("node", |cmd| {
277///     cmd.arg("server.js");
278/// });
279/// wrap_process_v9!(wrapped_cmd);
280/// wrapped_cmd.wrap(KillOnDrop);
281/// ```
282#[cfg(unix)]
283#[macro_export]
284macro_rules! wrap_process_v9 {
285    ($cmd:expr) => {{
286        use process_wrap::tokio::ProcessGroup;
287        $cmd.wrap(ProcessGroup::leader());
288    }};
289}
290
291#[cfg(windows)]
292#[macro_export]
293macro_rules! wrap_process_v9 {
294    ($cmd:expr) => {{
295        use process_wrap::tokio::{CreationFlags, JobObject};
296        use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
297        $cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
298        $cmd.wrap(JobObject);
299    }};
300}
301
302/// 启动 stderr 日志读取任务
303///
304/// 创建一个异步任务来读取子进程的 stderr 输出并记录到日志。
305/// 这个函数封装了通用的 stderr 读取逻辑。
306///
307/// # Arguments
308///
309/// * `stderr` - stderr 管道(实现 AsyncRead + Unpin + Send)
310/// * `service_name` - MCP 服务名称(用于日志标识)
311///
312/// # Returns
313///
314/// 返回 `JoinHandle<()>`,任务会在 stderr 关闭时自动结束
315///
316/// # Example
317///
318/// ```ignore
319/// use mcp_common::process_compat::spawn_stderr_reader;
320///
321/// let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
322///     .stderr(Stdio::piped())
323///     .spawn()?;
324///
325/// if let Some(stderr) = child_stderr {
326///     spawn_stderr_reader(stderr, "my-mcp-service".to_string());
327/// }
328/// ```
329pub fn spawn_stderr_reader<T>(stderr: T, service_name: String) -> tokio::task::JoinHandle<()>
330where
331    T: tokio::io::AsyncRead + Unpin + Send + 'static,
332{
333    tokio::spawn(async move {
334        use tokio::io::{AsyncBufReadExt, BufReader};
335
336        let mut reader = BufReader::new(stderr);
337        let mut line = String::new();
338        loop {
339            line.clear();
340            match reader.read_line(&mut line).await {
341                Ok(0) => {
342                    // EOF - stderr 已关闭
343                    tracing::debug!("[Subprocess stderr][{}] End of read (EOF)", service_name);
344                    break;
345                }
346                Ok(_) => {
347                    let trimmed = line.trim();
348                    if !trimmed.is_empty() {
349                        tracing::warn!("[child process stderr][{}] {}", service_name, trimmed);
350                    }
351                }
352                Err(e) => {
353                    tracing::debug!("[Subprocess stderr][{}] Read error: {}", service_name, e);
354                    break;
355                }
356            }
357        }
358    })
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_check_windows_command_non_windows() {
367        // 在非 Windows 平台上,此函数应该不执行任何操作
368        check_windows_command("npx some-server");
369        check_windows_command("test.cmd");
370    }
371
372    #[test]
373    fn test_ensure_runtime_path_no_env() {
374        // NUWAX_APP_RUNTIME_PATH 未设置时,返回原始 PATH
375        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
376        let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
377        assert_eq!(result, "/usr/bin:/usr/local/bin");
378    }
379
380    #[test]
381    fn test_ensure_runtime_path_prepend() {
382        unsafe {
383            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
384        }
385        let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
386        assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin:/usr/local/bin");
387        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
388    }
389
390    #[test]
391    fn test_ensure_runtime_path_dedup() {
392        // 模拟:PATH 中已有 runtime 的部分段 → 不应重复
393        unsafe {
394            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
395        }
396        let result = ensure_runtime_path("/app/node/bin:/opt/homebrew/bin:/usr/bin");
397        assert_eq!(
398            result,
399            "/app/node/bin:/app/uv/bin:/opt/homebrew/bin:/usr/bin"
400        );
401        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
402    }
403
404    #[test]
405    fn test_ensure_runtime_path_all_present() {
406        // PATH 已含全部 runtime 段 → 仅调整顺序确保 runtime 在前
407        unsafe {
408            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
409        }
410        let result = ensure_runtime_path("/app/uv/bin:/usr/bin:/app/node/bin");
411        assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin");
412        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
413    }
414
415    #[test]
416    fn test_ensure_runtime_path_double_node() {
417        // 模拟日志中的问题:node/bin 出现两次
418        unsafe {
419            std::env::set_var(
420                "NUWAX_APP_RUNTIME_PATH",
421                "/app/node/bin:/app/uv/bin:/app/debug",
422            );
423        }
424        let result = ensure_runtime_path(
425            "/app/node/bin:/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin",
426        );
427        assert_eq!(
428            result,
429            "/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin"
430        );
431        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
432    }
433}