feat: MCP stdio 命令解析与错误诊断增强
- 新增 resolve_command_path:Windows 上搜索 .exe/.cmd/.bat 后缀 - 启动失败时给出友好错误信息(含 PATH 和建议) - 捕获子进程 stderr 并在连接失败时回显 - ConfigPage 新增 cwd 工作目录字段 - 添加单元测试覆盖路径解析逻辑
This commit is contained in:
parent
1309fa28da
commit
76abdbd1de
@ -7,7 +7,7 @@
|
|||||||
//! - Dynamically registers MCP tools via the Tool trait adapter
|
//! - Dynamically registers MCP tools via the Tool trait adapter
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
@ -18,10 +18,13 @@ use rmcp::{
|
|||||||
transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig},
|
transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig},
|
||||||
};
|
};
|
||||||
use http::{HeaderName, HeaderValue};
|
use http::{HeaderName, HeaderValue};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::mcp::config::{McpServerConfig, McpTransportConfig};
|
use crate::mcp::config::{McpServerConfig, McpTransportConfig};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
/// Resolve ${ENV_VAR} placeholders in a value string
|
/// Resolve ${ENV_VAR} placeholders in a value string
|
||||||
fn resolve_env_placeholders_in_value(value: &str) -> String {
|
fn resolve_env_placeholders_in_value(value: &str) -> String {
|
||||||
@ -33,6 +36,65 @@ fn resolve_env_placeholders_in_value(value: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a command name to an executable path.
|
||||||
|
///
|
||||||
|
/// On Windows, if the command has no file extension, searches PATH for
|
||||||
|
/// `.exe`, `.cmd`, `.bat` variants (Rust's `Command::new` only finds `.exe`).
|
||||||
|
/// On non-Windows platforms, returns `None` (defers to OS default behavior).
|
||||||
|
fn resolve_command_path(command: &str) -> Option<PathBuf> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let path = PathBuf::from(command);
|
||||||
|
|
||||||
|
// If already an absolute path with extension, use directly
|
||||||
|
if path.is_absolute() {
|
||||||
|
if path.is_file() {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
// Absolute path but doesn't exist — try appending extensions if no ext
|
||||||
|
if path.extension().is_none() {
|
||||||
|
for ext in &["exe", "cmd", "bat"] {
|
||||||
|
let candidate = path.with_extension(ext);
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it has a path separator (relative path), don't search PATH
|
||||||
|
if path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare command name — search PATH with extensions
|
||||||
|
let extensions: &[&str] = if path.extension().is_some() {
|
||||||
|
&[""] // already has extension, try as-is
|
||||||
|
} else {
|
||||||
|
&[".exe", ".cmd", ".bat"]
|
||||||
|
};
|
||||||
|
|
||||||
|
let path_env = std::env::var_os("PATH")?;
|
||||||
|
for dir in std::env::split_paths(&path_env) {
|
||||||
|
for ext in extensions {
|
||||||
|
let candidate = dir.join(format!("{}{}", command, ext));
|
||||||
|
if candidate.is_file() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
// On non-Windows, defer to OS default behavior
|
||||||
|
let _ = command;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Type alias for the MCP client service
|
/// Type alias for the MCP client service
|
||||||
pub type McpClient = RunningService<RoleClient, ()>;
|
pub type McpClient = RunningService<RoleClient, ()>;
|
||||||
|
|
||||||
@ -183,7 +245,7 @@ impl McpClientManager {
|
|||||||
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
|
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
let client = match transport {
|
let client = match transport {
|
||||||
McpTransportConfig::Stdio { command, args, env, cwd } => {
|
McpTransportConfig::Stdio { command, args, env, cwd } => {
|
||||||
self.connect_stdio(&command, &args, &env, &cwd).await?
|
self.connect_stdio(key, &command, &args, &env, &cwd).await?
|
||||||
}
|
}
|
||||||
McpTransportConfig::Http { url, headers } => {
|
McpTransportConfig::Http { url, headers } => {
|
||||||
self.connect_http(&url, &headers).await?
|
self.connect_http(&url, &headers).await?
|
||||||
@ -219,12 +281,54 @@ impl McpClientManager {
|
|||||||
/// Connect via stdio transport (spawn child process)
|
/// Connect via stdio transport (spawn child process)
|
||||||
async fn connect_stdio(
|
async fn connect_stdio(
|
||||||
&self,
|
&self,
|
||||||
|
server_key: &str,
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
env: &HashMap<String, String>,
|
env: &HashMap<String, String>,
|
||||||
_cwd: &Option<std::path::PathBuf>,
|
cwd: &Option<PathBuf>,
|
||||||
) -> anyhow::Result<McpClient> {
|
) -> anyhow::Result<McpClient> {
|
||||||
let mut cmd = Command::new(command);
|
// Resolve command path (Windows extension handling)
|
||||||
|
let resolved_command = resolve_command_path(command);
|
||||||
|
let effective_command: PathBuf = match &resolved_command {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => PathBuf::from(command),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-flight check on Windows: if command is bare name and not resolved, give friendly error
|
||||||
|
#[cfg(windows)]
|
||||||
|
if resolved_command.is_none() {
|
||||||
|
let path = std::path::Path::new(command);
|
||||||
|
let is_absolute = path.is_absolute();
|
||||||
|
let has_separator = path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false);
|
||||||
|
if !is_absolute && !has_separator && path.extension().is_none() {
|
||||||
|
// Bare name not found on Windows
|
||||||
|
let path_env = std::env::var("PATH").unwrap_or_default();
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Command '{}' not found in PATH (tried .exe, .cmd, .bat). \
|
||||||
|
Current PATH: {}. \
|
||||||
|
Suggestion: use the full absolute path to the executable, \
|
||||||
|
or ensure the tool is installed and its directory is in PATH.",
|
||||||
|
command, path_env
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let env_keys: Vec<&String> = env.keys().collect();
|
||||||
|
let cwd_display = cwd
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "<inherit>".to_string());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
server_key = %server_key,
|
||||||
|
command = %effective_command.display(),
|
||||||
|
args = ?args,
|
||||||
|
env_keys = ?env_keys, // only keys, NOT values (avoid leaking secrets)
|
||||||
|
cwd = %cwd_display,
|
||||||
|
"Spawning MCP stdio child process"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&effective_command);
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
|
|
||||||
// Set environment variables
|
// Set environment variables
|
||||||
@ -232,10 +336,70 @@ impl McpClientManager {
|
|||||||
cmd.env(key, value);
|
cmd.env(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
let transport = TokioChildProcess::new(cmd)?;
|
// Set working directory if specified
|
||||||
|
if let Some(cwd_path) = cwd {
|
||||||
|
cmd.current_dir(cwd_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared buffer to collect stderr lines for error reporting
|
||||||
|
let stderr_lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let stderr_lines_for_task = stderr_lines.clone();
|
||||||
|
|
||||||
|
// Use builder to capture stderr (default is inherit)
|
||||||
|
let (transport, stderr_opt) = TokioChildProcess::builder(cmd)
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Failed to spawn MCP stdio child process '{}': {}",
|
||||||
|
effective_command.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Spawn a background task to read stderr and log it
|
||||||
|
if let Some(child_stderr) = stderr_opt {
|
||||||
|
let server_key_owned = server_key.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let reader = BufReader::new(child_stderr);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
tracing::warn!(
|
||||||
|
server_key = %server_key_owned,
|
||||||
|
stderr = %line,
|
||||||
|
"MCP child process stderr"
|
||||||
|
);
|
||||||
|
// Also collect into the shared buffer (cap at 50 lines)
|
||||||
|
if let Ok(mut buf) = stderr_lines_for_task.lock() {
|
||||||
|
if buf.len() < 50 {
|
||||||
|
buf.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Use default client handler (empty tuple)
|
// Use default client handler (empty tuple)
|
||||||
let client = ().serve(transport).await?;
|
let client = ().serve(transport).await.map_err(|e| {
|
||||||
|
// Include stderr summary in error if available
|
||||||
|
let stderr_summary = stderr_lines
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.map(|buf| {
|
||||||
|
if buf.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("\nstderr:\n {}", buf.join("\n "))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Failed to establish MCP stdio connection '{}': {}{}",
|
||||||
|
effective_command.display(),
|
||||||
|
e,
|
||||||
|
stderr_summary
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Track that we have a stdio (child process) connection
|
// Track that we have a stdio (child process) connection
|
||||||
self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
@ -599,3 +763,56 @@ impl McpInitializer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_command_path_returns_none_for_nonexistent_bare_name() {
|
||||||
|
// A bare name that definitely doesn't exist
|
||||||
|
let result = resolve_command_path("this_binary_definitely_does_not_exist_xyz123");
|
||||||
|
// On all platforms, this should return None (either because it's not found,
|
||||||
|
// or because non-Windows always returns None)
|
||||||
|
#[cfg(windows)]
|
||||||
|
assert!(result.is_none(), "Expected None for nonexistent command on Windows");
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
assert!(result.is_none(), "Expected None on non-Windows (always returns None)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_command_path_absolute_path_existing() {
|
||||||
|
// Use an absolute path to a known existing executable
|
||||||
|
#[cfg(windows)]
|
||||||
|
let cmd = "C:\\Windows\\System32\\cmd.exe";
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let cmd = "/bin/ls";
|
||||||
|
|
||||||
|
let result = resolve_command_path(cmd);
|
||||||
|
#[cfg(windows)]
|
||||||
|
assert!(result.is_some(), "Expected to find {} on Windows", cmd);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
assert!(result.is_none(), "Expected None on non-Windows for absolute path");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_command_path_absolute_path_nonexistent() {
|
||||||
|
let result = resolve_command_path("/definitely/not/a/real/path/binary123");
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn test_resolve_command_path_finds_known_windows_binary() {
|
||||||
|
// cmd.exe should always be in C:\Windows\System32 which is in PATH
|
||||||
|
let result = resolve_command_path("cmd");
|
||||||
|
assert!(result.is_some(), "Expected to find cmd.exe via PATH on Windows");
|
||||||
|
if let Some(p) = result {
|
||||||
|
assert!(
|
||||||
|
p.to_string_lossy().to_lowercase().ends_with("cmd.exe"),
|
||||||
|
"Expected resolved path to end with cmd.exe, got: {}",
|
||||||
|
p.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ interface McpServerConfig {
|
|||||||
command?: string
|
command?: string
|
||||||
args?: string[]
|
args?: string[]
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
|
cwd?: string
|
||||||
base_url?: string
|
base_url?: string
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
description?: string
|
description?: string
|
||||||
@ -746,8 +747,9 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
<Field label="描述"><input value={s.description ?? ''} onChange={e => updMcp(name, { description: e.target.value || undefined })} className={inputCls} placeholder="可选描述" /></Field>
|
<Field label="描述"><input value={s.description ?? ''} onChange={e => updMcp(name, { description: e.target.value || undefined })} className={inputCls} placeholder="可选描述" /></Field>
|
||||||
{s.type === 'stdio' && (
|
{s.type === 'stdio' && (
|
||||||
<>
|
<>
|
||||||
<Field label="命令" hint="如 npx, node, cargo"><input value={s.command ?? ''} onChange={e => updMcp(name, { command: e.target.value })} className={inputCls} placeholder="npx" /></Field>
|
<Field label="命令" hint="如 npx, node, cargo, uv"><input value={s.command ?? ''} onChange={e => updMcp(name, { command: e.target.value })} className={inputCls} placeholder="npx" /></Field>
|
||||||
<Field label="参数" hint="空格分隔"><input value={(s.args ?? []).join(' ')} onChange={e => updMcp(name, { args: e.target.value ? e.target.value.split(/\s+/) : [] })} className={inputCls} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" /></Field>
|
<Field label="参数" hint="空格分隔"><input value={(s.args ?? []).join(' ')} onChange={e => updMcp(name, { args: e.target.value ? e.target.value.split(/\s+/) : [] })} className={inputCls} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" /></Field>
|
||||||
|
<Field label="工作目录 (cwd)" hint="可选。子进程运行目录,常用于 uv/python 项目解析 pyproject.toml 或 venv"><input value={s.cwd ?? ''} onChange={e => updMcp(name, { cwd: e.target.value || undefined })} className={inputCls} placeholder="E:\code_project\my-mcp-server" /></Field>
|
||||||
<Field label="环境变量" hint="KEY=VALUE,每行一个">
|
<Field label="环境变量" hint="KEY=VALUE,每行一个">
|
||||||
<textarea
|
<textarea
|
||||||
value={Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}
|
value={Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user