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