提交qiming-mcp-proxy
This commit is contained in:
65
qiming-mcp-proxy/mcp-proxy/src/client/cli.rs
Normal file
65
qiming-mcp-proxy/mcp-proxy/src/client/cli.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// MCP-Proxy CLI 实现
|
||||
//
|
||||
// CLI 主入口,负责命令分发到各个实现模块
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
// 导出 CLI 特定类型
|
||||
// 注意: ConvertArgs 等通用参数类型已由 client/mod.rs 统一导出
|
||||
pub use crate::client::support::args::{Cli, Commands};
|
||||
|
||||
/// 运行 CLI 主逻辑
|
||||
///
|
||||
/// 根据命令类型分发到对应的实现模块:
|
||||
/// - Convert -> cli_impl/convert_cmd.rs
|
||||
/// - Check/Detect -> cli_impl/check.rs
|
||||
/// - Health -> cli_impl/health.rs
|
||||
/// - Proxy -> proxy_server.rs
|
||||
pub async fn run_cli(cli: Cli) -> Result<()> {
|
||||
match cli.command {
|
||||
Some(Commands::Convert(args)) => {
|
||||
crate::client::cli_impl::run_convert_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Check(args)) => {
|
||||
crate::client::cli_impl::run_check_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Detect(args)) => {
|
||||
crate::client::cli_impl::run_detect_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Health(args)) => {
|
||||
crate::client::cli_impl::run_health_command(args, cli.quiet).await
|
||||
}
|
||||
Some(Commands::Proxy(args)) => {
|
||||
super::proxy_server::run_proxy_command(args, cli.verbose, cli.quiet).await
|
||||
}
|
||||
None => {
|
||||
// 直接 URL 模式(向后兼容)
|
||||
if let Some(url) = cli.url {
|
||||
let args = crate::client::support::args::ConvertArgs {
|
||||
url: Some(url),
|
||||
config: None,
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0, // 无限重试
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30, // 默认 30 秒 ping 一次
|
||||
ping_timeout: 10, // 默认 10 秒超时
|
||||
logging: crate::client::support::LoggingArgs {
|
||||
diagnostic: true, // 默认启用诊断模式
|
||||
log_dir: None, // 默认无日志目录(将在 init_logging 中自动设置)
|
||||
log_file: None, // 默认无日志文件
|
||||
otlp_endpoint: None, // 默认不启用 OTLP 追踪
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
crate::client::cli_impl::run_convert_command(args, cli.verbose, cli.quiet).await
|
||||
} else {
|
||||
bail!("请提供 URL 或使用子命令")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/check.rs
Normal file
42
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/check.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! 检查和检测命令
|
||||
//!
|
||||
//! 实现服务状态检查和协议检测功能
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::support::{CheckArgs, DetectArgs};
|
||||
|
||||
/// 运行检查命令
|
||||
pub async fn run_check_command(args: CheckArgs, _verbose: bool, quiet: bool) -> Result<()> {
|
||||
if !quiet {
|
||||
eprintln!("Checking service health: {}", &args.url);
|
||||
}
|
||||
|
||||
match crate::client::protocol::detect_mcp_protocol(&args.url).await {
|
||||
Ok(protocol) => {
|
||||
if !quiet {
|
||||
eprintln!("Service is healthy (protocol: {protocol})");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
if !quiet {
|
||||
eprintln!("Service check failed: {e}");
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行协议检测命令
|
||||
pub async fn run_detect_command(args: DetectArgs, _verbose: bool, quiet: bool) -> Result<()> {
|
||||
let protocol = crate::client::protocol::detect_mcp_protocol(&args.url).await?;
|
||||
|
||||
if quiet {
|
||||
println!("{}", protocol);
|
||||
} else {
|
||||
eprintln!("{}", protocol);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
130
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/convert_cmd.rs
Normal file
130
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/convert_cmd.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! Convert 命令的 CLI 实现
|
||||
//!
|
||||
//! 处理 convert 命令的参数解析和路由到核心逻辑
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::core::{run_command_mode, run_url_mode_with_retry};
|
||||
use crate::client::support::{ConvertArgs, init_logging, parse_convert_config};
|
||||
use crate::proxy::ToolFilter;
|
||||
|
||||
/// 运行转换命令 - 核心功能
|
||||
pub async fn run_convert_command(args: ConvertArgs, verbose: bool, quiet: bool) -> Result<()> {
|
||||
// 检查 --allow-tools 和 --deny-tools 互斥
|
||||
if args.allow_tools.is_some() && args.deny_tools.is_some() {
|
||||
anyhow::bail!(
|
||||
"--allow-tools and --deny-tools cannot be used together, please choose only one"
|
||||
);
|
||||
}
|
||||
|
||||
// 创建工具过滤器
|
||||
let tool_filter = if let Some(allow_tools) = args.allow_tools.clone() {
|
||||
tracing::info!("Tool allowlist enabled: {:?}", allow_tools);
|
||||
ToolFilter::allow(allow_tools)
|
||||
} else if let Some(deny_tools) = args.deny_tools.clone() {
|
||||
tracing::info!("Tool denylist enabled: {:?}", deny_tools);
|
||||
ToolFilter::deny(deny_tools)
|
||||
} else {
|
||||
tracing::debug!("Tool filter disabled");
|
||||
ToolFilter::default()
|
||||
};
|
||||
|
||||
// 解析配置
|
||||
tracing::debug!("Parsing convert configuration...");
|
||||
let config_source = parse_convert_config(&args)?;
|
||||
tracing::info!("Configuration parsed");
|
||||
|
||||
// 提取 MCP 名称用于日志文件命名
|
||||
let mcp_name = match &config_source {
|
||||
crate::client::support::McpConfigSource::RemoteService { name, .. } => {
|
||||
tracing::info!("Service name: {}", name);
|
||||
Some(name.as_str())
|
||||
}
|
||||
crate::client::support::McpConfigSource::LocalCommand { name, .. } => {
|
||||
tracing::info!("Service name: {}", name);
|
||||
Some(name.as_str())
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Service name not specified");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化日志系统
|
||||
init_logging(&args, mcp_name, quiet, verbose)?;
|
||||
tracing::debug!("Logging initialized");
|
||||
|
||||
// 记录命令启动(必须在日志系统初始化之后)
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting convert command");
|
||||
tracing::info!("Command: convert");
|
||||
tracing::info!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
tracing::info!("Diagnostic mode: {}", args.logging.diagnostic);
|
||||
tracing::info!("========================================");
|
||||
|
||||
// 根据配置源执行不同逻辑
|
||||
match config_source {
|
||||
crate::client::support::McpConfigSource::DirectUrl { url } => {
|
||||
tracing::info!("Mode: direct URL");
|
||||
tracing::info!("Target URL: {}", url);
|
||||
// 直接 URL 模式(带自动重连)
|
||||
run_url_mode_with_retry(
|
||||
&args,
|
||||
&url,
|
||||
std::collections::HashMap::new(),
|
||||
None,
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
)
|
||||
.await
|
||||
}
|
||||
crate::client::support::McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
protocol,
|
||||
headers,
|
||||
timeout,
|
||||
} => {
|
||||
// 远程服务配置模式
|
||||
tracing::info!("Mode: remote service config");
|
||||
tracing::info!("Service name: {}", name);
|
||||
tracing::info!("Service URL: {}", url);
|
||||
if let Some(proto) = &protocol {
|
||||
tracing::info!("Configured protocol: {:?}", proto);
|
||||
}
|
||||
if !headers.is_empty() {
|
||||
tracing::debug!("Custom headers: {:?}", headers);
|
||||
}
|
||||
if let Some(timeout) = timeout {
|
||||
tracing::debug!("Configured timeout: {}s", timeout);
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🚀 MCP-Stdio-Proxy: {} ({}) → stdio", name, url);
|
||||
}
|
||||
// 合并 headers:配置 + 命令行
|
||||
let merged_headers =
|
||||
crate::client::support::merge_headers(headers, &args.header, args.auth.as_ref());
|
||||
run_url_mode_with_retry(
|
||||
&args,
|
||||
&url,
|
||||
merged_headers,
|
||||
protocol.or(timeout.map(|_| crate::client::protocol::McpProtocol::Stream)),
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
)
|
||||
.await
|
||||
}
|
||||
crate::client::support::McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command,
|
||||
args: cmd_args,
|
||||
env,
|
||||
} => {
|
||||
// 本地命令模式(子进程继承父进程环境变量,MCP JSON 的 env 会覆盖同名变量)
|
||||
run_command_mode(&name, &command, cmd_args, env, tool_filter, quiet).await
|
||||
}
|
||||
}
|
||||
}
|
||||
207
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/health.rs
Normal file
207
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/health.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! 健康检查命令
|
||||
//!
|
||||
//! 通过建立 MCP 连接验证服务是否健康
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use mcp_common::McpClientConfig;
|
||||
use mcp_sse_proxy::SseClientConnection;
|
||||
use mcp_streamable_proxy::StreamClientConnection;
|
||||
|
||||
use crate::client::protocol::detect_mcp_protocol;
|
||||
use crate::client::proxy_server::ProxyProtocol;
|
||||
use crate::client::support::HealthArgs;
|
||||
use crate::model::McpProtocol;
|
||||
|
||||
/// 健康检查结果
|
||||
struct HealthCheckResult {
|
||||
/// 工具数量
|
||||
tool_count: usize,
|
||||
/// 服务器名称
|
||||
server_name: Option<String>,
|
||||
/// 服务器版本
|
||||
server_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 运行健康检查命令
|
||||
///
|
||||
/// 通过真正建立 MCP 连接来验证服务是否健康。
|
||||
/// 成功返回 Ok(()),失败返回 Err。
|
||||
pub async fn run_health_command(args: HealthArgs, quiet: bool) -> Result<()> {
|
||||
if !quiet {
|
||||
eprintln!("Checking health for: {}", &args.url);
|
||||
}
|
||||
|
||||
// 1. 确定协议类型
|
||||
let protocol = match &args.protocol {
|
||||
Some(p) => {
|
||||
let proto = proxy_protocol_to_mcp_protocol(p.clone());
|
||||
if !quiet {
|
||||
eprintln!("Using protocol: {}", protocol_display_name(&proto));
|
||||
}
|
||||
proto
|
||||
}
|
||||
None => {
|
||||
if !quiet {
|
||||
eprintln!("Detecting protocol...");
|
||||
}
|
||||
let proto = detect_mcp_protocol(&args.url).await?;
|
||||
if !quiet {
|
||||
eprintln!("Detected protocol: {}", protocol_display_name(&proto));
|
||||
}
|
||||
proto
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 检查协议类型是否支持
|
||||
if protocol == McpProtocol::Stdio {
|
||||
bail!("Stdio protocol is not supported for health checks");
|
||||
}
|
||||
|
||||
// 3. 构建配置
|
||||
let config = build_config(&args);
|
||||
|
||||
// 4. 尝试连接(带超时)
|
||||
let start = Instant::now();
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(args.timeout),
|
||||
connect_and_verify(&config, protocol.clone()),
|
||||
)
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// 5. 输出结果
|
||||
match result {
|
||||
Ok(Ok(health_result)) => {
|
||||
if !quiet {
|
||||
eprintln!("Health check passed");
|
||||
eprintln!(" Protocol: {}", protocol_display_name(&protocol));
|
||||
eprintln!(" Tools: {}", health_result.tool_count);
|
||||
eprintln!(" Response time: {}ms", elapsed.as_millis());
|
||||
if let (Some(name), Some(version)) =
|
||||
(&health_result.server_name, &health_result.server_version)
|
||||
{
|
||||
eprintln!(" Server: {} v{}", name, version);
|
||||
} else if let Some(name) = &health_result.server_name {
|
||||
eprintln!(" Server: {}", name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
if !quiet {
|
||||
eprintln!("Health check failed");
|
||||
eprintln!(" Error: {}", e);
|
||||
eprintln!(" Response time: {}ms", elapsed.as_millis());
|
||||
}
|
||||
Err(anyhow::anyhow!("health check failed: {}", e))
|
||||
}
|
||||
Err(_) => {
|
||||
if !quiet {
|
||||
eprintln!("Health check failed");
|
||||
eprintln!(" Error: connection timed out ({}s)", args.timeout);
|
||||
}
|
||||
Err(anyhow::anyhow!("health check timeout"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取协议的显示名称
|
||||
fn protocol_display_name(protocol: &McpProtocol) -> &'static str {
|
||||
match protocol {
|
||||
McpProtocol::Sse => "SSE",
|
||||
McpProtocol::Stream => "Streamable HTTP",
|
||||
McpProtocol::Stdio => "Stdio",
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ProxyProtocol 转换为 McpProtocol
|
||||
fn proxy_protocol_to_mcp_protocol(p: ProxyProtocol) -> McpProtocol {
|
||||
match p {
|
||||
ProxyProtocol::Sse => McpProtocol::Sse,
|
||||
ProxyProtocol::Stream => McpProtocol::Stream,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 MCP 客户端配置
|
||||
fn build_config(args: &HealthArgs) -> McpClientConfig {
|
||||
let mut config = McpClientConfig::new(&args.url);
|
||||
|
||||
// 添加认证 header
|
||||
if let Some(auth) = &args.auth {
|
||||
config = config.with_header("Authorization", auth);
|
||||
}
|
||||
|
||||
// 添加自定义 headers
|
||||
for (key, value) in &args.header {
|
||||
config = config.with_header(key, value);
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
config = config.with_connect_timeout(Duration::from_secs(args.timeout));
|
||||
config = config.with_read_timeout(Duration::from_secs(args.timeout));
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// 尝试连接并验证服务
|
||||
async fn connect_and_verify(
|
||||
config: &McpClientConfig,
|
||||
protocol: McpProtocol,
|
||||
) -> Result<HealthCheckResult> {
|
||||
match protocol {
|
||||
McpProtocol::Sse => {
|
||||
let conn = SseClientConnection::connect(config.clone()).await?;
|
||||
|
||||
// 获取工具列表
|
||||
let tools = conn.list_tools().await?;
|
||||
let tool_count = tools.len();
|
||||
|
||||
// 获取服务器信息
|
||||
let (server_name, server_version) = conn
|
||||
.peer_info()
|
||||
.map(|info| {
|
||||
(
|
||||
Some(info.server_info.name.clone()),
|
||||
Some(info.server_info.version.clone()),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
Ok(HealthCheckResult {
|
||||
tool_count,
|
||||
server_name,
|
||||
server_version,
|
||||
})
|
||||
}
|
||||
McpProtocol::Stream => {
|
||||
let conn = StreamClientConnection::connect(config.clone()).await?;
|
||||
|
||||
// 获取工具列表
|
||||
let tools = conn.list_tools().await?;
|
||||
let tool_count = tools.len();
|
||||
|
||||
// 获取服务器信息
|
||||
let (server_name, server_version) = conn
|
||||
.peer_info()
|
||||
.map(|info| {
|
||||
(
|
||||
Some(info.server_info.name.clone()),
|
||||
Some(info.server_info.version.clone()),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
Ok(HealthCheckResult {
|
||||
tool_count,
|
||||
server_name,
|
||||
server_version,
|
||||
})
|
||||
}
|
||||
McpProtocol::Stdio => {
|
||||
// 不应该到达这里,因为前面已经检查过了
|
||||
bail!("stdio protocol is not supported for health check")
|
||||
}
|
||||
}
|
||||
}
|
||||
12
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/mod.rs
Normal file
12
qiming-mcp-proxy/mcp-proxy/src/client/cli_impl/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! CLI 实现模块
|
||||
//!
|
||||
//! 处理 CLI 命令的实现和参数解析
|
||||
|
||||
pub mod check;
|
||||
pub mod convert_cmd;
|
||||
pub mod health;
|
||||
|
||||
// 导出 CLI 命令实现
|
||||
pub use check::{run_check_command, run_detect_command};
|
||||
pub use convert_cmd::run_convert_command;
|
||||
pub use health::run_health_command;
|
||||
165
qiming-mcp-proxy/mcp-proxy/src/client/core/command.rs
Normal file
165
qiming-mcp-proxy/mcp-proxy/src/client/core/command.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! 本地命令模式处理
|
||||
//!
|
||||
//! 处理本地命令形式的 MCP 服务(通过子进程)
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
|
||||
use crate::proxy::{StreamProxyHandler, ToolFilter};
|
||||
|
||||
// 使用 mcp-streamable-proxy 的类型(rmcp 0.12,process-wrap 9.0)
|
||||
use mcp_streamable_proxy::{
|
||||
ClientCapabilities, ClientInfo, Implementation, ServiceExt, TokioChildProcess, stdio,
|
||||
};
|
||||
|
||||
use crate::client::support::utils::truncate_str;
|
||||
|
||||
// 进程组管理(跨平台子进程清理)
|
||||
use process_wrap::tokio::{CommandWrap, KillOnDrop};
|
||||
|
||||
#[cfg(unix)]
|
||||
use process_wrap::tokio::ProcessGroup;
|
||||
|
||||
#[cfg(windows)]
|
||||
use process_wrap::tokio::JobObject;
|
||||
|
||||
/// 命令模式执行(本地子进程)
|
||||
/// 使用 mcp-streamable-proxy(rmcp 0.12)实现 stdio CLI 模式
|
||||
pub async fn run_command_mode(
|
||||
name: &str,
|
||||
command: &str,
|
||||
cmd_args: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
tool_filter: ToolFilter,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Running in local command mode");
|
||||
tracing::info!("Command: {} {:?}", command, cmd_args);
|
||||
if !env.is_empty() {
|
||||
tracing::debug!("Env var count: {}", env.len());
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🚀 MCP-Stdio-Proxy: {} (command) → stdio", name);
|
||||
eprintln!(" Command: {} {:?}", command, cmd_args);
|
||||
if !env.is_empty() {
|
||||
eprintln!(" Env vars: {:?}", env);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示过滤器配置
|
||||
if !quiet && tool_filter.is_enabled() {
|
||||
eprintln!("🔧 Tool filtering enabled");
|
||||
}
|
||||
|
||||
// 诊断日志:记录子进程启动信息
|
||||
tracing::debug!("[child-process] {} {:?}", command, cmd_args);
|
||||
|
||||
// 使用 process-wrap 创建子进程命令(跨平台进程清理)
|
||||
// process-wrap 会自动处理进程组(Unix)或 Job Object(Windows)
|
||||
// 并且在 Drop 时自动清理子进程树
|
||||
// 子进程默认继承父进程的所有环境变量
|
||||
let mut wrapped_cmd = CommandWrap::with_new(command, |cmd| {
|
||||
cmd.args(&cmd_args);
|
||||
|
||||
// 设置 MCP JSON 配置中的环境变量(会覆盖继承的同名变量)
|
||||
for (k, v) in &env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
});
|
||||
// Unix: 创建进程组,支持 killpg 清理整个进程树
|
||||
#[cfg(unix)]
|
||||
wrapped_cmd.wrap(ProcessGroup::leader());
|
||||
// Windows: 使用 Job Object 管理进程树,并隐藏控制台窗口
|
||||
// 使用 CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP 确保孙进程也不弹出窗口
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use process_wrap::tokio::CreationFlags;
|
||||
use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
|
||||
wrapped_cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
|
||||
wrapped_cmd.wrap(JobObject);
|
||||
}
|
||||
// 所有平台: Drop 时自动清理进程
|
||||
wrapped_cmd.wrap(KillOnDrop);
|
||||
|
||||
// 启动子进程
|
||||
// 使用 builder 模式捕获 stderr,便于诊断子 MCP 服务初始化失败
|
||||
tracing::debug!("Starting child process...");
|
||||
let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// 启动 stderr 日志读取任务
|
||||
if let Some(stderr_pipe) = child_stderr {
|
||||
mcp_common::spawn_stderr_reader(stderr_pipe, name.to_string());
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Starting child process...");
|
||||
}
|
||||
|
||||
// 创建 ClientInfo(使用 rmcp 0.12 类型)
|
||||
let client_info = create_client_info();
|
||||
|
||||
// 连接到子进程
|
||||
let running = client_info.serve(tokio_process).await?;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("✅ Child process started, proxying to stdio...");
|
||||
|
||||
// 打印工具列表
|
||||
match running.list_tools(None).await {
|
||||
Ok(tools_result) => {
|
||||
let tools = &tools_result.tools;
|
||||
if tools.is_empty() {
|
||||
eprintln!("⚠️ Tool list is empty (tools/list returned 0 tools)");
|
||||
} else {
|
||||
eprintln!("🔧 Available tools ({}):", tools.len());
|
||||
for tool in tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
let desc_short = truncate_str(desc, 50);
|
||||
eprintln!(" - {} : {}", tool.name, desc_short);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to list tools: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("💡 You can now send JSON-RPC requests through stdin");
|
||||
}
|
||||
|
||||
// 使用 StreamProxyHandler + stdio 将本地 MCP 服务透明暴露为 stdio
|
||||
let proxy_handler =
|
||||
StreamProxyHandler::with_tool_filter(running, name.to_string(), tool_filter);
|
||||
let server = proxy_handler.serve(stdio()).await?;
|
||||
|
||||
// 设置 Ctrl+C 信号处理
|
||||
tokio::select! {
|
||||
result = server.waiting() => {
|
||||
result?;
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("Ctrl+C received, shutting down");
|
||||
// tokio runtime 会清理资源,包括子进程
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建 ClientInfo(使用 rmcp 1.1.0 类型)
|
||||
fn create_client_info() -> ClientInfo {
|
||||
let capabilities = ClientCapabilities::builder()
|
||||
.enable_experimental()
|
||||
.enable_roots()
|
||||
.enable_roots_list_changed()
|
||||
.enable_sampling()
|
||||
.build();
|
||||
ClientInfo::new(
|
||||
capabilities,
|
||||
Implementation::new("mcp-proxy-cli", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
}
|
||||
168
qiming-mcp-proxy/mcp-proxy/src/client/core/common.rs
Normal file
168
qiming-mcp-proxy/mcp-proxy/src/client/core/common.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! 公共模块 - 提取 SSE 和 Stream 模式的共享逻辑
|
||||
//!
|
||||
//! 减少代码重复,统一健康检查行为
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 健康检查能力 trait
|
||||
///
|
||||
/// 抽象 SSE 和 Stream handler 的共同行为
|
||||
pub trait HealthChecker: Send + Sync {
|
||||
/// 检查后端是否可用(同步检查)
|
||||
fn is_backend_available(&self) -> bool;
|
||||
|
||||
/// 检查是否已终止(异步,会调用 list_tools 验证后端服务)
|
||||
fn is_terminated_async(&self) -> impl Future<Output = bool> + Send;
|
||||
}
|
||||
|
||||
/// 监控连接健康状态
|
||||
///
|
||||
/// 提取自 monitor_sse_connection 和 monitor_stream_connection 的公共逻辑
|
||||
///
|
||||
/// # 参数
|
||||
/// - `handler`: 实现 HealthChecker trait 的 handler
|
||||
/// - `ping_interval`: ping 间隔(秒),0 表示禁用 ping
|
||||
/// - `ping_timeout`: ping 超时(秒)
|
||||
/// - `quiet`: 是否静默模式
|
||||
/// - `protocol_name`: 协议名称,用于日志输出(如 "SSE" 或 "Stream")
|
||||
///
|
||||
/// # 返回值
|
||||
/// 返回断开连接的原因描述
|
||||
pub async fn monitor_connection_health<H: HealthChecker>(
|
||||
handler: &H,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
quiet: bool,
|
||||
protocol_name: &str,
|
||||
) -> String {
|
||||
let connection_start = std::time::Instant::now();
|
||||
if !quiet {
|
||||
tracing::info!("[{}] Connection health monitoring started", protocol_name);
|
||||
}
|
||||
|
||||
// 健康检查日志间隔:与 ping 间隔一致,但至少 30 秒
|
||||
let health_log_interval_secs = if ping_interval > 0 {
|
||||
ping_interval.max(30)
|
||||
} else {
|
||||
30
|
||||
};
|
||||
|
||||
if ping_interval == 0 {
|
||||
// 没有 ping 检测,仅检查连接状态
|
||||
let mut health_log_interval =
|
||||
tokio::time::interval(Duration::from_secs(health_log_interval_secs));
|
||||
health_log_interval.tick().await; // 跳过第一次
|
||||
let mut check_count = 0u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {
|
||||
if !handler.is_backend_available() {
|
||||
let alive_duration = connection_start.elapsed();
|
||||
let disconnect_reason =
|
||||
format!(
|
||||
"[{}] Backend connection closed (alive: {}s)",
|
||||
protocol_name,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
}
|
||||
_ = health_log_interval.tick() => {
|
||||
check_count += 1;
|
||||
let alive_duration = connection_start.elapsed();
|
||||
let backend_available = handler.is_backend_available();
|
||||
tracing::info!(
|
||||
"[{}] Health check #{} status={}, alive={}s",
|
||||
protocol_name,
|
||||
check_count,
|
||||
if backend_available { "ok" } else { "error" },
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(ping_interval));
|
||||
interval.tick().await;
|
||||
let mut ping_count = 0u64;
|
||||
let mut last_health_log = std::time::Instant::now();
|
||||
let mut first_ping = true; // 标记是否是第一次 ping
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
ping_count += 1;
|
||||
|
||||
let alive_duration = connection_start.elapsed();
|
||||
|
||||
if !handler.is_backend_available() {
|
||||
let disconnect_reason = format!(
|
||||
"[{}] Backend connection closed (alive: {}s)",
|
||||
protocol_name,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
|
||||
let check_result = tokio::time::timeout(
|
||||
Duration::from_secs(ping_timeout),
|
||||
handler.is_terminated_async(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match check_result {
|
||||
Ok(true) => {
|
||||
let disconnect_reason = format!(
|
||||
"[{}] Ping check failed (service error), alive: {}s",
|
||||
protocol_name,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
Ok(false) => {
|
||||
// 第一次 ping 成功后打印,之后每隔 health_log_interval_secs 秒打印一次
|
||||
let since_last_log = last_health_log.elapsed().as_secs();
|
||||
if first_ping || since_last_log >= health_log_interval_secs {
|
||||
tracing::info!(
|
||||
"[{}] Ping check #{} passed, alive={}s",
|
||||
protocol_name,
|
||||
ping_count,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
last_health_log = std::time::Instant::now();
|
||||
first_ping = false;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[{}] list_tools ping #{} passed, alive={}s",
|
||||
protocol_name,
|
||||
ping_count,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let disconnect_reason = format!(
|
||||
"[{}] Ping check timeout ({}s), alive: {}s",
|
||||
protocol_name,
|
||||
ping_timeout,
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
if !quiet {
|
||||
tracing::error!("{}", disconnect_reason);
|
||||
}
|
||||
return disconnect_reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
157
qiming-mcp-proxy/mcp-proxy/src/client/core/convert.rs
Normal file
157
qiming-mcp-proxy/mcp-proxy/src/client/core/convert.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! 协议转换核心逻辑
|
||||
//!
|
||||
//! 处理协议转换的主要流程,包括 URL 模式、协议检测等
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::client::support::{ConvertArgs, protocol_name};
|
||||
use crate::proxy::{McpClientConfig, ToolFilter};
|
||||
|
||||
use super::sse::run_sse_mode;
|
||||
use super::stream::run_stream_mode;
|
||||
|
||||
/// URL 模式执行(带自动重连)
|
||||
/// 使用分支逻辑:根据协议类型调用不同的处理函数
|
||||
pub async fn run_url_mode_with_retry(
|
||||
args: &ConvertArgs,
|
||||
url: &str,
|
||||
merged_headers: HashMap<String, String>,
|
||||
config_protocol: Option<crate::client::protocol::McpProtocol>,
|
||||
tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Starting protocol conversion");
|
||||
tracing::info!("Target URL: {url}");
|
||||
tracing::debug!("Header count: {}", merged_headers.len());
|
||||
tracing::debug!(
|
||||
"Ping interval: {}s, ping timeout: {}s",
|
||||
args.ping_interval,
|
||||
args.ping_timeout
|
||||
);
|
||||
tracing::debug!("Retry count: {} (0 = unlimited)", args.retries);
|
||||
|
||||
if !quiet && merged_headers.is_empty() {
|
||||
eprintln!("🚀 MCP-Stdio-Proxy: {} → stdio", url);
|
||||
}
|
||||
|
||||
// 显示过滤器配置
|
||||
if !quiet {
|
||||
if let Some(ref allow_tools) = args.allow_tools {
|
||||
tracing::info!("Tool allowlist: {:?}", allow_tools);
|
||||
}
|
||||
if let Some(ref deny_tools) = args.deny_tools {
|
||||
tracing::info!("Tool denylist: {:?}", deny_tools);
|
||||
}
|
||||
}
|
||||
|
||||
// 确定协议类型:命令行参数 > 配置文件 > 自动检测
|
||||
let protocol = if let Some(ref proto) = args.protocol {
|
||||
let detected = match proto {
|
||||
crate::client::proxy_server::ProxyProtocol::Sse => {
|
||||
crate::client::protocol::McpProtocol::Sse
|
||||
}
|
||||
crate::client::proxy_server::ProxyProtocol::Stream => {
|
||||
crate::client::protocol::McpProtocol::Stream
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
"Using protocol from CLI argument: {}",
|
||||
protocol_name(&detected)
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🔧 Using protocol from CLI: {}", protocol_name(&detected));
|
||||
}
|
||||
detected
|
||||
} else if let Some(proto) = config_protocol {
|
||||
tracing::info!("Using protocol from config: {}", protocol_name(&proto));
|
||||
if !quiet {
|
||||
eprintln!("🔧 Using protocol from config: {}", protocol_name(&proto));
|
||||
}
|
||||
proto
|
||||
} else {
|
||||
tracing::info!("Detecting protocol...");
|
||||
if !quiet {
|
||||
eprintln!("🔍 Detecting protocol...");
|
||||
}
|
||||
let detection_start = std::time::Instant::now();
|
||||
let detected = crate::client::protocol::detect_mcp_protocol(url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Protocol detection failed: {}", e);
|
||||
e
|
||||
})?;
|
||||
let detection_duration = detection_start.elapsed();
|
||||
tracing::info!(
|
||||
"Protocol detection completed: protocol={}, duration={:?}",
|
||||
protocol_name(&detected),
|
||||
detection_duration
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🔍 Detected protocol: {}", protocol_name(&detected));
|
||||
}
|
||||
detected
|
||||
};
|
||||
|
||||
// 构建 McpClientConfig
|
||||
tracing::debug!("Building MCP client config...");
|
||||
let config = build_mcp_config(url, &merged_headers, args.auth.as_ref());
|
||||
tracing::debug!("MCP client config ready");
|
||||
|
||||
// 根据协议类型分支处理
|
||||
tracing::info!("Using protocol: {}", protocol_name(&protocol));
|
||||
match protocol {
|
||||
crate::client::protocol::McpProtocol::Sse => {
|
||||
run_sse_mode(config, args.clone(), tool_filter, verbose, quiet)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("SSE mode failed: {:?}", e);
|
||||
eprintln!("❌ SSE mode failed: {}", e);
|
||||
e
|
||||
})
|
||||
}
|
||||
crate::client::protocol::McpProtocol::Stream => {
|
||||
run_stream_mode(config, args.clone(), tool_filter, verbose, quiet)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Stream mode failed: {:?}", e);
|
||||
eprintln!("❌ Stream mode failed: {}", e);
|
||||
e
|
||||
})
|
||||
}
|
||||
crate::client::protocol::McpProtocol::Stdio => {
|
||||
tracing::error!("Stdio protocol does not support URL conversion");
|
||||
anyhow::bail!(
|
||||
"Stdio protocol does not support URL conversion, please use --config for local commands"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 McpClientConfig
|
||||
pub fn build_mcp_config(
|
||||
url: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
auth: Option<&String>,
|
||||
) -> McpClientConfig {
|
||||
let mut config = McpClientConfig::new(url);
|
||||
for (k, v) in headers {
|
||||
// Authorization header: 确保有 "Bearer " 前缀,与 Server 模式行为一致
|
||||
if k.eq_ignore_ascii_case("Authorization") {
|
||||
let value = if v.starts_with("Bearer ") {
|
||||
v.clone()
|
||||
} else {
|
||||
format!("Bearer {}", v)
|
||||
};
|
||||
config = config.with_header(k, value);
|
||||
} else {
|
||||
config = config.with_header(k, v);
|
||||
}
|
||||
}
|
||||
if let Some(auth_value) = auth {
|
||||
// 命令行 --auth 参数不带 "Bearer " 前缀,直接添加
|
||||
config = config.with_header("Authorization", auth_value);
|
||||
}
|
||||
config
|
||||
}
|
||||
15
qiming-mcp-proxy/mcp-proxy/src/client/core/mod.rs
Normal file
15
qiming-mcp-proxy/mcp-proxy/src/client/core/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! 核心业务逻辑模块
|
||||
//!
|
||||
//! 包含协议转换的核心实现,与 CLI 接口解耦
|
||||
|
||||
pub mod command;
|
||||
pub mod common;
|
||||
pub mod convert;
|
||||
pub mod sse;
|
||||
pub mod stream;
|
||||
|
||||
// 导出公共 API
|
||||
// 注意: run_sse_mode 和 run_stream_mode 是内部实现细节,
|
||||
// 只被 convert 模块使用,不需要对外暴露
|
||||
pub use command::run_command_mode;
|
||||
pub use convert::run_url_mode_with_retry;
|
||||
436
qiming-mcp-proxy/mcp-proxy/src/client/core/sse.rs
Normal file
436
qiming-mcp-proxy/mcp-proxy/src/client/core/sse.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
//! SSE 模式处理
|
||||
//!
|
||||
//! Server-Sent Events 协议的实现和连接管理
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::error;
|
||||
|
||||
use super::common::HealthChecker;
|
||||
use crate::client::support::{
|
||||
ConvertArgs, classify_error, print_diagnostic_report, summarize_error, truncate_str,
|
||||
};
|
||||
use crate::proxy::{McpClientConfig, ProxyHandler, SseClientConnection, ToolFilter};
|
||||
|
||||
use mcp_sse_proxy::{ServiceExt, stdio as sse_stdio};
|
||||
|
||||
/// 为 ProxyHandler 实现 HealthChecker trait
|
||||
impl HealthChecker for ProxyHandler {
|
||||
fn is_backend_available(&self) -> bool {
|
||||
self.is_backend_available()
|
||||
}
|
||||
|
||||
async fn is_terminated_async(&self) -> bool {
|
||||
self.is_terminated_async().await
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 模式处理(使用 mcp-sse-proxy,rmcp 0.10)
|
||||
pub async fn run_sse_mode(
|
||||
config: McpClientConfig,
|
||||
args: ConvertArgs,
|
||||
tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting SSE mode");
|
||||
tracing::info!("Target URL: {}", config.url);
|
||||
tracing::info!(
|
||||
"Ping config: interval={}s, timeout={}s",
|
||||
args.ping_interval,
|
||||
args.ping_timeout
|
||||
);
|
||||
tracing::info!("========================================");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Connecting to backend service (SSE)...");
|
||||
}
|
||||
|
||||
// 1. 使用高层 API 连接(带重试,防止初始连接因时序问题失败)
|
||||
let connect_timeout = Duration::from_secs(15);
|
||||
const MAX_INITIAL_RETRIES: u32 = 3;
|
||||
const INITIAL_BACKOFF_SECS: u64 = 2;
|
||||
const MAX_BACKOFF_SECS: u64 = 4;
|
||||
|
||||
tracing::info!(
|
||||
"Connecting to backend (per-attempt timeout: {}s, max retries: {})",
|
||||
connect_timeout.as_secs(),
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
let connect_start = std::time::Instant::now();
|
||||
|
||||
let mut last_error = None;
|
||||
let mut conn = None;
|
||||
let mut backoff_secs = INITIAL_BACKOFF_SECS;
|
||||
for attempt in 1..=MAX_INITIAL_RETRIES {
|
||||
match tokio::time::timeout(
|
||||
connect_timeout,
|
||||
SseClientConnection::connect(config.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(c)) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(
|
||||
"Backend connection succeeded on attempt {}/{}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
}
|
||||
conn = Some(c);
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} failed: {}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
e
|
||||
);
|
||||
last_error = Some(format!("Backend connection failed: {}", e));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} timed out ({}s)",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
connect_timeout.as_secs()
|
||||
);
|
||||
last_error = Some(format!(
|
||||
"Backend connection timeout ({}s)",
|
||||
connect_timeout.as_secs()
|
||||
));
|
||||
}
|
||||
}
|
||||
if attempt < MAX_INITIAL_RETRIES {
|
||||
tracing::info!("Retrying in {}s... (elapsed: {:?})", backoff_secs, connect_start.elapsed());
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ Connection attempt {}/{} failed, retrying in {}s...",
|
||||
attempt, MAX_INITIAL_RETRIES, backoff_secs
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = conn.ok_or_else(|| {
|
||||
let elapsed = connect_start.elapsed();
|
||||
let msg = last_error.unwrap_or_else(|| "Unknown connection error".to_string());
|
||||
tracing::error!(
|
||||
"All {} connection attempts failed after {:?}: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed, msg
|
||||
);
|
||||
eprintln!(
|
||||
"❌ All {} connection attempts failed after {:.1}s: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed.as_secs_f64(), msg
|
||||
);
|
||||
anyhow::anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let connect_duration = connect_start.elapsed();
|
||||
tracing::info!(
|
||||
"Backend connected successfully (duration: {:?})",
|
||||
connect_duration
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("✅ Backend connected successfully");
|
||||
// 打印工具列表
|
||||
print_sse_tools(&conn, quiet).await;
|
||||
if args.ping_interval > 0 {
|
||||
eprintln!(
|
||||
"💓 Health ping: every {}s (timeout {}s)",
|
||||
args.ping_interval, args.ping_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建 handler(消耗 conn)
|
||||
tracing::debug!("Creating ProxyHandler...");
|
||||
let handler = Arc::new(conn.into_handler("cli".to_string(), tool_filter.clone()));
|
||||
tracing::debug!("ProxyHandler created");
|
||||
|
||||
// 3. 启动 stdio server
|
||||
tracing::info!("Starting stdio server...");
|
||||
let server = (*handler).clone().serve(sse_stdio()).await.map_err(|e| {
|
||||
tracing::error!("Failed to start stdio server: {:?}", e);
|
||||
eprintln!("❌ Failed to start stdio server: {}", e);
|
||||
e
|
||||
})?;
|
||||
tracing::info!("Stdio server started");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("💡 Stdio server started, proxying traffic...");
|
||||
}
|
||||
|
||||
// 4. 启动 watchdog 任务
|
||||
let handler_for_watchdog = handler.clone();
|
||||
let mut watchdog_handle = tokio::spawn(run_sse_watchdog(
|
||||
handler_for_watchdog,
|
||||
args,
|
||||
config,
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
));
|
||||
tracing::debug!("Watchdog task started");
|
||||
|
||||
// 5. 等待 stdio server 退出
|
||||
tracing::info!("Waiting for stdio/watchdog events...");
|
||||
tokio::select! {
|
||||
result = server.waiting() => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Stdio server exited (EOF)");
|
||||
tracing::info!("========================================");
|
||||
watchdog_handle.abort();
|
||||
result?;
|
||||
}
|
||||
watchdog_result = &mut watchdog_handle => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Watchdog task exited");
|
||||
tracing::info!("========================================");
|
||||
if let Err(e) = watchdog_result
|
||||
&& !e.is_cancelled()
|
||||
{
|
||||
error!("SSE Watchdog task failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("SSE mode exited normally");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打印 SSE 连接的工具列表
|
||||
async fn print_sse_tools(conn: &SseClientConnection, quiet: bool) {
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
match conn.list_tools().await {
|
||||
Ok(tools) => {
|
||||
if tools.is_empty() {
|
||||
eprintln!("⚠️ Tool list is empty (tools/list returned 0 tools)");
|
||||
} else {
|
||||
eprintln!("🔧 Available tools ({}):", tools.len());
|
||||
for tool in &tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
let desc_short = truncate_str(desc, 50);
|
||||
eprintln!(" - {} : {}", tool.name, desc_short);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to list tools: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 模式的 watchdog:负责监控连接健康、断开时重连
|
||||
async fn run_sse_watchdog(
|
||||
handler: Arc<ProxyHandler>,
|
||||
args: ConvertArgs,
|
||||
config: McpClientConfig,
|
||||
_tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting SSE watchdog");
|
||||
tracing::info!("Max retries: {}", args.retries);
|
||||
tracing::info!("========================================");
|
||||
|
||||
let max_retries = args.retries;
|
||||
let mut attempt = 0u32;
|
||||
let mut backoff_secs = 1u64;
|
||||
const MAX_BACKOFF_SECS: u64 = 30;
|
||||
const EVENT_DISCONNECTED: &str = "EVENT_DISCONNECTED";
|
||||
const EVENT_RECONNECTED: &str = "EVENT_RECONNECTED";
|
||||
const EVENT_RETRY_BACKOFF: &str = "EVENT_RETRY_BACKOFF";
|
||||
let initial_connection_start = std::time::Instant::now();
|
||||
|
||||
// 首先监控现有连接的健康状态
|
||||
tracing::info!("Monitoring initial connection health");
|
||||
let disconnect_reason =
|
||||
monitor_sse_connection(&handler, args.ping_interval, args.ping_timeout, quiet).await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
tracing::warn!("Initial connection disconnected: {}", disconnect_reason);
|
||||
handler.swap_backend(None);
|
||||
|
||||
let alive_duration = initial_connection_start.elapsed();
|
||||
tracing::info!(
|
||||
"Initial connection alive duration: {}s",
|
||||
alive_duration.as_secs()
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(首次断开)
|
||||
print_diagnostic_report(
|
||||
"SSE",
|
||||
&config.url,
|
||||
alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
|
||||
// 进入重连循环
|
||||
loop {
|
||||
attempt += 1;
|
||||
tracing::info!("========================================");
|
||||
if max_retries == 0 {
|
||||
tracing::info!("Reconnect attempt #{} (unlimited mode)", attempt);
|
||||
} else {
|
||||
tracing::info!("Reconnect attempt {}/{}", attempt, max_retries);
|
||||
}
|
||||
tracing::info!("Backoff: {}s", backoff_secs);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Reconnecting (attempt #{})...", attempt);
|
||||
}
|
||||
|
||||
// 尝试建立连接
|
||||
tracing::debug!("Attempting to establish connection...");
|
||||
let connect_start = std::time::Instant::now();
|
||||
let connect_result = SseClientConnection::connect(config.clone()).await;
|
||||
let connect_duration = connect_start.elapsed();
|
||||
|
||||
match connect_result {
|
||||
Ok(conn) => {
|
||||
tracing::info!("Reconnect succeeded (duration: {:?})", connect_duration);
|
||||
|
||||
// 连接成功,获取 RunningService 并热替换后端
|
||||
let running = conn.into_running_service();
|
||||
handler.swap_backend(Some(running));
|
||||
backoff_secs = 1;
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"✅ [{}] Reconnected, proxy service resumed",
|
||||
EVENT_RECONNECTED
|
||||
);
|
||||
}
|
||||
|
||||
// 监控连接健康
|
||||
tracing::info!("Monitoring connection after reconnect");
|
||||
let reconnect_start = std::time::Instant::now();
|
||||
let disconnect_reason =
|
||||
monitor_sse_connection(&handler, args.ping_interval, args.ping_timeout, quiet)
|
||||
.await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
tracing::warn!("Reconnected session disconnected: {}", disconnect_reason);
|
||||
handler.swap_backend(None);
|
||||
let reconnect_alive_duration = reconnect_start.elapsed();
|
||||
tracing::info!(
|
||||
"Reconnected session alive duration: {}s",
|
||||
reconnect_alive_duration.as_secs()
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(重连后断开)
|
||||
print_diagnostic_report(
|
||||
"SSE",
|
||||
&config.url,
|
||||
reconnect_alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let error_type = classify_error(&e);
|
||||
tracing::error!(
|
||||
"Connection failed [{}]: {} (duration: {:?})",
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
connect_duration
|
||||
);
|
||||
|
||||
if max_retries > 0 && attempt >= max_retries {
|
||||
tracing::error!("Max retries reached: {}", max_retries);
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"❌ Connection failed, max retries reached ({})",
|
||||
max_retries
|
||||
);
|
||||
eprintln!(" Error type: {}", error_type);
|
||||
eprintln!(" Error detail: {}", e);
|
||||
}
|
||||
// 生成最终诊断报告
|
||||
print_diagnostic_report(
|
||||
"SSE",
|
||||
&config.url,
|
||||
0,
|
||||
"Connection failed: max retries reached",
|
||||
Some(&error_type),
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
if max_retries == 0 {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s (attempt #{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s ({}/{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt,
|
||||
max_retries
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if verbose && !quiet {
|
||||
eprintln!(" Full error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("Waiting {}s before next reconnect attempt", backoff_secs);
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
|
||||
tracing::info!("SSE watchdog exited");
|
||||
}
|
||||
|
||||
/// 监控 SSE 连接健康状态
|
||||
///
|
||||
/// 委托给 common::monitor_connection_health 公共函数
|
||||
async fn monitor_sse_connection(
|
||||
handler: &ProxyHandler,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
quiet: bool,
|
||||
) -> String {
|
||||
super::common::monitor_connection_health(handler, ping_interval, ping_timeout, quiet, "SSE")
|
||||
.await
|
||||
}
|
||||
405
qiming-mcp-proxy/mcp-proxy/src/client/core/stream.rs
Normal file
405
qiming-mcp-proxy/mcp-proxy/src/client/core/stream.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
//! Stream 模式处理
|
||||
//!
|
||||
//! Streamable HTTP 协议的实现和连接管理
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::common::HealthChecker;
|
||||
use crate::client::support::{
|
||||
ConvertArgs, classify_error, print_diagnostic_report, summarize_error, truncate_str,
|
||||
};
|
||||
use crate::proxy::{McpClientConfig, StreamClientConnection, StreamProxyHandler, ToolFilter};
|
||||
|
||||
use mcp_streamable_proxy::{ServiceExt, stdio as stream_stdio};
|
||||
|
||||
/// 为 StreamProxyHandler 实现 HealthChecker trait
|
||||
impl HealthChecker for StreamProxyHandler {
|
||||
fn is_backend_available(&self) -> bool {
|
||||
self.is_backend_available()
|
||||
}
|
||||
|
||||
async fn is_terminated_async(&self) -> bool {
|
||||
self.is_terminated_async().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream 模式处理(使用 mcp-streamable-proxy,rmcp 0.12)
|
||||
pub async fn run_stream_mode(
|
||||
config: McpClientConfig,
|
||||
args: ConvertArgs,
|
||||
tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting Stream mode");
|
||||
tracing::info!("Target URL: {}", config.url);
|
||||
tracing::info!(
|
||||
"Ping config: interval={}s, timeout={}s",
|
||||
args.ping_interval,
|
||||
args.ping_timeout
|
||||
);
|
||||
tracing::info!("========================================");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Connecting to backend service (Stream)...");
|
||||
}
|
||||
|
||||
// 1. 使用高层 API 连接(带重试,防止初始连接因时序问题失败)
|
||||
//
|
||||
// 重试策略:最多 3 次,每次给 15s 连接超时,间隔 2s/4s
|
||||
// 第1次(最多 15s)→ 失败 → 等 2s → 第2次(最多 15s)→ 失败 → 等 4s → 第3次(最多 15s)→ 退出
|
||||
// 最坏总耗时:15 + 2 + 15 + 4 + 15 = 51s(但大多数失败会比 15s 更快返回)
|
||||
// 通常总耗时:连接快速失败(~1s) + 2 + 快速失败(~1s) + 4 + 快速失败(~1s) = ~9s
|
||||
let connect_timeout = Duration::from_secs(15);
|
||||
const MAX_INITIAL_RETRIES: u32 = 3;
|
||||
const INITIAL_BACKOFF_SECS: u64 = 2;
|
||||
const MAX_BACKOFF_SECS: u64 = 4;
|
||||
|
||||
tracing::info!(
|
||||
"Connecting to backend (per-attempt timeout: {}s, max retries: {})",
|
||||
connect_timeout.as_secs(),
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
let connect_start = std::time::Instant::now();
|
||||
|
||||
let mut last_error = None;
|
||||
let mut conn = None;
|
||||
let mut backoff_secs = INITIAL_BACKOFF_SECS;
|
||||
for attempt in 1..=MAX_INITIAL_RETRIES {
|
||||
match tokio::time::timeout(
|
||||
connect_timeout,
|
||||
StreamClientConnection::connect(config.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(c)) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(
|
||||
"Backend connection succeeded on attempt {}/{}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES
|
||||
);
|
||||
}
|
||||
conn = Some(c);
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} failed: {}",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
e
|
||||
);
|
||||
last_error = Some(format!("Backend connection failed: {}", e));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"Backend connection attempt {}/{} timed out ({}s)",
|
||||
attempt,
|
||||
MAX_INITIAL_RETRIES,
|
||||
connect_timeout.as_secs()
|
||||
);
|
||||
last_error = Some(format!(
|
||||
"Backend connection timeout ({}s)",
|
||||
connect_timeout.as_secs()
|
||||
));
|
||||
}
|
||||
}
|
||||
if attempt < MAX_INITIAL_RETRIES {
|
||||
tracing::info!("Retrying in {}s... (elapsed: {:?})", backoff_secs, connect_start.elapsed());
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ Connection attempt {}/{} failed, retrying in {}s...",
|
||||
attempt, MAX_INITIAL_RETRIES, backoff_secs
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = conn.ok_or_else(|| {
|
||||
let elapsed = connect_start.elapsed();
|
||||
let msg = last_error.unwrap_or_else(|| "Unknown connection error".to_string());
|
||||
tracing::error!(
|
||||
"All {} connection attempts failed after {:?}: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed, msg
|
||||
);
|
||||
eprintln!(
|
||||
"❌ All {} connection attempts failed after {:.1}s: {}",
|
||||
MAX_INITIAL_RETRIES, elapsed.as_secs_f64(), msg
|
||||
);
|
||||
anyhow::anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let connect_duration = connect_start.elapsed();
|
||||
tracing::info!(
|
||||
"Backend connected successfully (duration: {:?})",
|
||||
connect_duration
|
||||
);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("✅ Backend connected successfully");
|
||||
// 打印工具列表
|
||||
print_stream_tools(&conn, quiet).await;
|
||||
if args.ping_interval > 0 {
|
||||
eprintln!(
|
||||
"💓 Health ping: every {}s (timeout {}s)",
|
||||
args.ping_interval, args.ping_timeout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建 handler(消耗 conn)
|
||||
tracing::debug!("Creating ProxyHandler...");
|
||||
let handler = Arc::new(conn.into_handler("cli".to_string(), tool_filter.clone()));
|
||||
tracing::debug!("ProxyHandler created");
|
||||
|
||||
// 3. 启动 stdio server(使用 stream_stdio,即 rmcp 0.12 的 stdio)
|
||||
tracing::info!("Starting stdio server...");
|
||||
let server = (*handler).clone().serve(stream_stdio()).await.map_err(|e| {
|
||||
tracing::error!("Failed to start stdio server: {:?}", e);
|
||||
eprintln!("❌ Failed to start stdio server: {}", e);
|
||||
e
|
||||
})?;
|
||||
tracing::info!("Stdio server started");
|
||||
|
||||
if !quiet {
|
||||
eprintln!("💡 Stdio server started, proxying traffic...");
|
||||
}
|
||||
|
||||
// 4. 启动 watchdog 任务
|
||||
let handler_for_watchdog = handler.clone();
|
||||
let mut watchdog_handle = tokio::spawn(run_stream_watchdog(
|
||||
handler_for_watchdog,
|
||||
args,
|
||||
config,
|
||||
tool_filter,
|
||||
verbose,
|
||||
quiet,
|
||||
));
|
||||
tracing::debug!("Watchdog task started");
|
||||
|
||||
// 5. 等待 stdio server 退出
|
||||
tracing::info!("Waiting for stdio/watchdog events...");
|
||||
tokio::select! {
|
||||
result = server.waiting() => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Stdio server exited (EOF)");
|
||||
tracing::info!("========================================");
|
||||
watchdog_handle.abort();
|
||||
result?;
|
||||
}
|
||||
watchdog_result = &mut watchdog_handle => {
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Watchdog task exited");
|
||||
tracing::info!("========================================");
|
||||
if let Err(e) = watchdog_result
|
||||
&& !e.is_cancelled()
|
||||
{
|
||||
tracing::error!("Stream Watchdog task failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Stream mode exited normally");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打印 Stream 连接的工具列表
|
||||
async fn print_stream_tools(conn: &StreamClientConnection, quiet: bool) {
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
match conn.list_tools().await {
|
||||
Ok(tools) => {
|
||||
if tools.is_empty() {
|
||||
eprintln!("⚠️ Tool list is empty (tools/list returned 0 tools)");
|
||||
} else {
|
||||
eprintln!("🔧 Available tools ({}):", tools.len());
|
||||
for tool in &tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
let desc_short = truncate_str(desc, 50);
|
||||
eprintln!(" - {} : {}", tool.name, desc_short);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to list tools: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream 模式的 watchdog:负责监控连接健康、断开时重连
|
||||
async fn run_stream_watchdog(
|
||||
handler: Arc<StreamProxyHandler>,
|
||||
args: ConvertArgs,
|
||||
config: McpClientConfig,
|
||||
_tool_filter: ToolFilter,
|
||||
verbose: bool,
|
||||
quiet: bool,
|
||||
) {
|
||||
let max_retries = args.retries;
|
||||
let mut attempt = 0u32;
|
||||
let mut backoff_secs = 1u64;
|
||||
const MAX_BACKOFF_SECS: u64 = 30;
|
||||
const EVENT_DISCONNECTED: &str = "EVENT_DISCONNECTED";
|
||||
const EVENT_RECONNECTED: &str = "EVENT_RECONNECTED";
|
||||
const EVENT_RETRY_BACKOFF: &str = "EVENT_RETRY_BACKOFF";
|
||||
let initial_connection_start = std::time::Instant::now();
|
||||
|
||||
// 首先监控现有连接的健康状态
|
||||
let disconnect_reason =
|
||||
monitor_stream_connection(&handler, args.ping_interval, args.ping_timeout, quiet).await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
handler.swap_backend(None);
|
||||
|
||||
let alive_duration = initial_connection_start.elapsed();
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(首次断开)
|
||||
print_diagnostic_report(
|
||||
"Streamable HTTP",
|
||||
&config.url,
|
||||
alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
|
||||
// 进入重连循环
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔗 Reconnecting (attempt #{})...", attempt);
|
||||
}
|
||||
|
||||
// 尝试建立连接
|
||||
let connect_result = StreamClientConnection::connect(config.clone()).await;
|
||||
|
||||
match connect_result {
|
||||
Ok(conn) => {
|
||||
// 连接成功,获取 RunningService 并热替换后端
|
||||
let running = conn.into_running_service();
|
||||
handler.swap_backend(Some(running));
|
||||
backoff_secs = 1;
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"✅ [{}] Reconnected, proxy service resumed",
|
||||
EVENT_RECONNECTED
|
||||
);
|
||||
}
|
||||
|
||||
// 监控连接健康
|
||||
let reconnect_start = std::time::Instant::now();
|
||||
let disconnect_reason = monitor_stream_connection(
|
||||
&handler,
|
||||
args.ping_interval,
|
||||
args.ping_timeout,
|
||||
quiet,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 连接断开,标记后端不可用
|
||||
handler.swap_backend(None);
|
||||
let reconnect_alive_duration = reconnect_start.elapsed();
|
||||
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection disconnected: {}",
|
||||
EVENT_DISCONNECTED, disconnect_reason
|
||||
);
|
||||
}
|
||||
|
||||
// 生成诊断报告(重连后断开)
|
||||
print_diagnostic_report(
|
||||
"Streamable HTTP",
|
||||
&config.url,
|
||||
reconnect_alive_duration.as_secs(),
|
||||
&disconnect_reason,
|
||||
None,
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let error_type = classify_error(&e);
|
||||
|
||||
if max_retries > 0 && attempt >= max_retries {
|
||||
if !quiet {
|
||||
eprintln!(
|
||||
"❌ Connection failed, max retries reached ({})",
|
||||
max_retries
|
||||
);
|
||||
eprintln!(" Error type: {}", error_type);
|
||||
eprintln!(" Error detail: {}", e);
|
||||
}
|
||||
// 生成最终诊断报告
|
||||
print_diagnostic_report(
|
||||
"Streamable HTTP",
|
||||
&config.url,
|
||||
0,
|
||||
"Connection failed: max retries reached",
|
||||
Some(&error_type),
|
||||
args.logging.diagnostic,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
if max_retries == 0 {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s (attempt #{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"⚠️ [{}] Connection failed [{}]: {}; retrying in {}s ({}/{})...",
|
||||
EVENT_RETRY_BACKOFF,
|
||||
error_type,
|
||||
summarize_error(&e),
|
||||
backoff_secs,
|
||||
attempt,
|
||||
max_retries
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if verbose && !quiet {
|
||||
eprintln!(" Full error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
/// 监控 Stream 连接健康状态
|
||||
///
|
||||
/// 委托给 common::monitor_connection_health 公共函数
|
||||
async fn monitor_stream_connection(
|
||||
handler: &StreamProxyHandler,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
quiet: bool,
|
||||
) -> String {
|
||||
super::common::monitor_connection_health(handler, ping_interval, ping_timeout, quiet, "Stream")
|
||||
.await
|
||||
}
|
||||
20
qiming-mcp-proxy/mcp-proxy/src/client/mod.rs
Normal file
20
qiming-mcp-proxy/mcp-proxy/src/client/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// MCP 客户端模块
|
||||
// 提供各种 MCP 协议的客户端实现和 CLI 工具
|
||||
|
||||
mod cli;
|
||||
mod protocol;
|
||||
pub(crate) mod proxy_server;
|
||||
|
||||
// 新的模块化架构 (按功能层次分组)
|
||||
pub mod cli_impl; // CLI 命令实现
|
||||
pub mod core; // 核心业务逻辑
|
||||
pub mod support; // 支持功能
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// 导出 CLI 功能(公共 API)
|
||||
pub use cli::{Cli, Commands, run_cli};
|
||||
|
||||
// 注意:ConvertArgs, CheckArgs, DetectArgs 等类型只在内部使用,
|
||||
// 不需要在这里重新导出。如需使用,请通过 support 模块导入。
|
||||
15
qiming-mcp-proxy/mcp-proxy/src/client/protocol.rs
Normal file
15
qiming-mcp-proxy/mcp-proxy/src/client/protocol.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// MCP 协议检测模块
|
||||
// 用于自动检测远程 MCP 服务的协议类型
|
||||
// 复用 server 模块的协议检测逻辑
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
// 复用 model 中的协议类型定义
|
||||
pub use crate::model::McpProtocol;
|
||||
|
||||
/// 自动检测 MCP 协议类型
|
||||
///
|
||||
/// 直接复用 server 模块的协议检测逻辑
|
||||
pub async fn detect_mcp_protocol(url: &str) -> Result<McpProtocol> {
|
||||
crate::server::detect_mcp_protocol(url).await
|
||||
}
|
||||
378
qiming-mcp-proxy/mcp-proxy/src/client/proxy_server.rs
Normal file
378
qiming-mcp-proxy/mcp-proxy/src/client/proxy_server.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
//! MCP Proxy Server - 将 stdio MCP 服务代理为 HTTP/SSE 或 Streamable HTTP 服务
|
||||
//!
|
||||
//! 支持多个 agent 复用同一个 MCP 服务
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Once;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::Parser;
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::client::support::{LoggingArgs, init_logging_with_config};
|
||||
use crate::proxy::ToolFilter;
|
||||
|
||||
/// Panic hook 初始化标志(确保只设置一次)
|
||||
static INIT_PANIC_HOOK: Once = Once::new();
|
||||
|
||||
/// 最大重试次数 (0 = 无限重试)
|
||||
const MAX_RETRIES: u32 = 0;
|
||||
/// 初始重试间隔(秒)
|
||||
const INITIAL_RETRY_DELAY_SECS: u64 = 3;
|
||||
/// 最大重试间隔(秒)
|
||||
const MAX_RETRY_DELAY_SECS: u64 = 30;
|
||||
|
||||
/// 输出协议类型
|
||||
#[derive(clap::ValueEnum, Clone, Debug, Default)]
|
||||
pub enum ProxyProtocol {
|
||||
/// SSE 协议
|
||||
Sse,
|
||||
/// Streamable HTTP 协议
|
||||
#[default]
|
||||
Stream,
|
||||
}
|
||||
|
||||
/// 代理模式参数 - 将 stdio MCP 服务代理为 HTTP 服务
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ProxyArgs {
|
||||
/// 监听端口
|
||||
#[arg(short, long, default_value = "8080", help = "监听端口")]
|
||||
pub port: u16,
|
||||
|
||||
/// 监听地址
|
||||
#[arg(long, default_value = "127.0.0.1", help = "监听地址")]
|
||||
pub host: String,
|
||||
|
||||
/// MCP 服务名称(当配置包含多个服务时必需)
|
||||
#[arg(short, long, help = "MCP 服务名称(多服务配置时必需)")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// MCP 服务配置 JSON
|
||||
#[arg(long, conflicts_with = "config_file", help = "MCP 服务配置 JSON")]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// MCP 服务配置文件路径
|
||||
#[arg(long, conflicts_with = "config", help = "MCP 服务配置文件路径")]
|
||||
pub config_file: Option<PathBuf>,
|
||||
|
||||
/// 输出协议类型
|
||||
#[arg(long, value_enum, default_value = "stream", help = "输出协议类型")]
|
||||
pub protocol: ProxyProtocol,
|
||||
|
||||
/// SSE 端点路径(仅 SSE 协议)
|
||||
#[arg(long, default_value = "/sse", help = "SSE 端点路径")]
|
||||
pub sse_path: String,
|
||||
|
||||
/// 消息端点路径(仅 SSE 协议)
|
||||
#[arg(long, default_value = "/message", help = "消息端点路径")]
|
||||
pub message_path: String,
|
||||
|
||||
/// 工具白名单(逗号分隔),只允许指定的工具
|
||||
#[arg(long, value_delimiter = ',', help = "工具白名单(逗号分隔)")]
|
||||
pub allow_tools: Option<Vec<String>>,
|
||||
|
||||
/// 工具黑名单(逗号分隔),排除指定的工具
|
||||
#[arg(long, value_delimiter = ',', help = "工具黑名单(逗号分隔)")]
|
||||
pub deny_tools: Option<Vec<String>>,
|
||||
|
||||
/// 日志配置(使用通用结构)
|
||||
#[command(flatten)]
|
||||
pub logging: LoggingArgs,
|
||||
}
|
||||
|
||||
/// MCP 配置格式
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct McpConfig {
|
||||
#[serde(rename = "mcpServers")]
|
||||
mcp_servers: HashMap<String, StdioConfig>,
|
||||
}
|
||||
|
||||
/// stdio 配置
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct StdioConfig {
|
||||
command: String,
|
||||
args: Option<Vec<String>>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 解析后的服务配置(包含服务名)
|
||||
struct ParsedConfig {
|
||||
name: String,
|
||||
config: StdioConfig,
|
||||
}
|
||||
|
||||
/// 运行代理命令
|
||||
pub async fn run_proxy_command(args: ProxyArgs, verbose: bool, quiet: bool) -> Result<()> {
|
||||
// 设置 panic hook 以记录详细的 panic 信息(只设置一次)
|
||||
INIT_PANIC_HOOK.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|panic_info| {
|
||||
let backtrace = std::backtrace::Backtrace::capture();
|
||||
error!(
|
||||
"[PANIC] Program panic - Location: {}:{}, Message: {:?}",
|
||||
panic_info.location().map(|l| l.file()).unwrap_or("unknown"),
|
||||
panic_info.location().map(|l| l.line()).unwrap_or(0),
|
||||
panic_info.payload().downcast_ref::<String>()
|
||||
);
|
||||
error!("[PANIC] Stack trace:\\n{:?}", backtrace);
|
||||
}));
|
||||
});
|
||||
|
||||
// 1. 验证互斥参数
|
||||
if args.allow_tools.is_some() && args.deny_tools.is_some() {
|
||||
bail!("--allow-tools 和 --deny-tools 不能同时使用,请只选择其中一个");
|
||||
}
|
||||
|
||||
// 2. 解析配置
|
||||
let parsed = parse_config(&args)?;
|
||||
|
||||
// 2.5 初始化镜像源环境变量(MCP_PROXY_NPM_REGISTRY → npm_config_registry 等)
|
||||
// 必须在日志初始化之前、单线程阶段调用
|
||||
{
|
||||
let mirror = mcp_common::mirror::MirrorConfig::from_env();
|
||||
if !mirror.is_empty() {
|
||||
mirror.apply_to_process_env();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 初始化日志系统(在启动服务之前)
|
||||
init_logging_with_config(&args.logging, Some(&parsed.name), quiet, verbose)?;
|
||||
|
||||
// 4. 创建工具过滤器
|
||||
let tool_filter = if let Some(allow_tools) = args.allow_tools.clone() {
|
||||
ToolFilter::allow(allow_tools)
|
||||
} else if let Some(deny_tools) = args.deny_tools.clone() {
|
||||
ToolFilter::deny(deny_tools)
|
||||
} else {
|
||||
ToolFilter::default()
|
||||
};
|
||||
|
||||
let protocol_name = match args.protocol {
|
||||
ProxyProtocol::Sse => "SSE",
|
||||
ProxyProtocol::Stream => "Streamable HTTP",
|
||||
};
|
||||
|
||||
// 记录服务启动信息到日志文件
|
||||
info!(
|
||||
"[Service startup] MCP Proxy service startup - protocol: {}, service name: {}, command: {} {:?}",
|
||||
protocol_name,
|
||||
parsed.name,
|
||||
parsed.config.command,
|
||||
parsed.config.args.as_ref().unwrap_or(&vec![])
|
||||
);
|
||||
|
||||
if let Some(ref allow_tools) = args.allow_tools {
|
||||
info!("[Service startup] Tool whitelist: {:?}", allow_tools);
|
||||
}
|
||||
if let Some(ref deny_tools) = args.deny_tools {
|
||||
info!("[Service startup] Tool blacklist: {:?}", deny_tools);
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🚀 MCP Proxy Service");
|
||||
eprintln!("Protocol type: {}", protocol_name);
|
||||
eprintln!("Service name: {}", parsed.name);
|
||||
eprintln!(
|
||||
"Command: {} {:?}",
|
||||
parsed.config.command,
|
||||
parsed.config.args.as_ref().unwrap_or(&vec![])
|
||||
);
|
||||
if verbose && let Some(ref env) = parsed.config.env {
|
||||
eprintln!("Environment variable: {:?}", env);
|
||||
}
|
||||
// 显示过滤器配置
|
||||
if let Some(ref allow_tools) = args.allow_tools {
|
||||
eprintln!("Tool whitelist: {:?}", allow_tools);
|
||||
}
|
||||
if let Some(ref deny_tools) = args.deny_tools {
|
||||
eprintln!("Tool blacklist: {:?}", deny_tools);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 端口提前绑定(在重试循环之前),确保 ServiceManager 的 TCP 健康检查能检测到进程存活
|
||||
let bind_addr = format!("{}:{}", args.host, args.port);
|
||||
let std_listener = std::net::TcpListener::bind(&bind_addr)
|
||||
.map_err(|e| anyhow::anyhow!("端口绑定失败 {}: {}", bind_addr, e))?;
|
||||
std_listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| anyhow::anyhow!("设置非阻塞失败: {}", e))?;
|
||||
info!("[Port Binding] Binded {}", bind_addr);
|
||||
|
||||
// 6. 主循环 - 支持子进程崩溃后自动重启(指数退避,有上限)
|
||||
let mut retry_count: u32 = 0;
|
||||
let mut retry_delay = Duration::from_secs(INITIAL_RETRY_DELAY_SECS);
|
||||
|
||||
loop {
|
||||
let result = run_proxy_server(
|
||||
&args,
|
||||
&parsed,
|
||||
&std_listener,
|
||||
tool_filter.clone(),
|
||||
verbose,
|
||||
quiet,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// 正常退出(如 Ctrl+C)
|
||||
info!(
|
||||
"[Service stopped] MCP Proxy service stopped normally - service name: {}",
|
||||
parsed.name
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🛑 Service has been stopped");
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
retry_count += 1;
|
||||
// MAX_RETRIES = 0 表示无限重试,非零值表示最大重试次数
|
||||
#[allow(clippy::absurd_extreme_comparisons)]
|
||||
if MAX_RETRIES > 0 && retry_count >= MAX_RETRIES {
|
||||
error!(
|
||||
"[Service Termination] Maximum number of retries reached {}, service name: {}, last error: {}",
|
||||
MAX_RETRIES, parsed.name, e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
let retry_info = if MAX_RETRIES == 0 {
|
||||
format!("第{}次", retry_count)
|
||||
} else {
|
||||
format!("第{}/{}次", retry_count, MAX_RETRIES)
|
||||
};
|
||||
error!(
|
||||
"[Service exception] MCP Proxy service exited abnormally - service name: {}, error: {}, {} and restarts after seconds ({})",
|
||||
parsed.name,
|
||||
e,
|
||||
retry_delay.as_secs(),
|
||||
retry_info
|
||||
);
|
||||
eprintln!(
|
||||
"⚠️ Service exception: {}, {}, restart after seconds ({})...",
|
||||
e,
|
||||
retry_delay.as_secs(),
|
||||
retry_info
|
||||
);
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
retry_delay =
|
||||
std::cmp::min(retry_delay * 2, Duration::from_secs(MAX_RETRY_DELAY_SECS));
|
||||
warn!(
|
||||
"[Service Restart] Restarting MCP Proxy service - Service name: {}",
|
||||
parsed.name
|
||||
);
|
||||
if !quiet {
|
||||
eprintln!("🔄 Restarting service...");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 运行代理服务器(单次运行)
|
||||
async fn run_proxy_server(
|
||||
args: &ProxyArgs,
|
||||
parsed: &ParsedConfig,
|
||||
std_listener: &std::net::TcpListener,
|
||||
tool_filter: ToolFilter,
|
||||
_verbose: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
// Windows 上解析命令扩展名(如 npx -> npx.cmd)
|
||||
let resolved_command = mcp_common::resolve_windows_command(&parsed.config.command);
|
||||
if resolved_command != parsed.config.command {
|
||||
info!(
|
||||
"[Command parsing] Windows command parsed: {} -> {}",
|
||||
parsed.config.command, resolved_command
|
||||
);
|
||||
}
|
||||
|
||||
// 根据协议类型选择对应的库并启动服务器
|
||||
// 每个库使用自己的 rmcp 版本创建完整的生命周期
|
||||
match args.protocol {
|
||||
ProxyProtocol::Sse => {
|
||||
// 使用 mcp-sse-proxy 库(rmcp 0.10)
|
||||
let config = mcp_sse_proxy::McpServiceConfig {
|
||||
name: parsed.name.clone(),
|
||||
command: resolved_command,
|
||||
args: parsed.config.args.clone(),
|
||||
env: parsed.config.env.clone(),
|
||||
tool_filter: Some(tool_filter),
|
||||
};
|
||||
mcp_sse_proxy::run_sse_server_from_config(config, std_listener, quiet).await
|
||||
}
|
||||
ProxyProtocol::Stream => {
|
||||
// 使用 mcp-streamable-proxy 库(rmcp 0.12)
|
||||
let config = mcp_streamable_proxy::McpServiceConfig {
|
||||
name: parsed.name.clone(),
|
||||
command: resolved_command,
|
||||
args: parsed.config.args.clone(),
|
||||
env: parsed.config.env.clone(),
|
||||
tool_filter: Some(tool_filter),
|
||||
};
|
||||
mcp_streamable_proxy::run_stream_server_from_config(config, std_listener, quiet).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: 两个库现在都使用 mcp-common::ToolFilter,所以直接传递即可
|
||||
|
||||
/// 解析配置
|
||||
fn parse_config(args: &ProxyArgs) -> Result<ParsedConfig> {
|
||||
// 1. 读取配置内容
|
||||
let json_str = if let Some(ref config) = args.config {
|
||||
config.clone()
|
||||
} else if let Some(ref path) = args.config_file {
|
||||
std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("读取配置文件失败: {}", e))?
|
||||
} else {
|
||||
bail!("必须提供 --config 或 --config-file 参数");
|
||||
};
|
||||
|
||||
// 2. 解析配置
|
||||
let mcp_config: McpConfig = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"配置解析失败: {}。配置必须是标准 MCP 格式,包含 mcpServers 字段",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let servers = mcp_config.mcp_servers;
|
||||
|
||||
if servers.is_empty() {
|
||||
bail!("配置中没有找到任何 MCP 服务");
|
||||
}
|
||||
|
||||
// 3. 根据服务数量和 --name 参数选择服务
|
||||
if servers.len() == 1 {
|
||||
// 单服务:自动使用,无需 --name
|
||||
let (name, config) = servers.into_iter().next().unwrap();
|
||||
Ok(ParsedConfig { name, config })
|
||||
} else if let Some(ref name) = args.name {
|
||||
// 多服务:根据 --name 选择
|
||||
servers
|
||||
.get(name)
|
||||
.cloned()
|
||||
.map(|config| ParsedConfig {
|
||||
name: name.clone(),
|
||||
config,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"服务 '{}' 不存在。可用服务: {:?}",
|
||||
name,
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// 多服务但未指定 --name
|
||||
bail!(
|
||||
"配置包含多个服务 {:?},请使用 --name 指定要启动的服务",
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
244
qiming-mcp-proxy/mcp-proxy/src/client/support/args.rs
Normal file
244
qiming-mcp-proxy/mcp-proxy/src/client/support/args.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
//! CLI 参数定义
|
||||
//!
|
||||
//! 定义所有命令行参数结构体
|
||||
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 通用日志配置参数
|
||||
///
|
||||
/// 用于多个命令之间共享日志配置
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct LoggingArgs {
|
||||
/// 启用详细诊断模式,输出连接和工具调用的详细时间信息(默认关闭
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "false",
|
||||
help = "启用详细诊断模式,追踪连接生命周期和超时问题(默认关闭)"
|
||||
)]
|
||||
pub diagnostic: bool,
|
||||
|
||||
/// 日志输出目录(自动生成文件名)
|
||||
#[arg(long, help = "日志输出目录,将自动生成日志文件名")]
|
||||
pub log_dir: Option<PathBuf>,
|
||||
|
||||
/// 日志文件完整路径(手动指定)
|
||||
#[arg(long, conflicts_with = "log_dir", help = "日志文件完整路径")]
|
||||
pub log_file: Option<PathBuf>,
|
||||
|
||||
/// OTLP 追踪端点(如 http://localhost:4317)
|
||||
///
|
||||
/// 启用 diagnostic 模式时,可配置此参数将追踪数据发送到 Jaeger 等 OTLP 兼容的后端。
|
||||
/// 支持 gRPC (端口 4317) 和 HTTP (端口 4318) 协议。
|
||||
#[arg(
|
||||
long,
|
||||
env = "OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
help = "OTLP 追踪端点 (如 http://localhost:4317)"
|
||||
)]
|
||||
pub otlp_endpoint: Option<String>,
|
||||
|
||||
/// 追踪服务名称(用于 Jaeger 等追踪后端标识)
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "mcp-proxy",
|
||||
help = "追踪服务名称(用于 Jaeger 等追踪后端标识)"
|
||||
)]
|
||||
pub service_name: String,
|
||||
}
|
||||
|
||||
/// MCP-Proxy CLI 主命令结构
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "mcp-proxy")]
|
||||
#[command(version = env!("CARGO_PKG_VERSION"))]
|
||||
#[command(about = "MCP 协议转换代理工具", long_about = None)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
|
||||
/// 直接URL模式(向后兼容)
|
||||
#[arg(value_name = "URL", help = "MCP 服务的 URL 地址(直接模式)")]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// 全局详细输出
|
||||
#[arg(short, long, global = true)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// 全局静默模式
|
||||
#[arg(short, long, global = true)]
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// 协议转换模式 - 将 URL 转换为 stdio
|
||||
Convert(ConvertArgs),
|
||||
|
||||
/// 检查服务状态
|
||||
Check(CheckArgs),
|
||||
|
||||
/// 协议检测
|
||||
Detect(DetectArgs),
|
||||
|
||||
/// 代理模式 - 将 stdio MCP 服务代理为 HTTP/SSE 服务
|
||||
Proxy(crate::client::proxy_server::ProxyArgs),
|
||||
|
||||
/// 健康检查 - 验证 MCP 服务是否可用
|
||||
Health(HealthArgs),
|
||||
}
|
||||
|
||||
/// 协议转换参数
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub struct ConvertArgs {
|
||||
/// MCP 服务的 URL 地址(可选,与 --config/--config-file 二选一)
|
||||
#[arg(value_name = "URL", help = "MCP 服务的 URL 地址")]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// MCP 服务配置 JSON
|
||||
#[arg(long, conflicts_with = "config_file", help = "MCP 服务配置 JSON")]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// MCP 服务配置文件路径
|
||||
#[arg(long, conflicts_with = "config", help = "MCP 服务配置文件路径")]
|
||||
pub config_file: Option<PathBuf>,
|
||||
|
||||
/// MCP 服务名称(多服务配置时必需)
|
||||
#[arg(short, long, help = "MCP 服务名称(多服务配置时必需)")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// 指定远程服务协议类型(不指定则自动检测)
|
||||
#[arg(long, value_enum, help = "指定远程服务协议类型(不指定则自动检测)")]
|
||||
pub protocol: Option<crate::client::proxy_server::ProxyProtocol>,
|
||||
|
||||
/// 认证 header (如: "Bearer token")
|
||||
#[arg(short, long, help = "认证 header")]
|
||||
pub auth: Option<String>,
|
||||
|
||||
/// 自定义 HTTP headers
|
||||
#[arg(short = 'H', long, value_parser = parse_key_val, help = "自定义 HTTP headers (KEY=VALUE 格式)")]
|
||||
pub header: Vec<(String, String)>,
|
||||
|
||||
/// 重试次数
|
||||
#[arg(long, default_value = "0", help = "重试次数,0 表示无限重试")]
|
||||
pub retries: u32,
|
||||
|
||||
/// 工具白名单(逗号分隔),只允许指定的工具
|
||||
#[arg(
|
||||
long,
|
||||
value_delimiter = ',',
|
||||
help = "工具白名单(逗号分隔),只允许指定的工具"
|
||||
)]
|
||||
pub allow_tools: Option<Vec<String>>,
|
||||
|
||||
/// 工具黑名单(逗号分隔),排除指定的工具
|
||||
#[arg(
|
||||
long,
|
||||
value_delimiter = ',',
|
||||
help = "工具黑名单(逗号分隔),排除指定的工具"
|
||||
)]
|
||||
pub deny_tools: Option<Vec<String>>,
|
||||
|
||||
/// 客户端 ping 间隔(秒),0 表示禁用
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "30",
|
||||
help = "客户端 ping 间隔(秒),0 表示禁用"
|
||||
)]
|
||||
pub ping_interval: u64,
|
||||
|
||||
/// 客户端 ping 超时(秒)
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "10",
|
||||
help = "客户端 ping 超时(秒),超时则认为连接断开"
|
||||
)]
|
||||
pub ping_timeout: u64,
|
||||
|
||||
/// 日志配置(使用通用结构)
|
||||
#[command(flatten)]
|
||||
pub logging: LoggingArgs,
|
||||
}
|
||||
|
||||
/// 检查参数
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct CheckArgs {
|
||||
/// 要检查的 MCP 服务 URL
|
||||
#[arg(value_name = "URL")]
|
||||
pub url: String,
|
||||
|
||||
/// 认证 header
|
||||
#[arg(short, long)]
|
||||
pub auth: Option<String>,
|
||||
|
||||
/// 超时时间
|
||||
#[arg(long, default_value = "10")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
/// 协议检测参数
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct DetectArgs {
|
||||
/// 要检测的 MCP 服务 URL
|
||||
#[arg(value_name = "URL")]
|
||||
pub url: String,
|
||||
|
||||
/// 认证 header
|
||||
#[arg(short, long)]
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
/// 健康检查参数
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(after_help = "\
|
||||
退出码:
|
||||
0 服务健康 - MCP 连接握手成功
|
||||
1 服务不健康 - 连接失败、超时或握手失败
|
||||
|
||||
示例:
|
||||
# 基本用法
|
||||
mcp-proxy health http://localhost:8080/mcp
|
||||
|
||||
# 带认证
|
||||
mcp-proxy health http://localhost:8080/mcp -a \"Bearer token123\"
|
||||
|
||||
# 指定协议和超时
|
||||
mcp-proxy health http://localhost:8080/mcp --protocol sse --timeout 5
|
||||
|
||||
# 静默模式(仅返回退出码,适合脚本使用)
|
||||
mcp-proxy health http://localhost:8080/mcp -q
|
||||
|
||||
# 在 shell 脚本中使用
|
||||
if mcp-proxy health http://localhost:8080/mcp -q; then
|
||||
echo \"MCP 服务正常\"
|
||||
else
|
||||
echo \"MCP 服务不可用\"
|
||||
fi
|
||||
")]
|
||||
pub struct HealthArgs {
|
||||
/// 要检查的 MCP 服务 URL
|
||||
#[arg(value_name = "URL")]
|
||||
pub url: String,
|
||||
|
||||
/// 认证 header (如: "Bearer token")
|
||||
#[arg(short, long, help = "认证 header")]
|
||||
pub auth: Option<String>,
|
||||
|
||||
/// 自定义 HTTP headers
|
||||
#[arg(short = 'H', long, value_parser = parse_key_val, help = "自定义 HTTP headers (KEY=VALUE 格式)")]
|
||||
pub header: Vec<(String, String)>,
|
||||
|
||||
/// 超时时间(秒)
|
||||
#[arg(long, default_value = "10")]
|
||||
pub timeout: u64,
|
||||
|
||||
/// 指定远程服务协议类型(不指定则自动检测)
|
||||
#[arg(long, value_enum, help = "指定远程服务协议类型(不指定则自动检测)")]
|
||||
pub protocol: Option<crate::client::proxy_server::ProxyProtocol>,
|
||||
}
|
||||
|
||||
/// 解析 KEY=VALUE 格式的辅助函数
|
||||
pub fn parse_key_val(s: &str) -> Result<(String, String), String> {
|
||||
let pos = s
|
||||
.find('=')
|
||||
.ok_or_else(|| format!("无效的 KEY=VALUE 格式: {}", s))?;
|
||||
Ok((s[..pos].to_string(), s[pos + 1..].to_string()))
|
||||
}
|
||||
197
qiming-mcp-proxy/mcp-proxy/src/client/support/config.rs
Normal file
197
qiming-mcp-proxy/mcp-proxy/src/client/support/config.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! MCP 配置解析
|
||||
//!
|
||||
//! 解析 JSON 配置文件,支持多种服务配置格式
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::args::ConvertArgs;
|
||||
|
||||
/// 解析后的配置源
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum McpConfigSource {
|
||||
/// 直接 URL 模式(命令行参数)
|
||||
DirectUrl { url: String },
|
||||
/// 远程服务配置(JSON 配置)
|
||||
RemoteService {
|
||||
name: String,
|
||||
url: String,
|
||||
protocol: Option<crate::client::protocol::McpProtocol>,
|
||||
headers: HashMap<String, String>,
|
||||
timeout: Option<u64>,
|
||||
},
|
||||
/// 本地命令配置(JSON 配置)
|
||||
LocalCommand {
|
||||
name: String,
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// MCP 配置格式
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct McpConfig {
|
||||
#[serde(rename = "mcpServers")]
|
||||
mcp_servers: HashMap<String, McpServerInnerConfig>,
|
||||
}
|
||||
|
||||
/// MCP 服务配置(支持 Command 和 Url 两种类型)
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum McpServerInnerConfig {
|
||||
Command(StdioConfig),
|
||||
Url(UrlConfig),
|
||||
}
|
||||
|
||||
/// stdio 配置(本地命令)
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct StdioConfig {
|
||||
command: String,
|
||||
args: Option<Vec<String>>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// URL 配置(远程服务)
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct UrlConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(
|
||||
skip_serializing_if = "Option::is_none",
|
||||
default,
|
||||
rename = "baseUrl",
|
||||
alias = "baseurl",
|
||||
alias = "base_url"
|
||||
)]
|
||||
base_url: Option<String>,
|
||||
#[serde(default, rename = "type", alias = "Type")]
|
||||
r#type: Option<String>,
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
#[serde(default, alias = "authToken", alias = "auth_token")]
|
||||
pub auth_token: Option<String>,
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
impl UrlConfig {
|
||||
fn get_url(&self) -> Option<&str> {
|
||||
self.url.as_deref().or(self.base_url.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 convert 命令的配置
|
||||
pub fn parse_convert_config(args: &ConvertArgs) -> Result<McpConfigSource> {
|
||||
// 优先级:url > config > config_file
|
||||
if let Some(ref url) = args.url {
|
||||
return Ok(McpConfigSource::DirectUrl { url: url.clone() });
|
||||
}
|
||||
|
||||
// 读取 JSON 配置
|
||||
let json_str = if let Some(ref config) = args.config {
|
||||
config.clone()
|
||||
} else if let Some(ref path) = args.config_file {
|
||||
std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("读取配置文件失败: {}", e))?
|
||||
} else {
|
||||
bail!("必须提供 URL、--config 或 --config-file 参数之一");
|
||||
};
|
||||
|
||||
// 解析 JSON 配置
|
||||
let mcp_config: McpConfig = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"配置解析失败: {}。配置必须是标准 MCP 格式,包含 mcpServers 字段",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let servers = mcp_config.mcp_servers;
|
||||
|
||||
if servers.is_empty() {
|
||||
bail!("配置中没有找到任何 MCP 服务");
|
||||
}
|
||||
|
||||
// 选择服务
|
||||
let (name, inner_config) = if let Some(ref name) = args.name {
|
||||
// 用户指定了服务名称,必须严格匹配
|
||||
let config = servers.get(name).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"服务 '{}' 不存在。可用服务: {:?}",
|
||||
name,
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
)
|
||||
})?;
|
||||
(name.clone(), config)
|
||||
} else if servers.len() == 1 {
|
||||
// 单服务且未指定名称,自动使用
|
||||
servers.into_iter().next().unwrap()
|
||||
} else {
|
||||
// 多服务且未指定名称
|
||||
bail!(
|
||||
"配置包含多个服务 {:?},请使用 --name 指定要使用的服务",
|
||||
servers.keys().collect::<Vec<_>>()
|
||||
);
|
||||
};
|
||||
|
||||
// 根据配置类型返回
|
||||
match inner_config {
|
||||
McpServerInnerConfig::Command(stdio) => Ok(McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command: stdio.command,
|
||||
args: stdio.args.unwrap_or_default(),
|
||||
env: stdio.env.unwrap_or_default(),
|
||||
}),
|
||||
McpServerInnerConfig::Url(url_config) => {
|
||||
let url = url_config
|
||||
.get_url()
|
||||
.ok_or_else(|| anyhow::anyhow!("URL 配置缺少 url 或 baseUrl 字段"))?
|
||||
.to_string();
|
||||
|
||||
// 解析协议类型
|
||||
let protocol =
|
||||
url_config
|
||||
.r#type
|
||||
.as_ref()
|
||||
.and_then(|t| match t.to_ascii_lowercase().as_str() {
|
||||
"sse" => Some(crate::client::protocol::McpProtocol::Sse),
|
||||
"http" | "stream" | "streamablehttp" | "streamable-http"
|
||||
| "streamable_http" => Some(crate::client::protocol::McpProtocol::Stream),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// 合并 headers:JSON 配置中的 auth_token -> Authorization
|
||||
let mut headers = url_config.headers.clone().unwrap_or_default();
|
||||
if let Some(auth_token) = &url_config.auth_token {
|
||||
headers.insert("Authorization".to_string(), auth_token.clone());
|
||||
}
|
||||
|
||||
Ok(McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
protocol,
|
||||
headers,
|
||||
timeout: url_config.timeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并 headers:JSON 配置 + 命令行参数(命令行优先)
|
||||
pub fn merge_headers(
|
||||
config_headers: HashMap<String, String>,
|
||||
cli_headers: &[(String, String)],
|
||||
cli_auth: Option<&String>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut merged = config_headers;
|
||||
|
||||
// 命令行 -H 参数覆盖配置
|
||||
for (key, value) in cli_headers {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// 命令行 --auth 参数优先级最高
|
||||
if let Some(auth_value) = cli_auth {
|
||||
merged.insert("Authorization".to_string(), auth_value.clone());
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
628
qiming-mcp-proxy/mcp-proxy/src/client/support/config_tests.rs
Normal file
628
qiming-mcp-proxy/mcp-proxy/src/client/support/config_tests.rs
Normal file
@@ -0,0 +1,628 @@
|
||||
//! 配置解析单元测试
|
||||
//!
|
||||
//! 测试各种 MCP JSON 配置格式的解析
|
||||
|
||||
use super::args::{ConvertArgs, LoggingArgs};
|
||||
use super::config::{McpConfigSource, parse_convert_config};
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_parsing_tests {
|
||||
use super::*;
|
||||
|
||||
/// 测试 1: 解析简单的本地命令配置(command 类型)
|
||||
#[test]
|
||||
fn test_parse_simple_command_config() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"test-service": {
|
||||
"command": "node",
|
||||
"args": ["server.js"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok(), "解析应该成功");
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command,
|
||||
args,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "test-service");
|
||||
assert_eq!(command, "node");
|
||||
assert_eq!(args, vec!["server.js"]);
|
||||
}
|
||||
_ => panic!("应该解析为 LocalCommand 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 2: 解析带环境变量的本地命令配置
|
||||
#[test]
|
||||
fn test_parse_command_config_with_env() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"my-service": {
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"env": {
|
||||
"API_KEY": "test-key",
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::LocalCommand {
|
||||
name,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
assert_eq!(name, "my-service");
|
||||
assert_eq!(command, "python");
|
||||
assert_eq!(args, vec!["-m", "mcp_server"]);
|
||||
assert_eq!(env.get("API_KEY"), Some(&"test-key".to_string()));
|
||||
assert_eq!(env.get("DEBUG"), Some(&"true".to_string()));
|
||||
}
|
||||
_ => panic!("应该解析为 LocalCommand 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 3: 解析远程 URL 配置(使用 baseUrl)
|
||||
#[test]
|
||||
fn test_parse_remote_service_config_baseurl() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"remote-service": {
|
||||
"baseUrl": "https://api.example.com/mcp",
|
||||
"headers": {
|
||||
"X-Custom-Header": "custom-value"
|
||||
},
|
||||
"authToken": "Bearer token123",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
headers,
|
||||
timeout,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "remote-service");
|
||||
assert_eq!(url, "https://api.example.com/mcp");
|
||||
assert_eq!(
|
||||
headers.get("X-Custom-Header"),
|
||||
Some(&"custom-value".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("Authorization"),
|
||||
Some(&"Bearer token123".to_string())
|
||||
);
|
||||
assert_eq!(timeout, Some(30));
|
||||
}
|
||||
_ => panic!("应该解析为 RemoteService 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 4: 解析远程 URL 配置(使用 url 字段)
|
||||
#[test]
|
||||
fn test_parse_remote_service_config_url() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"sse-service": {
|
||||
"url": "https://api.example.com/sse",
|
||||
"type": "sse"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::RemoteService {
|
||||
name,
|
||||
url,
|
||||
protocol,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(name, "sse-service");
|
||||
assert_eq!(url, "https://api.example.com/sse");
|
||||
assert_eq!(format!("{:?}", protocol), "Some(Sse)"); // SSE 协议
|
||||
}
|
||||
_ => panic!("应该解析为 RemoteService 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 5: 多服务配置,使用 --name 参数选择
|
||||
#[test]
|
||||
fn test_parse_multi_server_config_with_name() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"service-a": {
|
||||
"command": "node",
|
||||
"args": ["a.js"]
|
||||
},
|
||||
"service-b": {
|
||||
"command": "python",
|
||||
"args": ["b.py"]
|
||||
},
|
||||
"service-c": {
|
||||
"baseUrl": "https://api.example.com/mcp"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: Some("service-b".to_string()),
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::LocalCommand { name, command, .. } => {
|
||||
assert_eq!(name, "service-b");
|
||||
assert_eq!(command, "python");
|
||||
}
|
||||
_ => panic!("应该解析为 LocalCommand 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 6: 多服务配置,未指定 --name 参数应该失败
|
||||
#[test]
|
||||
fn test_parse_multi_server_config_without_name_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"service-a": {
|
||||
"command": "node"
|
||||
},
|
||||
"service-b": {
|
||||
"command": "python"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None, // 未指定 name
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "多服务配置未指定 --name 应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("请使用 --name 指定"),
|
||||
"错误消息应该提示使用 --name"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 7: 指定的服务不存在应该失败
|
||||
#[test]
|
||||
fn test_parse_nonexistent_service_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"service-a": {
|
||||
"command": "node"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: Some("nonexistent".to_string()), // 不存在的服务
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "不存在的服务应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("'nonexistent' 不存在"),
|
||||
"错误消息应该提示服务不存在"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 8: 空配置应该失败
|
||||
#[test]
|
||||
fn test_parse_empty_config_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "空配置应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("没有找到任何 MCP 服务"),
|
||||
"错误消息应该提示没有服务"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 9: URL 配置缺少 url 和 baseUrl 应该失败
|
||||
#[test]
|
||||
fn test_parse_url_config_without_url_should_fail() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"invalid-service": {
|
||||
"type": "sse"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "缺少 URL 的配置应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("缺少 url 或 baseUrl"),
|
||||
"错误消息应该提示缺少 URL"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 10: 直接 URL 模式(不使用 JSON 配置)
|
||||
#[test]
|
||||
fn test_parse_direct_url_mode() {
|
||||
let args = ConvertArgs {
|
||||
url: Some("https://api.example.com/mcp".to_string()),
|
||||
config: None,
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::DirectUrl { url } => {
|
||||
assert_eq!(url, "https://api.example.com/mcp");
|
||||
}
|
||||
_ => panic!("应该解析为 DirectUrl 类型"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 11: 既没有 URL 也没有配置应该失败
|
||||
#[test]
|
||||
fn test_parse_no_url_no_config_should_fail() {
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: None,
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "既没有 URL 也没有配置应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("必须提供 URL"),
|
||||
"错误消息应该提示需要提供配置"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 12: 无效的 JSON 应该失败
|
||||
#[test]
|
||||
fn test_parse_invalid_json_should_fail() {
|
||||
let invalid_json = r#"{
|
||||
"mcpServers": {
|
||||
"test": {
|
||||
"command": "node"
|
||||
}
|
||||
// 缺少闭合括号
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(invalid_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_err(), "无效的 JSON 应该失败");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("配置解析失败"),
|
||||
"错误消息应该提示解析失败"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试 13: Stream 协议类型解析
|
||||
#[test]
|
||||
fn test_parse_stream_protocol_type() {
|
||||
let config_json = r#"{
|
||||
"mcpServers": {
|
||||
"stream-service": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"type": "stream"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let args = ConvertArgs {
|
||||
url: None,
|
||||
config: Some(config_json.to_string()),
|
||||
config_file: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
auth: None,
|
||||
header: vec![],
|
||||
retries: 0,
|
||||
allow_tools: None,
|
||||
deny_tools: None,
|
||||
ping_interval: 30,
|
||||
ping_timeout: 10,
|
||||
logging: LoggingArgs {
|
||||
diagnostic: true,
|
||||
log_dir: None,
|
||||
log_file: None,
|
||||
otlp_endpoint: None,
|
||||
service_name: "mcp-proxy".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = parse_convert_config(&args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let config_source = result.unwrap();
|
||||
match config_source {
|
||||
McpConfigSource::RemoteService { protocol, .. } => {
|
||||
assert_eq!(format!("{:?}", protocol), "Some(Stream)");
|
||||
}
|
||||
_ => panic!("应该解析为 RemoteService 类型"),
|
||||
}
|
||||
}
|
||||
}
|
||||
211
qiming-mcp-proxy/mcp-proxy/src/client/support/diagnostic.rs
Normal file
211
qiming-mcp-proxy/mcp-proxy/src/client/support/diagnostic.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
//! 诊断和错误处理
|
||||
//!
|
||||
//! 提供错误分类、诊断报告生成等功能
|
||||
|
||||
/// 错误分类
|
||||
pub fn classify_error(e: &anyhow::Error) -> String {
|
||||
let err_str = e.to_string().to_lowercase();
|
||||
|
||||
// 特殊识别 30 秒超时(可能是服务器限制)
|
||||
if (err_str.contains("30") || err_str.contains("thirty"))
|
||||
&& (err_str.contains("timeout") || err_str.contains("second") || err_str.contains("秒"))
|
||||
{
|
||||
"30-second timeout".to_string()
|
||||
}
|
||||
// 识别 503 服务不可用
|
||||
else if err_str.contains("503") || err_str.contains("service unavailable") {
|
||||
"503 Service Unavailable".to_string()
|
||||
}
|
||||
// 识别其他 HTTP 5xx 错误
|
||||
else if err_str.contains("500") || err_str.contains("internal server error") {
|
||||
"500 Internal Server Error".to_string()
|
||||
} else if err_str.contains("502") || err_str.contains("bad gateway") {
|
||||
"502 Bad Gateway".to_string()
|
||||
} else if err_str.contains("504") || err_str.contains("gateway timeout") {
|
||||
"504 Gateway Timeout".to_string()
|
||||
}
|
||||
// 识别 HTTP 4xx 错误
|
||||
else if err_str.contains("401") || err_str.contains("unauthorized") {
|
||||
"401 Unauthorized".to_string()
|
||||
} else if err_str.contains("403") || err_str.contains("forbidden") {
|
||||
"403 Forbidden".to_string()
|
||||
} else if err_str.contains("404") || err_str.contains("not found") {
|
||||
"404 Not Found".to_string()
|
||||
} else if err_str.contains("408") || err_str.contains("request timeout") {
|
||||
"408 Request Timeout".to_string()
|
||||
}
|
||||
// 通用超时
|
||||
else if err_str.contains("timeout") || err_str.contains("timed out") {
|
||||
"Timeout".to_string()
|
||||
}
|
||||
// 连接相关错误
|
||||
else if err_str.contains("connection refused") {
|
||||
"Connection Refused".to_string()
|
||||
} else if err_str.contains("connection reset") {
|
||||
"Connection Reset".to_string()
|
||||
} else if err_str.contains("eof") || err_str.contains("closed") || err_str.contains("shutdown")
|
||||
{
|
||||
"Connection Closed".to_string()
|
||||
}
|
||||
// 网络相关错误
|
||||
else if err_str.contains("dns") || err_str.contains("resolve") {
|
||||
"DNS Resolution Failed".to_string()
|
||||
} else if err_str.contains("certificate") || err_str.contains("ssl") || err_str.contains("tls")
|
||||
{
|
||||
"SSL/TLS Error".to_string()
|
||||
} else if err_str.contains("sending request") || err_str.contains("network") {
|
||||
"Network Error".to_string()
|
||||
}
|
||||
// 会话相关
|
||||
else if err_str.contains("session") {
|
||||
"Session Error".to_string()
|
||||
} else {
|
||||
"Unknown Error".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化错误信息(用于单行日志)
|
||||
pub fn summarize_error(e: &anyhow::Error) -> String {
|
||||
let full = e.to_string();
|
||||
// 截取第一行或前80个字符
|
||||
let first_line = full.lines().next().unwrap_or(&full);
|
||||
// 使用 chars() 安全处理 UTF-8 字符,避免在多字节字符中间截断
|
||||
if first_line.chars().count() > 80 {
|
||||
format!("{}...", first_line.chars().take(77).collect::<String>())
|
||||
} else {
|
||||
first_line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成诊断报告
|
||||
pub fn print_diagnostic_report(
|
||||
protocol: &str,
|
||||
url: &str,
|
||||
alive_duration_secs: u64,
|
||||
disconnect_reason: &str,
|
||||
error_type: Option<&str>,
|
||||
diagnostic: bool,
|
||||
) {
|
||||
if !diagnostic {
|
||||
return;
|
||||
}
|
||||
|
||||
eprintln!("\n=== Connection Diagnostic Report ===");
|
||||
eprintln!("Protocol: {protocol}");
|
||||
|
||||
// 隐藏 URL 中的敏感信息(如 token/ak/key/secret 参数)
|
||||
let masked_url = if url.contains("?") {
|
||||
let parts: Vec<&str> = url.split('?').collect();
|
||||
if parts.len() == 2 {
|
||||
let base = parts[0];
|
||||
let params: Vec<&str> = parts[1].split('&').collect();
|
||||
let masked_params: Vec<String> = params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let lower = p.to_lowercase();
|
||||
let key_part = lower.split('=').next().unwrap_or("");
|
||||
if key_part.contains("key")
|
||||
|| key_part.contains("token")
|
||||
|| key_part.contains("secret")
|
||||
|| key_part.contains("auth")
|
||||
|| key_part.contains("password")
|
||||
|| key_part.contains("passwd")
|
||||
|| key_part.contains("credential")
|
||||
|| key_part == "ak"
|
||||
|| key_part == "sk"
|
||||
{
|
||||
let original_key = p.split('=').next().unwrap_or("");
|
||||
format!("{}=***", original_key)
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{}?{}", base, masked_params.join("&"))
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
|
||||
eprintln!("Service URL: {masked_url}");
|
||||
eprintln!("Connection duration: {}s", alive_duration_secs);
|
||||
eprintln!("Disconnect reason: {disconnect_reason}");
|
||||
|
||||
if let Some(err_type) = error_type {
|
||||
eprintln!("Error type: {err_type}");
|
||||
}
|
||||
|
||||
// 分析可能的原因
|
||||
eprintln!("\nPossible causes:");
|
||||
if (28..=32).contains(&alive_duration_secs) {
|
||||
eprintln!(" Connection dropped around 30 seconds:");
|
||||
eprintln!(" 1. The backend may enforce a fixed session timeout");
|
||||
eprintln!(" 2. A load balancer or gateway may be closing idle connections");
|
||||
eprintln!(" 3. Keepalive/ping settings may be too weak for this environment");
|
||||
} else if alive_duration_secs < 10 {
|
||||
eprintln!(" Quick disconnect ({}s):", alive_duration_secs);
|
||||
eprintln!(" 1. Service may be unavailable or misconfigured");
|
||||
eprintln!(" 2. Authentication/headers may be invalid");
|
||||
eprintln!(" 3. URL/path may point to a non-MCP endpoint");
|
||||
} else if alive_duration_secs >= 60 {
|
||||
eprintln!(" Long-lived connection ({}s):", alive_duration_secs);
|
||||
eprintln!(" 1. Disconnect may be caused by transient network instability");
|
||||
eprintln!(" 2. Backend restarts or rolling deployments may interrupt sessions");
|
||||
}
|
||||
|
||||
let timeout_30s = "30-second timeout";
|
||||
let service_unavailable = "503 Service Unavailable";
|
||||
|
||||
if error_type == Some(timeout_30s) || error_type == Some(service_unavailable) {
|
||||
eprintln!("\nSuggestions:");
|
||||
eprintln!(" 1. Increase backend/read timeout settings");
|
||||
eprintln!(" 2. Increase client ping timeout if the backend is slow");
|
||||
eprintln!(" 3. Consider async/task-based invocation patterns");
|
||||
eprintln!(" 4. Increase ping interval to {}s to reduce pressure", 120);
|
||||
} else if disconnect_reason.contains("Ping") || disconnect_reason.contains("ping") {
|
||||
eprintln!("\nSuggestions:");
|
||||
eprintln!(" 1. Increase ping timeout to {}s", 30);
|
||||
eprintln!(" 2. Increase ping interval to {}s", 60);
|
||||
eprintln!(" 3. Disable ping if the backend does not support stable probes");
|
||||
}
|
||||
|
||||
eprintln!("==============================\n");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
|
||||
#[test]
|
||||
fn test_classify_error() {
|
||||
assert_eq!(classify_error(&anyhow!("connection timeout")), "Timeout");
|
||||
assert_eq!(
|
||||
classify_error(&anyhow!("connection refused")),
|
||||
"Connection Refused"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error(&anyhow!("503 Service Unavailable")),
|
||||
"503 Service Unavailable"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error(&anyhow!("401 Unauthorized")),
|
||||
"401 Unauthorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_error() {
|
||||
let short_err = anyhow!("short error");
|
||||
assert_eq!(summarize_error(&short_err), "short error");
|
||||
|
||||
let long_err = anyhow!(
|
||||
"this is a very long error message that exceeds eighty characters and should be truncated"
|
||||
);
|
||||
let summary = summarize_error(&long_err);
|
||||
assert!(summary.len() <= 80);
|
||||
assert!(summary.ends_with("..."));
|
||||
}
|
||||
}
|
||||
268
qiming-mcp-proxy/mcp-proxy/src/client/support/logging.rs
Normal file
268
qiming-mcp-proxy/mcp-proxy/src/client/support/logging.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! 日志系统初始化
|
||||
//!
|
||||
//! 处理日志文件的创建、日志级别配置和 OpenTelemetry 追踪初始化
|
||||
|
||||
use anyhow::Result;
|
||||
use mcp_common::{TracingConfig, TracingGuard};
|
||||
use once_cell::sync::OnceCell;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
use super::args::{ConvertArgs, LoggingArgs};
|
||||
|
||||
/// 全局追踪守卫,保持 OTLP exporter 存活
|
||||
static TRACING_GUARD: OnceCell<TracingGuard> = OnceCell::new();
|
||||
|
||||
/// 初始化日志系统
|
||||
/// 只有在 diagnostic=true 时才会创建日志文件并输出详细日志
|
||||
pub fn init_logging(
|
||||
args: &ConvertArgs,
|
||||
mcp_name: Option<&str>,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
init_logging_with_config(&args.logging, mcp_name, quiet, verbose)
|
||||
}
|
||||
|
||||
/// 使用日志配置初始化日志系统(更通用的接口)
|
||||
///
|
||||
/// 这个函数可以被任何需要日志初始化的地方调用,不依赖完整的 ConvertArgs
|
||||
///
|
||||
/// # 功能
|
||||
///
|
||||
/// - 根据 `diagnostic` 参数控制日志级别
|
||||
/// - 支持日志文件输出
|
||||
/// - 支持 OTLP 追踪(当配置了 `otlp_endpoint` 时)
|
||||
pub fn init_logging_with_config(
|
||||
logging: &LoggingArgs,
|
||||
mcp_name: Option<&str>,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
// 检查是否需要启用 OTLP 追踪
|
||||
let enable_otlp = logging.diagnostic && logging.otlp_endpoint.is_some();
|
||||
|
||||
// 如果启用 OTLP,先初始化 tracer provider
|
||||
if enable_otlp {
|
||||
init_otlp_tracing(logging, mcp_name, quiet)?;
|
||||
}
|
||||
|
||||
// 只有在 diagnostic=true 或用户明确指定日志文件时才创建日志文件
|
||||
let log_file_path = determine_log_file_path(logging, mcp_name)?;
|
||||
|
||||
// 根据不同的配置组合初始化 subscriber
|
||||
match (log_file_path, enable_otlp) {
|
||||
(Some(file_path), true) => {
|
||||
init_with_file_and_otlp(logging, &file_path, quiet, verbose)?;
|
||||
}
|
||||
(Some(file_path), false) => {
|
||||
init_with_file_only(logging, &file_path, quiet, verbose)?;
|
||||
}
|
||||
(None, true) => {
|
||||
init_stderr_with_otlp(logging, quiet, verbose)?;
|
||||
}
|
||||
(None, false) => {
|
||||
init_stderr_only(logging, quiet, verbose)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确定日志文件路径
|
||||
fn determine_log_file_path(
|
||||
logging: &LoggingArgs,
|
||||
mcp_name: Option<&str>,
|
||||
) -> Result<Option<std::path::PathBuf>> {
|
||||
if let Some(log_file) = &logging.log_file {
|
||||
// 手动指定文件
|
||||
Ok(Some(log_file.clone()))
|
||||
} else if let Some(log_dir) = &logging.log_dir {
|
||||
// 指定日志目录
|
||||
let session_id = generate_session_id();
|
||||
let date = chrono::Local::now().format("%Y%m%d");
|
||||
let name_part = mcp_name.unwrap_or("unknown");
|
||||
let filename = format!("mcp-proxy-{}-{}-{}.log", name_part, date, session_id);
|
||||
|
||||
std::fs::create_dir_all(log_dir)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create log directory: {}", e))?;
|
||||
Ok(Some(log_dir.join(filename)))
|
||||
} else if logging.diagnostic {
|
||||
// diagnostic=true 时,使用系统临时目录
|
||||
let session_id = generate_session_id();
|
||||
let date = chrono::Local::now().format("%Y%m%d");
|
||||
let name_part = mcp_name.unwrap_or("unknown");
|
||||
let filename = format!("mcp-proxy-{}-{}-{}.log", name_part, date, session_id);
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_path = temp_dir.join(filename);
|
||||
|
||||
// 尝试创建文件,验证目录可写
|
||||
std::fs::File::create(&file_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create log file: {} (path: {})",
|
||||
e,
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Some(file_path))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建日志过滤器
|
||||
fn create_filter(logging: &LoggingArgs, verbose: bool) -> EnvFilter {
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
if logging.diagnostic {
|
||||
EnvFilter::new("debug")
|
||||
} else if verbose {
|
||||
EnvFilter::new("info")
|
||||
} else {
|
||||
EnvFilter::new("warn")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 初始化:文件 + OTLP
|
||||
fn init_with_file_and_otlp(
|
||||
logging: &LoggingArgs,
|
||||
file_path: &std::path::Path,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
let file = std::fs::File::create(file_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create log file: {}", e))?;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("📝 Log file: {}", file_path.display());
|
||||
eprintln!(
|
||||
"📋 Diagnostic mode: {} (log level: {})",
|
||||
if logging.diagnostic {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
if logging.diagnostic { "DEBUG" } else { "WARN" }
|
||||
);
|
||||
}
|
||||
|
||||
let file_shared = std::sync::Arc::new(file);
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(std::sync::Mutex::new(file_shared.clone()))
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(fmt::layer().with_writer(std::io::stderr).with_ansi(true))
|
||||
.with(tracing_opentelemetry::layer())
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化:仅文件
|
||||
fn init_with_file_only(
|
||||
logging: &LoggingArgs,
|
||||
file_path: &std::path::Path,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
let file = std::fs::File::create(file_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create log file: {}", e))?;
|
||||
|
||||
if !quiet {
|
||||
eprintln!("📝 Log file: {}", file_path.display());
|
||||
eprintln!(
|
||||
"📋 Diagnostic mode: {} (log level: {})",
|
||||
if logging.diagnostic {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
if logging.diagnostic { "DEBUG" } else { "WARN" }
|
||||
);
|
||||
}
|
||||
|
||||
let file_shared = std::sync::Arc::new(file);
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_writer(std::sync::Mutex::new(file_shared.clone()))
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(fmt::layer().with_writer(std::io::stderr).with_ansi(true))
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化:stderr + OTLP
|
||||
fn init_stderr_with_otlp(logging: &LoggingArgs, quiet: bool, verbose: bool) -> Result<()> {
|
||||
if !quiet && !logging.diagnostic {
|
||||
eprintln!("📋 Diagnostic mode: disabled (no log file will be created)");
|
||||
}
|
||||
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().with_writer(std::io::stderr))
|
||||
.with(tracing_opentelemetry::layer())
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化:仅 stderr
|
||||
fn init_stderr_only(logging: &LoggingArgs, quiet: bool, verbose: bool) -> Result<()> {
|
||||
if !quiet && !logging.diagnostic {
|
||||
eprintln!("📋 Diagnostic mode: disabled (no log file will be created)");
|
||||
}
|
||||
|
||||
let filter = create_filter(logging, verbose);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化 OTLP 追踪(Jaeger 等)
|
||||
fn init_otlp_tracing(logging: &LoggingArgs, mcp_name: Option<&str>, quiet: bool) -> Result<()> {
|
||||
if let Some(endpoint) = &logging.otlp_endpoint {
|
||||
let service_name = mcp_name.unwrap_or(&logging.service_name);
|
||||
|
||||
let config = TracingConfig::new(service_name)
|
||||
.with_otlp(endpoint)
|
||||
.with_version(env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let guard = mcp_common::init_tracing(&config)?;
|
||||
|
||||
// 保存到全局静态变量,确保 guard 在程序运行期间保持存活
|
||||
let _ = TRACING_GUARD.set(guard);
|
||||
|
||||
if !quiet {
|
||||
eprintln!("🔭 OTLP tracing: enabled");
|
||||
eprintln!(" Endpoint: {}", endpoint);
|
||||
eprintln!(" Service: {}", service_name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 生成随机会话 ID(8 位十六进制)
|
||||
pub fn generate_session_id() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
format!("{:08x}", rng.random::<u32>())
|
||||
}
|
||||
19
qiming-mcp-proxy/mcp-proxy/src/client/support/mod.rs
Normal file
19
qiming-mcp-proxy/mcp-proxy/src/client/support/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! 支持功能模块
|
||||
//!
|
||||
//! 提供配置、日志、工具函数等基础设施支持
|
||||
|
||||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod diagnostic;
|
||||
pub mod logging;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests;
|
||||
|
||||
// 导出常用类型
|
||||
pub use args::{CheckArgs, ConvertArgs, DetectArgs, HealthArgs, LoggingArgs};
|
||||
pub use config::{McpConfigSource, merge_headers, parse_convert_config};
|
||||
pub use diagnostic::{classify_error, print_diagnostic_report, summarize_error};
|
||||
pub use logging::{init_logging, init_logging_with_config};
|
||||
pub use utils::{protocol_name, truncate_str};
|
||||
40
qiming-mcp-proxy/mcp-proxy/src/client/support/utils.rs
Normal file
40
qiming-mcp-proxy/mcp-proxy/src/client/support/utils.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! 通用工具函数
|
||||
|
||||
/// 获取协议名称
|
||||
pub fn protocol_name(protocol: &crate::client::protocol::McpProtocol) -> &'static str {
|
||||
match protocol {
|
||||
crate::client::protocol::McpProtocol::Sse => "SSE",
|
||||
crate::client::protocol::McpProtocol::Stream => "Streamable HTTP",
|
||||
crate::client::protocol::McpProtocol::Stdio => "Stdio",
|
||||
}
|
||||
}
|
||||
|
||||
/// 截断字符串(UTF-8 安全)
|
||||
pub fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
if s.chars().count() > max_len {
|
||||
format!("{}...", s.chars().take(max_len - 3).collect::<String>())
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::protocol::McpProtocol;
|
||||
|
||||
#[test]
|
||||
fn test_protocol_name() {
|
||||
assert_eq!(protocol_name(&McpProtocol::Sse), "SSE");
|
||||
assert_eq!(protocol_name(&McpProtocol::Stream), "Streamable HTTP");
|
||||
assert_eq!(protocol_name(&McpProtocol::Stdio), "Stdio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_str() {
|
||||
assert_eq!(truncate_str("hello", 10), "hello");
|
||||
assert_eq!(truncate_str("hello world", 8), "hello...");
|
||||
assert_eq!(truncate_str("你好世界", 3), "...");
|
||||
assert_eq!(truncate_str("你好世界", 6), "你好世界");
|
||||
}
|
||||
}
|
||||
874
qiming-mcp-proxy/mcp-proxy/src/client/tests.rs
Normal file
874
qiming-mcp-proxy/mcp-proxy/src/client/tests.rs
Normal file
@@ -0,0 +1,874 @@
|
||||
// MCP 客户端模块测试 - 集成测试
|
||||
|
||||
// ============== 共享测试工具 ==============
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_helpers {
|
||||
use serde_json::json;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 测试端口分配
|
||||
pub const TEST_PORT_INTEGRATION: u16 = 19880; // integration_tests 使用
|
||||
pub const TEST_PORT_PROTOCOL: u16 = 19881; // protocol detection 使用
|
||||
pub const TEST_PORT_RECONNECT: u16 = 19876; // reconnection_tests 使用
|
||||
|
||||
/// 获取预编译的 test_mcp_server 二进制路径
|
||||
///
|
||||
/// 注意:test_mcp_server 现在是 mcp-sse-proxy 的 example,
|
||||
/// 编译输出在 target/debug/examples/test_mcp_server
|
||||
pub fn get_test_mcp_server_path() -> String {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace_root = std::path::Path::new(manifest_dir).parent().unwrap();
|
||||
workspace_root
|
||||
.join("target/debug/examples/test_mcp_server")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 获取预编译的 mcp-proxy 二进制路径
|
||||
pub fn get_mcp_proxy_path() -> String {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace_root = std::path::Path::new(manifest_dir).parent().unwrap();
|
||||
workspace_root
|
||||
.join("target/debug/mcp-proxy")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 创建 MCP 服务配置 JSON(使用预编译二进制)
|
||||
pub fn create_test_config() -> String {
|
||||
let binary_path = get_test_mcp_server_path();
|
||||
json!({
|
||||
"mcpServers": {
|
||||
"test-server": {
|
||||
"command": binary_path,
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 启动 proxy 服务器
|
||||
pub async fn spawn_proxy_server(port: u16) -> anyhow::Result<Child> {
|
||||
let config = create_test_config();
|
||||
let mcp_proxy_path = get_mcp_proxy_path();
|
||||
|
||||
let child = Command::new(&mcp_proxy_path)
|
||||
.args([
|
||||
"proxy",
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--config",
|
||||
&config,
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// 等待服务器就绪(TCP 轮询)
|
||||
pub async fn wait_for_server_ready(addr: &str, max_retries: u32) -> anyhow::Result<()> {
|
||||
for i in 0..max_retries {
|
||||
match tokio::net::TcpStream::connect(addr).await {
|
||||
Ok(_) => {
|
||||
println!("✅ Server ready (try #{})", i + 1);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
if i < max_retries - 1 {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("服务器在 {} 次尝试后未就绪", max_retries)
|
||||
}
|
||||
|
||||
/// 启动 convert 客户端进程
|
||||
pub async fn spawn_convert_client(
|
||||
url: &str,
|
||||
ping_interval: u64,
|
||||
ping_timeout: u64,
|
||||
) -> anyhow::Result<Child> {
|
||||
let mcp_proxy_path = get_mcp_proxy_path();
|
||||
|
||||
let child = Command::new(&mcp_proxy_path)
|
||||
.args([
|
||||
"convert",
|
||||
url,
|
||||
"--ping-interval",
|
||||
&ping_interval.to_string(),
|
||||
"--ping-timeout",
|
||||
&ping_timeout.to_string(),
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// 监控 stderr 输出,查找任一日志模式
|
||||
pub async fn wait_for_stderr_patterns(
|
||||
stderr: &mut BufReader<tokio::process::ChildStderr>,
|
||||
patterns: &[&str],
|
||||
timeout_duration: Duration,
|
||||
) -> anyhow::Result<bool> {
|
||||
let result = timeout(timeout_duration, async {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stderr.read_line(&mut line).await {
|
||||
Ok(0) => return false, // EOF
|
||||
Ok(_) => {
|
||||
print!("[stderr] {}", line);
|
||||
if patterns.iter().any(|pattern| line.contains(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(found) => Ok(found),
|
||||
Err(_) => Ok(false), // timeout
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送 JSON-RPC 请求并获取响应
|
||||
pub async fn send_jsonrpc_and_receive(
|
||||
stdin: &mut tokio::process::ChildStdin,
|
||||
stdout: &mut BufReader<tokio::process::ChildStdout>,
|
||||
request: serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let msg = format!("{}\n", serde_json::to_string(&request)?);
|
||||
stdin.write_all(msg.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
let mut response = String::new();
|
||||
stdout.read_line(&mut response).await?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// 初始化 MCP 客户端(发送 initialize + initialized)
|
||||
pub async fn initialize_mcp_client(
|
||||
stdin: &mut tokio::process::ChildStdin,
|
||||
stdout: &mut BufReader<tokio::process::ChildStdout>,
|
||||
) -> anyhow::Result<()> {
|
||||
// 发送 initialize 请求
|
||||
let init_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"roots": {"listChanged": true},
|
||||
"sampling": {}
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "test-client",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let init_response = send_jsonrpc_and_receive(stdin, stdout, init_request).await?;
|
||||
if init_response["error"].is_object() {
|
||||
anyhow::bail!("Initialize failed: {:?}", init_response["error"]);
|
||||
}
|
||||
|
||||
// 发送 initialized 通知
|
||||
let initialized_notification = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
});
|
||||
let msg = format!("{}\n", serde_json::to_string(&initialized_notification)?);
|
||||
stdin.write_all(msg.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
// 等待一下让服务器处理
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 集成测试 ==============
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::test_helpers::*;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::io::BufReader;
|
||||
|
||||
/// 测试本地 MCP 服务连接和通信
|
||||
///
|
||||
/// 使用本地 test-mcp-server + mcp-proxy proxy 进行测试
|
||||
/// 验证完整的 MCP 通信流程:initialize -> tools/list -> tools/call
|
||||
#[tokio::test]
|
||||
async fn test_real_mcp_service_communication() {
|
||||
println!("\\n========== Test: MCP service connection and communication ==========");
|
||||
|
||||
// 1. 启动本地 proxy 服务器
|
||||
println!("🚀 Start local proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_INTEGRATION)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_INTEGRATION);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 等待 proxy 完全初始化后端连接
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// 2. 启动 convert 客户端连接到本地 proxy
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 30, 10)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let mut stdin = client.stdin.take().expect("获取 stdin 失败");
|
||||
let stdout = client.stdout.take().expect("获取 stdout 失败");
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 等待客户端连接成功
|
||||
println!("⏳ Waiting for client to connect...");
|
||||
let connected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&["开始代理转换", "proxying traffic", "Stdio server started"],
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
if !connected {
|
||||
println!("⚠️ No connection success log detected, trying to communicate directly...");
|
||||
// 额外等待以确保连接建立
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// 3. 初始化 MCP 客户端
|
||||
println!("🤝 Initialize MCP client...");
|
||||
initialize_mcp_client(&mut stdin, &mut stdout_reader)
|
||||
.await
|
||||
.expect("初始化失败");
|
||||
|
||||
// 4. 发送 tools/list 请求
|
||||
println!("📋 Get tool list...");
|
||||
let tools_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
});
|
||||
let tools_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request)
|
||||
.await
|
||||
.expect("tools/list 请求失败");
|
||||
|
||||
assert_eq!(tools_response["jsonrpc"], "2.0");
|
||||
assert_eq!(tools_response["id"], 2);
|
||||
assert!(tools_response["result"]["tools"].is_array());
|
||||
|
||||
let tools = tools_response["result"]["tools"].as_array().unwrap();
|
||||
println!("✅ Obtained {} tools", tools.len());
|
||||
|
||||
// 验证本地测试工具存在
|
||||
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
println!("Tool list: {:?}", tool_names);
|
||||
assert!(tool_names.contains(&"echo"), "应该包含 echo 工具");
|
||||
assert!(tool_names.contains(&"increment"), "应该包含 increment 工具");
|
||||
|
||||
// 5. 测试调用 echo 工具
|
||||
println!("🔧 Call the echo tool...");
|
||||
let call_tool_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "echo",
|
||||
"arguments": {
|
||||
"message": "Hello from integration test!"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let call_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, call_tool_request)
|
||||
.await
|
||||
.expect("tools/call 请求失败");
|
||||
|
||||
assert_eq!(call_response["jsonrpc"], "2.0");
|
||||
assert_eq!(call_response["id"], 3);
|
||||
|
||||
// 验证返回结果
|
||||
if call_response["error"].is_object() {
|
||||
panic!("Tool call failed with error: {:?}", call_response["error"]);
|
||||
}
|
||||
|
||||
let result = &call_response["result"];
|
||||
assert!(
|
||||
!result["isError"].as_bool().unwrap_or(true),
|
||||
"echo 调用不应该出错"
|
||||
);
|
||||
assert!(result["content"].is_array(), "Content should be an array");
|
||||
|
||||
let content = result["content"].as_array().unwrap();
|
||||
assert!(!content.is_empty(), "Content should not be empty");
|
||||
|
||||
let first_content = &content[0];
|
||||
assert_eq!(first_content["type"], "text");
|
||||
let text = first_content["text"]
|
||||
.as_str()
|
||||
.expect("Should have text field");
|
||||
assert!(
|
||||
text.contains("Hello from integration test!"),
|
||||
"Should echo our message"
|
||||
);
|
||||
|
||||
println!("✅ Tool call successful! Response: {}", text);
|
||||
|
||||
// 6. 测试调用 increment 工具
|
||||
println!("🔧 Call the increment tool...");
|
||||
let increment_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "increment",
|
||||
"arguments": {}
|
||||
}
|
||||
});
|
||||
|
||||
let increment_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, increment_request)
|
||||
.await
|
||||
.expect("increment 请求失败");
|
||||
|
||||
assert!(
|
||||
!increment_response["result"]["isError"]
|
||||
.as_bool()
|
||||
.unwrap_or(true),
|
||||
"increment 调用不应该出错"
|
||||
);
|
||||
println!("✅ increment call successful");
|
||||
|
||||
// 清理:关闭进程
|
||||
println!("🧹 Cleaning process...");
|
||||
drop(stdin);
|
||||
let _ = client.kill().await;
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test completed ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试协议检测功能
|
||||
///
|
||||
/// 使用本地 mcp-proxy proxy 服务测试协议检测
|
||||
/// 本地 proxy 默认使用 Streamable HTTP 协议
|
||||
#[tokio::test]
|
||||
async fn test_protocol_detection() {
|
||||
println!("\\n========== Test: Protocol Detection ==========");
|
||||
|
||||
// 1. 启动本地 proxy 服务器(Streamable HTTP 模式)
|
||||
println!("🚀 Start local proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_PROTOCOL)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_PROTOCOL);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 测试协议检测
|
||||
println!("🔍Detect protocol type...");
|
||||
let url = format!("http://{}", addr);
|
||||
let protocol = crate::client::protocol::detect_mcp_protocol(&url).await;
|
||||
|
||||
assert!(protocol.is_ok(), "协议检测应该成功");
|
||||
|
||||
let protocol = protocol.unwrap();
|
||||
use crate::client::protocol::McpProtocol;
|
||||
// 本地 proxy 默认使用 Streamable HTTP
|
||||
assert_eq!(
|
||||
protocol,
|
||||
McpProtocol::Stream,
|
||||
"应该检测到 Streamable HTTP 协议"
|
||||
);
|
||||
|
||||
println!("✅ Protocol detected: {:?}", protocol);
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test completed ==========\\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地重连测试模块
|
||||
///
|
||||
/// 使用 `mcp-proxy proxy` 启动本地服务,`mcp-proxy convert` 连接测试
|
||||
/// 验证通道检查和自动重连逻辑
|
||||
#[cfg(test)]
|
||||
mod reconnection_tests {
|
||||
use super::test_helpers::*;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 测试配置
|
||||
const CLIENT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const RECONNECT_DETECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 测试 1: 正常连接和通信
|
||||
///
|
||||
/// 验证:
|
||||
/// - proxy 服务启动正常
|
||||
/// - convert 客户端连接成功
|
||||
/// - tools/list 请求正常响应
|
||||
#[tokio::test]
|
||||
async fn test_reconnection_normal_connection() {
|
||||
println!("\\n========== Test 1: Normal connection and communication ==========");
|
||||
|
||||
// 1. 启动 proxy 服务器
|
||||
println!("🚀 Start proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
// 等待服务器就绪
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_RECONNECT);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 启动 convert 客户端
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 5, 3)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let mut stdin = client.stdin.take().expect("获取 stdin 失败");
|
||||
let stdout = client.stdout.take().expect("获取 stdout 失败");
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 等待客户端连接成功(监控 stderr)
|
||||
println!("⏳ Waiting for client to connect...");
|
||||
let connected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&["连接成功", "Backend connected successfully"],
|
||||
CLIENT_CONNECT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
if !connected {
|
||||
// 可能连接很快,直接尝试初始化
|
||||
println!("⚠️ No connection success log detected, trying to communicate directly...");
|
||||
}
|
||||
|
||||
// 3. 初始化 MCP 客户端
|
||||
println!("🤝 Initialize MCP client...");
|
||||
initialize_mcp_client(&mut stdin, &mut stdout_reader)
|
||||
.await
|
||||
.expect("初始化失败");
|
||||
|
||||
// 4. 发送 tools/list 请求
|
||||
println!("📋 Get tool list...");
|
||||
let tools_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
});
|
||||
let tools_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request)
|
||||
.await
|
||||
.expect("tools/list 请求失败");
|
||||
|
||||
assert!(
|
||||
tools_response["result"]["tools"].is_array(),
|
||||
"应该返回工具列表"
|
||||
);
|
||||
let tools = tools_response["result"]["tools"].as_array().unwrap();
|
||||
println!("✅ Obtained {} tools", tools.len());
|
||||
|
||||
// 验证我们的测试工具存在
|
||||
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
assert!(tool_names.contains(&"echo"), "应该包含 echo 工具");
|
||||
assert!(tool_names.contains(&"increment"), "应该包含 increment 工具");
|
||||
|
||||
// 5. 测试调用 increment 工具
|
||||
println!("🔧 Call the increment tool...");
|
||||
let call_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "increment",
|
||||
"arguments": {}
|
||||
}
|
||||
});
|
||||
let call_response = send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, call_request)
|
||||
.await
|
||||
.expect("tools/call 请求失败");
|
||||
|
||||
assert!(
|
||||
!call_response["result"]["isError"].as_bool().unwrap_or(true),
|
||||
"increment 调用不应该出错"
|
||||
);
|
||||
println!("✅ increment call successful");
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
drop(stdin);
|
||||
let _ = client.kill().await;
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test 1 Complete ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试 2: 服务器重启后自动重连
|
||||
///
|
||||
/// 验证:
|
||||
/// - 杀死 proxy 服务后,convert 检测到断开
|
||||
/// - 重启 proxy 后,convert 自动重连
|
||||
/// - 重连后功能正常
|
||||
#[tokio::test]
|
||||
async fn test_reconnection_on_server_restart() {
|
||||
println!("\\n========== Test 2: Automatically reconnect after server restart ==========");
|
||||
|
||||
// 1. 启动 proxy 服务器
|
||||
println!("🚀 Start proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT + 1)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_RECONNECT + 1);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 启动 convert 客户端(短 ping 间隔以快速检测断开)
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 2, 2)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 等待客户端连接成功
|
||||
println!("⏳ Waiting for client to connect...");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// 3. 杀死 proxy 服务器
|
||||
println!("💀 Kill proxy server...");
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
// 4. 等待客户端检测到断开
|
||||
println!("⏳ Waiting for the client to detect the disconnect...");
|
||||
let disconnected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&["连接断开", "EVENT_DISCONNECTED", "Connection disconnected"],
|
||||
RECONNECT_DETECT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
// 也可能是 "Ping 检测" 或 "后端连接已关闭"
|
||||
if !disconnected {
|
||||
println!(
|
||||
"⚠️ No clear disconnection log detected, check if there are any reconnection attempts..."
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 重启 proxy 服务器
|
||||
println!("🔄 Restart proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT + 1)
|
||||
.await
|
||||
.expect("重启 proxy 失败");
|
||||
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器重启超时");
|
||||
|
||||
// 6. 等待客户端重连成功
|
||||
println!("⏳ Waiting for the client to reconnect...");
|
||||
let reconnected = wait_for_stderr_patterns(
|
||||
&mut stderr_reader,
|
||||
&[
|
||||
"重连成功",
|
||||
"EVENT_RECONNECTED",
|
||||
"Reconnected, proxy service resumed",
|
||||
],
|
||||
RECONNECT_DETECT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.expect("监控 stderr 失败");
|
||||
|
||||
if reconnected {
|
||||
println!("✅ The client has been reconnected successfully");
|
||||
} else {
|
||||
println!(
|
||||
"⚠️ No reconnection success log detected (may have reconnected before timeout)"
|
||||
);
|
||||
}
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
let _ = client.kill().await;
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
println!("========== Test 2 Complete ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试 3: 指数退避验证
|
||||
///
|
||||
/// 验证退避时间递增(1s, 2s, 4s...)
|
||||
#[tokio::test]
|
||||
async fn test_reconnection_exponential_backoff() {
|
||||
println!("\\n========== Test 3: Exponential Backoff Verification ==========");
|
||||
|
||||
// 不启动服务器,直接启动客户端
|
||||
// 客户端应该不断尝试连接并显示退避时间
|
||||
println!("🔗 Start convert client (serverless)...");
|
||||
let url = format!("http://127.0.0.1:{}", TEST_PORT_RECONNECT + 2);
|
||||
let mut client = spawn_convert_client(&url, 30, 10)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 监控退避日志
|
||||
println!("⏳ Monitor the backoff log (wait about 15 seconds)...");
|
||||
let mut backoff_times: Vec<String> = Vec::new();
|
||||
|
||||
let result = timeout(Duration::from_secs(15), async {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stderr_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
print!("[stderr] {}", line);
|
||||
// 查找退避时间日志(兼容中英文/事件标记)
|
||||
if line.contains("秒后重连")
|
||||
|| line.contains("retrying in")
|
||||
|| line.contains("EVENT_RETRY_BACKOFF")
|
||||
{
|
||||
backoff_times.push(line.clone());
|
||||
if backoff_times.len() >= 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
println!("⚠️ Monitoring timeout");
|
||||
}
|
||||
|
||||
println!("📊 Detected backoff log: {:?}", backoff_times);
|
||||
|
||||
// 验证退避时间递增
|
||||
if backoff_times.len() >= 2 {
|
||||
println!("✅ It has been detected that the backoff mechanism is working");
|
||||
} else {
|
||||
println!("⚠️ Not enough backoff logs detected");
|
||||
}
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
let _ = client.kill().await;
|
||||
|
||||
println!("========== Test 3 Complete ==========\\n");
|
||||
}
|
||||
|
||||
/// 测试 4: 通道断开后请求是否立即返回错误
|
||||
///
|
||||
/// 验证:
|
||||
/// - 服务器停止后,客户端发送请求能否立即返回错误
|
||||
/// - 而不是空等超时
|
||||
#[tokio::test]
|
||||
async fn test_request_returns_error_when_connection_closed() {
|
||||
println!(
|
||||
"\\n========== Test 4: The request returns an error immediately after the channel is disconnected =========="
|
||||
);
|
||||
|
||||
// 1. 启动 proxy 服务器
|
||||
println!("🚀 Start proxy server...");
|
||||
let mut proxy = spawn_proxy_server(TEST_PORT_RECONNECT + 3)
|
||||
.await
|
||||
.expect("启动 proxy 失败");
|
||||
|
||||
let addr = format!("127.0.0.1:{}", TEST_PORT_RECONNECT + 3);
|
||||
wait_for_server_ready(&addr, 20)
|
||||
.await
|
||||
.expect("服务器启动超时");
|
||||
|
||||
// 2. 启动 convert 客户端(短 ping 间隔以快速检测断开)
|
||||
println!("🔗 Start convert client...");
|
||||
let url = format!("http://{}", addr);
|
||||
let mut client = spawn_convert_client(&url, 2, 2)
|
||||
.await
|
||||
.expect("启动 convert 失败");
|
||||
|
||||
let mut stdin = client.stdin.take().expect("获取 stdin 失败");
|
||||
let stdout = client.stdout.take().expect("获取 stdout 失败");
|
||||
let stderr = client.stderr.take().expect("获取 stderr 失败");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
|
||||
// 后台监控 stderr
|
||||
let stderr_monitor = tokio::spawn(async move {
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stderr_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
print!("[stderr] {}", line);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 等待连接建立
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// 3. 初始化 MCP 客户端
|
||||
println!("🤝 Initialize MCP client...");
|
||||
initialize_mcp_client(&mut stdin, &mut stdout_reader)
|
||||
.await
|
||||
.expect("初始化失败");
|
||||
|
||||
// 4. 发送第一个 tools/list 请求确认通信正常
|
||||
println!("📋 Send first tools/list request (should succeed)...");
|
||||
let tools_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
});
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let tools_response =
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request)
|
||||
.await
|
||||
.expect("tools/list 请求失败");
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
tools_response["result"]["tools"].is_array(),
|
||||
"第一个请求应该成功返回工具列表"
|
||||
);
|
||||
println!(
|
||||
"✅ The first request was successful and took time: {:?}",
|
||||
elapsed
|
||||
);
|
||||
|
||||
// 5. 杀死 proxy 服务器
|
||||
println!("💀 Kill proxy server...");
|
||||
let _ = proxy.kill().await;
|
||||
|
||||
// 等待 ping 检测发现连接断开(ping 间隔是 2s,超时也是 2s)
|
||||
println!("⏳ Wait for the ping to detect the disconnection (about 5 seconds)...");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// 6. 发送第二个 tools/list 请求
|
||||
println!("📋 Send a second tools/list request (should return an error quickly)...");
|
||||
let tools_request2 = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/list"
|
||||
});
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = timeout(
|
||||
Duration::from_secs(5),
|
||||
send_jsonrpc_and_receive(&mut stdin, &mut stdout_reader, tools_request2),
|
||||
)
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
match result {
|
||||
Ok(Ok(response)) => {
|
||||
// 检查是否是错误响应
|
||||
if response["error"].is_object() {
|
||||
println!("✅ Error response received, time taken: {:?}", elapsed);
|
||||
println!("Error message: {:?}", response["error"]);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"错误响应应该在 3 秒内返回,实际耗时: {:?}",
|
||||
elapsed
|
||||
);
|
||||
} else {
|
||||
// 如果返回了成功响应,说明可能重连了
|
||||
println!(
|
||||
"⚠️ Successful response received (may have been reconnected), time taken: {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
println!(
|
||||
"✅ The request failed (as expected), time taken: {:?}",
|
||||
elapsed
|
||||
);
|
||||
println!("Error: {}", e);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"错误应该在 3 秒内返回,实际耗时: {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
println!(
|
||||
"❌ The request times out (5 seconds), indicating that the client is waiting!"
|
||||
);
|
||||
panic!("请求应该快速返回错误,而不是超时空等");
|
||||
}
|
||||
}
|
||||
|
||||
// 清理
|
||||
println!("🧹 Cleaning process...");
|
||||
drop(stdin);
|
||||
stderr_monitor.abort();
|
||||
let _ = client.kill().await;
|
||||
|
||||
println!("========== Test 4 Complete ==========\\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user