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 检测到 .cmd/.bat 命令: {} - 可能会弹 CMD 窗口!",
68                command
69            );
70            warn!("[MCP] 建议改用 node.exe 直接运行 JS 文件,或在配置中使用完整路径");
71        }
72        None => {
73            // 无扩展名,检查是否是 npx 命令
74            if command.contains("npx") {
75                warn!(
76                    "[MCP] Windows 检测到 npx 命令: {} - 可能会弹 CMD 窗口!",
77                    command
78                );
79                warn!("[MCP] 建议改用 node.exe 直接运行 JS 文件");
80            }
81        }
82        _ => {
83            info!("[MCP] Windows 检测到命令格式: {}", command);
84        }
85    }
86}
87
88/// Unix/macOS 平台的空实现
89#[cfg(not(windows))]
90pub fn check_windows_command(_command: &str) {
91    // 非 Windows 平台无需检测
92}
93
94/// 确保应用内置运行时路径(NUWAX_APP_RUNTIME_PATH)在 PATH 最前面。
95///
96/// 当应用捆绑了 node/uv 等运行时时,通过 `NUWAX_APP_RUNTIME_PATH` 传递其路径。
97/// 此函数将这些路径插入到给定 PATH 的最前面,确保优先使用应用内置版本,
98/// 即使用户在 MCP 配置的 `env` 中指定了自定义 PATH。
99///
100/// **按段去重**:将 runtime_path 和现有 PATH 拆分为独立条目,
101/// 先放 runtime 段,再追加 PATH 中不在 runtime 里的段,彻底避免重复。
102///
103/// 如果 `NUWAX_APP_RUNTIME_PATH` 未设置或为空,直接返回原始 PATH。
104pub fn ensure_runtime_path(path: &str) -> String {
105    if let Ok(runtime_path) = std::env::var("NUWAX_APP_RUNTIME_PATH") {
106        let runtime_path = runtime_path.trim();
107        if !runtime_path.is_empty() {
108            let sep = if cfg!(windows) { ";" } else { ":" };
109
110            // 将 runtime_path 拆成各段
111            let runtime_segments: Vec<&str> =
112                runtime_path.split(sep).filter(|s| !s.is_empty()).collect();
113
114            // 将现有 PATH 拆成各段,去掉已在 runtime 中的
115            let existing_segments: Vec<&str> = path
116                .split(sep)
117                .filter(|s| !s.is_empty() && !runtime_segments.contains(s))
118                .collect();
119
120            let merged: Vec<&str> = runtime_segments
121                .iter()
122                .copied()
123                .chain(existing_segments)
124                .collect();
125
126            let result = merged.join(sep);
127            if result != path {
128                tracing::info!(
129                    "[ProcessCompat] 前置应用内置运行时到 PATH: {}",
130                    runtime_path
131                );
132            }
133            return result;
134        }
135    }
136    path.to_string()
137}
138
139/// 为 stdio 子进程准备最终的 PATH 和过滤后的环境变量。
140///
141/// 统一处理:
142/// 1. 从 config env 或父进程确定基础 PATH
143/// 2. Windows 上追加 npm 全局 bin 目录
144/// 3. 通过 `ensure_runtime_path` 按段去重前置应用内置运行时
145/// 4. 从 config env 中过滤掉 PATH(已单独处理)
146///
147/// 返回 `(Option<final_path>, filtered_env)`,调用方只需 apply 到 `cmd` 即可。
148pub fn prepare_stdio_env(
149    env: &Option<std::collections::HashMap<String, String>>,
150) -> (Option<String>, Option<Vec<(String, String)>>) {
151    // 1. 确定基础 PATH
152    let base_path = if env.as_ref().map_or(true, |e| !e.contains_key("PATH")) {
153        std::env::var("PATH").ok()
154    } else {
155        env.as_ref().and_then(|e| e.get("PATH").cloned())
156    };
157
158    // 2. Windows: 追加 npm 全局 bin + 3. ensure_runtime_path
159    let final_path = base_path.map(|path| {
160        #[cfg(target_os = "windows")]
161        let path = {
162            if let Ok(appdata) = std::env::var("APPDATA") {
163                let npm_path = format!(r"{}\npm", appdata);
164                if !path.contains(&npm_path) {
165                    format!("{};{}", path, npm_path)
166                } else {
167                    path
168                }
169            } else {
170                tracing::warn!("Windows: APPDATA not found, skipping npm global bin");
171                path
172            }
173        };
174        ensure_runtime_path(&path)
175    });
176
177    // 4. 过滤掉 PATH(已单独处理)
178    let filtered_env = env.as_ref().map(|vars| {
179        vars.iter()
180            .filter(|(k, _)| k.as_str() != "PATH")
181            .map(|(k, v)| (k.clone(), v.clone()))
182            .collect()
183    });
184
185    (final_path, filtered_env)
186}
187
188/// 为 process-wrap 8.x 的 TokioCommandWrap 应用平台特定的包装
189///
190/// 此宏会根据目标平台自动应用正确的进程包装:
191/// - Windows: `CreationFlags(CREATE_NO_WINDOW)` + `JobObject`
192/// - Unix: `ProcessGroup::leader()`
193///
194/// # Arguments
195///
196/// * `$cmd` - 可变的 TokioCommandWrap 实例
197///
198/// # Example
199///
200/// ```ignore
201/// use process_wrap::tokio::{TokioCommandWrap, KillOnDrop};
202/// use mcp_common::process_compat::wrap_process_v8;
203///
204/// let mut wrapped_cmd = TokioCommandWrap::with_new("node", |cmd| {
205///     cmd.arg("server.js");
206/// });
207/// wrap_process_v8!(wrapped_cmd);
208/// wrapped_cmd.wrap(KillOnDrop);
209/// ```
210#[cfg(unix)]
211#[macro_export]
212macro_rules! wrap_process_v8 {
213    ($cmd:expr) => {
214        {
215            use process_wrap::tokio::ProcessGroup;
216            $cmd.wrap(ProcessGroup::leader());
217        }
218    };
219}
220
221#[cfg(windows)]
222#[macro_export]
223macro_rules! wrap_process_v8 {
224    ($cmd:expr) => {
225        {
226            use process_wrap::tokio::{CreationFlags, JobObject};
227            use windows::Win32::System::Threading::CREATE_NO_WINDOW;
228            $cmd.wrap(CreationFlags(CREATE_NO_WINDOW));
229            $cmd.wrap(JobObject);
230        }
231    };
232}
233
234/// 为 process-wrap 9.x 的 CommandWrap 应用平台特定的包装
235///
236/// 此宏会根据目标平台自动应用正确的进程包装:
237/// - Windows: `CreationFlags(CREATE_NO_WINDOW)` + `JobObject`
238/// - Unix: `ProcessGroup::leader()`
239///
240/// # Arguments
241///
242/// * `$cmd` - 可变的 CommandWrap 实例
243///
244/// # Example
245///
246/// ```ignore
247/// use process_wrap::tokio::{CommandWrap, KillOnDrop};
248/// use mcp_common::process_compat::wrap_process_v9;
249///
250/// let mut wrapped_cmd = CommandWrap::with_new("node", |cmd| {
251///     cmd.arg("server.js");
252/// });
253/// wrap_process_v9!(wrapped_cmd);
254/// wrapped_cmd.wrap(KillOnDrop);
255/// ```
256#[cfg(unix)]
257#[macro_export]
258macro_rules! wrap_process_v9 {
259    ($cmd:expr) => {
260        {
261            use process_wrap::tokio::ProcessGroup;
262            $cmd.wrap(ProcessGroup::leader());
263        }
264    };
265}
266
267#[cfg(windows)]
268#[macro_export]
269macro_rules! wrap_process_v9 {
270    ($cmd:expr) => {
271        {
272            use process_wrap::tokio::{CreationFlags, JobObject};
273            use windows::Win32::System::Threading::CREATE_NO_WINDOW;
274            $cmd.wrap(CreationFlags(CREATE_NO_WINDOW));
275            $cmd.wrap(JobObject);
276        }
277    };
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_check_windows_command_non_windows() {
286        // 在非 Windows 平台上,此函数应该不执行任何操作
287        check_windows_command("npx some-server");
288        check_windows_command("test.cmd");
289    }
290
291    #[test]
292    fn test_ensure_runtime_path_no_env() {
293        // NUWAX_APP_RUNTIME_PATH 未设置时,返回原始 PATH
294        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
295        let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
296        assert_eq!(result, "/usr/bin:/usr/local/bin");
297    }
298
299    #[test]
300    fn test_ensure_runtime_path_prepend() {
301        unsafe {
302            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
303        }
304        let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
305        assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin:/usr/local/bin");
306        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
307    }
308
309    #[test]
310    fn test_ensure_runtime_path_dedup() {
311        // 模拟:PATH 中已有 runtime 的部分段 → 不应重复
312        unsafe {
313            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
314        }
315        let result =
316            ensure_runtime_path("/app/node/bin:/opt/homebrew/bin:/usr/bin");
317        assert_eq!(
318            result,
319            "/app/node/bin:/app/uv/bin:/opt/homebrew/bin:/usr/bin"
320        );
321        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
322    }
323
324    #[test]
325    fn test_ensure_runtime_path_all_present() {
326        // PATH 已含全部 runtime 段 → 仅调整顺序确保 runtime 在前
327        unsafe {
328            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
329        }
330        let result =
331            ensure_runtime_path("/app/uv/bin:/usr/bin:/app/node/bin");
332        assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin");
333        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
334    }
335
336    #[test]
337    fn test_ensure_runtime_path_double_node() {
338        // 模拟日志中的问题:node/bin 出现两次
339        unsafe {
340            std::env::set_var(
341                "NUWAX_APP_RUNTIME_PATH",
342                "/app/node/bin:/app/uv/bin:/app/debug",
343            );
344        }
345        let result = ensure_runtime_path(
346            "/app/node/bin:/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin",
347        );
348        assert_eq!(
349            result,
350            "/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin"
351        );
352        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
353    }
354}