提交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");
|
||||
}
|
||||
}
|
||||
106
qiming-mcp-proxy/mcp-proxy/src/config.rs
Normal file
106
qiming-mcp-proxy/mcp-proxy/src/config.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
/// The default config file
|
||||
const DEFAULT_CONFIG_YAML: &str = include_str!("../config.yml");
|
||||
|
||||
/// config.yml 中 mirror 段的结构
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
pub struct MirrorYamlConfig {
|
||||
#[serde(default)]
|
||||
pub npm_registry: String,
|
||||
#[serde(default)]
|
||||
pub pypi_index_url: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub log: LogConfig,
|
||||
#[serde(default)]
|
||||
pub mirror: MirrorYamlConfig,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
/// The port to listen on for incoming connections
|
||||
pub port: u16,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct LogConfig {
|
||||
/// The log level to use
|
||||
pub level: String,
|
||||
/// The path to the log file
|
||||
pub path: String,
|
||||
/// The number of log files to retain (default: 20)
|
||||
#[serde(default = "default_retain_days")]
|
||||
pub retain_days: u32,
|
||||
}
|
||||
|
||||
/// Default log files to retain
|
||||
fn default_retain_days() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AppConfig {
|
||||
/// Load the config file from the following sources:
|
||||
/// 1. /app/config.yml
|
||||
/// 2. config.yml
|
||||
/// 3. BOT_SERVER_CONFIG environment variable
|
||||
///
|
||||
/// Environment variables can override config values:
|
||||
/// - MCP_PROXY_PORT: Override server port
|
||||
/// - MCP_PROXY_LOG_DIR: Override log directory path
|
||||
/// - MCP_PROXY_LOG_LEVEL: Override log level
|
||||
pub fn load_config() -> Result<Self> {
|
||||
let mut config = match (
|
||||
File::open("/app/config.yml"),
|
||||
File::open("config.yml"),
|
||||
env::var("BOT_SERVER_CONFIG"),
|
||||
) {
|
||||
(Ok(file), _, _) => serde_yaml::from_reader(file),
|
||||
(_, Ok(file), _) => serde_yaml::from_reader(file),
|
||||
(_, _, Ok(file_path)) => serde_yaml::from_reader(File::open(file_path)?),
|
||||
_ => {
|
||||
// 如果都没有,则使用默认配置
|
||||
serde_yaml::from_str::<AppConfig>(DEFAULT_CONFIG_YAML)
|
||||
}
|
||||
}?;
|
||||
|
||||
// 环境变量覆盖配置(优先级最高)
|
||||
if let Ok(port) = env::var("MCP_PROXY_PORT")
|
||||
&& let Ok(port_num) = port.parse::<u16>()
|
||||
{
|
||||
config.server.port = port_num;
|
||||
}
|
||||
|
||||
if let Ok(log_dir) = env::var("MCP_PROXY_LOG_DIR") {
|
||||
config.log.path = log_dir;
|
||||
}
|
||||
|
||||
if let Ok(log_level) = env::var("MCP_PROXY_LOG_LEVEL") {
|
||||
config.log.level = log_level;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn log_path_init(&self) -> Result<()> {
|
||||
let log_path = &self.log.path;
|
||||
|
||||
// 获取日志文件的父目录
|
||||
if let Some(parent) = Path::new(log_path).parent()
|
||||
&& !parent.exists()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
44
qiming-mcp-proxy/mcp-proxy/src/env_init.rs
Normal file
44
qiming-mcp-proxy/mcp-proxy/src/env_init.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! 进程环境初始化
|
||||
//!
|
||||
//! 在 mcp-proxy 启动早期调用,设置进程级环境变量:
|
||||
//! - 镜像源(npm_config_registry / UV_INDEX_URL)
|
||||
|
||||
use crate::config::{AppConfig, MirrorYamlConfig};
|
||||
|
||||
/// 初始化进程环境(镜像源)
|
||||
///
|
||||
/// 在 main() 启动早期、日志初始化前调用。
|
||||
/// 设置的环境变量会被所有子进程(npx/uvx 等)自动继承。
|
||||
pub fn init(app_config: &AppConfig) {
|
||||
// 1. 镜像源配置
|
||||
init_mirror(&app_config.mirror);
|
||||
|
||||
// 2. 汇总诊断日志
|
||||
mcp_common::diagnostic::eprint_env_summary();
|
||||
}
|
||||
|
||||
/// 从 config.yml + 环境变量合并镜像配置,设为进程级环境变量
|
||||
fn init_mirror(yml: &MirrorYamlConfig) {
|
||||
let mut config = mcp_common::mirror::MirrorConfig::from_env();
|
||||
|
||||
// config.yml 非空值作为默认(环境变量优先级更高)
|
||||
if config.npm_registry.is_none() && !yml.npm_registry.is_empty() {
|
||||
config.npm_registry = Some(yml.npm_registry.clone());
|
||||
}
|
||||
if config.pypi_index_url.is_none() && !yml.pypi_index_url.is_empty() {
|
||||
config.pypi_index_url = Some(yml.pypi_index_url.clone());
|
||||
}
|
||||
|
||||
if config.is_empty() {
|
||||
eprintln!(" - Mirror: not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref npm) = config.npm_registry {
|
||||
eprintln!(" - npm registry: {npm}");
|
||||
}
|
||||
if let Some(ref pypi) = config.pypi_index_url {
|
||||
eprintln!(" - PyPI index: {pypi}");
|
||||
}
|
||||
config.apply_to_process_env();
|
||||
}
|
||||
40
qiming-mcp-proxy/mcp-proxy/src/lib.rs
Normal file
40
qiming-mcp-proxy/mcp-proxy/src/lib.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
// 初始化 i18n,使用 crate 内置翻译文件
|
||||
#[macro_use]
|
||||
extern crate rust_i18n;
|
||||
|
||||
// 初始化翻译文件,使用 crate 内置 locales(支持独立发布)
|
||||
i18n!("locales", fallback = "en");
|
||||
|
||||
mod client;
|
||||
mod config;
|
||||
pub mod env_init;
|
||||
mod mcp_error;
|
||||
mod model;
|
||||
mod proxy;
|
||||
mod server;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// 导出基础功能
|
||||
pub use config::AppConfig;
|
||||
pub use mcp_error::AppError;
|
||||
pub use model::{
|
||||
AppState, DynamicRouterService, McpConfig, McpProtocol, McpType, ProxyHandlerManager,
|
||||
get_proxy_manager,
|
||||
};
|
||||
pub use proxy::{McpHandler, ProxyHandler, StreamProxyHandler};
|
||||
pub use proxy::{SseBackendConfig, SseServerBuilder, StreamBackendConfig, StreamServerBuilder};
|
||||
pub use server::{
|
||||
create_telemetry_layer, get_health, get_ready, get_router, init_tracer_provider,
|
||||
log_service_info, mcp_start_task, schedule_check_mcp_live, set_layer, shutdown_telemetry,
|
||||
start_schedule_task,
|
||||
};
|
||||
|
||||
// 导出 CLI 功能
|
||||
pub use client::{Cli, Commands, run_cli};
|
||||
|
||||
// 导出 i18n 功能
|
||||
pub use mcp_common::{current_locale, init_locale_from_env, set_locale, t};
|
||||
|
||||
// 导出用于基准测试的组件
|
||||
pub use server::handlers::run_code_handler::{RunCodeMessageRequest, run_code_handler};
|
||||
355
qiming-mcp-proxy/mcp-proxy/src/main.rs
Normal file
355
qiming-mcp-proxy/mcp-proxy/src/main.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
// Windows 平台配置:隐藏控制台窗口
|
||||
// 当从 Tauri 等 GUI 应用启动时,不显示 CMD 窗口
|
||||
// 注意:这会影响所有 Windows 平台的运行,独立运行时也不会有控制台输出
|
||||
// 日志会写入文件(默认 ./logs/),可以通过日志文件查看运行状态
|
||||
|
||||
mod config;
|
||||
|
||||
use anyhow::Result;
|
||||
use backtrace::Backtrace;
|
||||
use clap::Parser;
|
||||
use log::{error, info, warn};
|
||||
use mcp_stdio_proxy::{
|
||||
AppConfig, AppState, Cli, get_proxy_manager, get_router, init_locale_from_env,
|
||||
init_tracer_provider, log_service_info, run_cli, start_schedule_task,
|
||||
};
|
||||
use run_code_rmcp::warm_up_all_envs;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal;
|
||||
use tracing_appender::rolling::{Builder, Rotation};
|
||||
use tracing_subscriber::layer::SubscriberExt as _;
|
||||
use tracing_subscriber::util::SubscriberInitExt as _;
|
||||
use tracing_subscriber::{EnvFilter, Layer as _};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 在最早期注册 panic hook,确保 panic 信息一定输出到 stderr
|
||||
// 当作为 stdio 子进程运行时(如 mcp-proxy convert),父进程通过 stderr pipe 捕获此输出
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let msg = if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else {
|
||||
"unknown panic".to_string()
|
||||
};
|
||||
let location = info
|
||||
.location()
|
||||
.map(|l| format!(" at {}:{}", l.file(), l.line()))
|
||||
.unwrap_or_default();
|
||||
eprintln!("❌ PANIC{}: {}", location, msg);
|
||||
}));
|
||||
|
||||
// 初始化 Rustls CryptoProvider(必须在任何使用 TLS 的代码之前)
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
// 解析命令行参数
|
||||
let cli = Cli::parse();
|
||||
|
||||
// 如果有子命令,运行 CLI 模式
|
||||
if cli.command.is_some() || cli.url.is_some() {
|
||||
return run_cli_mode(cli).await;
|
||||
}
|
||||
|
||||
// 否则运行传统的服务器模式
|
||||
run_server_mode().await
|
||||
}
|
||||
|
||||
/// 运行 CLI 模式
|
||||
async fn run_cli_mode(cli: Cli) -> Result<()> {
|
||||
// 初始化语言设置
|
||||
init_locale_from_env();
|
||||
|
||||
// 检查是否是需要自定义日志初始化的命令
|
||||
// convert 和 proxy 命令会根据自己的参数(--log-dir、--log-file)初始化日志,所以这里跳过
|
||||
let is_convert_command = matches!(cli.command, Some(mcp_stdio_proxy::Commands::Convert(_)));
|
||||
let is_proxy_command = matches!(cli.command, Some(mcp_stdio_proxy::Commands::Proxy(_)));
|
||||
let is_health_command = matches!(cli.command, Some(mcp_stdio_proxy::Commands::Health(_)));
|
||||
let has_custom_logging = is_convert_command || is_proxy_command;
|
||||
|
||||
// CLI 模式独立的日志配置
|
||||
// 跳过会自己初始化日志的命令,避免重复初始化导致 panic
|
||||
if !has_custom_logging && !cli.quiet {
|
||||
// CLI 模式默认只显示错误,避免 info/debug 日志污染输出
|
||||
let log_level = if cli.verbose {
|
||||
"debug"
|
||||
} else if is_health_command {
|
||||
// health 命令使用更严格的过滤,屏蔽 rmcp 库的噪音日志
|
||||
"off"
|
||||
} else {
|
||||
"error" // 默认只显示错误,屏蔽 info/warn/debug
|
||||
};
|
||||
|
||||
// CLI 模式的日志配置:
|
||||
// 1. 禁用 ANSI 颜色(避免污染 JSON)
|
||||
// 2. 输出到 stderr(stdout 用于 JSON-RPC 通信)
|
||||
// 3. 简化格式(无时间戳、无目标)
|
||||
// 4. 优先使用 RUST_LOG 环境变量,否则使用默认日志级别
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_target(false)
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_writer(std::io::stderr)
|
||||
.compact()
|
||||
.init();
|
||||
}
|
||||
|
||||
// 运行 CLI 命令
|
||||
let result = run_cli(cli).await;
|
||||
if let Err(ref e) = result {
|
||||
// 确保错误信息在进程退出前写到 stderr,方便排查 stdio bridge 启动失败问题
|
||||
eprintln!("❌ CLI command failed: {:?}", e);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 运行传统的服务器模式
|
||||
async fn run_server_mode() -> Result<()> {
|
||||
// 初始化语言设置
|
||||
init_locale_from_env();
|
||||
|
||||
// 配置日志(保持原有的完整日志配置)
|
||||
let app_config = AppConfig::load_config()?;
|
||||
|
||||
// 打印配置信息到 stderr(在日志系统初始化之前)
|
||||
eprintln!("========================================");
|
||||
eprintln!("Starting MCP proxy service...");
|
||||
eprintln!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
eprintln!("Configuration loaded:");
|
||||
eprintln!(" - Port: {}", app_config.server.port);
|
||||
eprintln!(" - Log directory: {}", &app_config.log.path);
|
||||
eprintln!(" - Log level: {}", &app_config.log.level);
|
||||
eprintln!(" - Log retention days: {}", app_config.log.retain_days);
|
||||
mcp_stdio_proxy::env_init::init(&app_config);
|
||||
eprintln!("========================================");
|
||||
|
||||
app_config.log_path_init()?;
|
||||
let log_level = app_config.log.level.clone();
|
||||
let log_path = app_config.log.path.clone();
|
||||
let server_port = app_config.server.port;
|
||||
let retain_days = app_config.log.retain_days;
|
||||
|
||||
// 解析 RUST_LOG 环境变量
|
||||
let log_level_for_console = log_level.clone();
|
||||
let mut console_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level_for_console));
|
||||
|
||||
// 修复 rmcp 库的 span clone panic 问题
|
||||
// 过滤掉 rmcp 的 trace/debug 级别日志,只保留 warn/error,避免 span 生命周期管理问题
|
||||
// see: https://github.com/tokio-rs/tracing/issues/2778
|
||||
console_filter = console_filter
|
||||
.add_directive("rmcp=warn".parse().unwrap())
|
||||
.add_directive("run_code_rmcp=warn".parse().unwrap());
|
||||
|
||||
// 使用 tracing-subscriber 初始化日志记录器
|
||||
let console_layer = tracing_subscriber::fmt::layer()
|
||||
.pretty()
|
||||
.with_writer(std::io::stdout)
|
||||
.with_filter(console_filter);
|
||||
|
||||
// 日志写入到文件,使用 Builder 模式配置日志轮转和保留策略
|
||||
let log_path_for_file = log_path.clone();
|
||||
let file_appender = Builder::new()
|
||||
.rotation(Rotation::DAILY) // 按天滚动
|
||||
.filename_prefix("log") // 文件名前缀
|
||||
.max_log_files(retain_days as usize) // 保留最近 N 个日志文件
|
||||
.build(&log_path_for_file)?;
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let mut log_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level));
|
||||
|
||||
// 修复 rmcp 库的 span clone panic 问题(同样应用于文件日志)
|
||||
log_filter = log_filter
|
||||
.add_directive("rmcp=warn".parse().unwrap())
|
||||
.add_directive("run_code_rmcp=warn".parse().unwrap());
|
||||
|
||||
// 配置文件日志层:使用 compact 格式,避免显示完整的 span 嵌套链,减少日志膨胀
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.with_ansi(false)
|
||||
.with_writer(non_blocking)
|
||||
.with_filter(log_filter);
|
||||
|
||||
// 初始化 OpenTelemetry tracer provider
|
||||
init_tracer_provider("mcp-proxy", "0.1.0")?;
|
||||
|
||||
// 修复 rmcp 库的 span clone panic 问题(同样应用于 OpenTelemetry 层)
|
||||
// 只为 warn 和以上级别创建 span,避免过多的 span 导致 clone 问题
|
||||
let telemetry_filter = EnvFilter::new("warn")
|
||||
.add_directive("mcp_proxy=debug".parse().unwrap())
|
||||
.add_directive("mcp_stdio_proxy=debug".parse().unwrap())
|
||||
.add_directive("rmcp=error".parse().unwrap())
|
||||
.add_directive("run_code_rmcp=error".parse().unwrap());
|
||||
|
||||
// 配置 OpenTelemetry(添加过滤器以避免 span clone panic)
|
||||
let telemetry_layer = tracing_opentelemetry::layer().with_filter(telemetry_filter);
|
||||
|
||||
// 初始化 tracing 订阅器
|
||||
tracing_subscriber::registry()
|
||||
.with(console_layer)
|
||||
.with(file_layer)
|
||||
.with(telemetry_layer)
|
||||
.init();
|
||||
|
||||
// 记录服务信息
|
||||
log_service_info("mcp-proxy", "0.1.0")?;
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting MCP proxy service...");
|
||||
tracing::info!("Running in proxy server mode");
|
||||
tracing::info!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
tracing::info!("Configuration:");
|
||||
tracing::info!(" - Listen port: {}", server_port);
|
||||
tracing::info!(" - Log directory: {}", log_path);
|
||||
tracing::info!(" - Log level: {}", &app_config.log.level);
|
||||
tracing::info!(" - Log retention days: {}", retain_days);
|
||||
tracing::info!("Environment overrides:");
|
||||
if std::env::var("MCP_PROXY_PORT").is_ok() {
|
||||
tracing::info!(" - MCP_PROXY_PORT override detected: {}", server_port);
|
||||
}
|
||||
if let Ok(log_dir) = std::env::var("MCP_PROXY_LOG_DIR") {
|
||||
tracing::info!(" - MCP_PROXY_LOG_DIR override detected: {}", log_dir);
|
||||
}
|
||||
if let Ok(level) = std::env::var("MCP_PROXY_LOG_LEVEL") {
|
||||
tracing::info!(" - MCP_PROXY_LOG_LEVEL override detected: {}", level);
|
||||
}
|
||||
tracing::info!("========================================");
|
||||
|
||||
// 监听地址
|
||||
let addr = format!("0.0.0.0:{server_port}");
|
||||
tracing::info!("Binding to {addr}...");
|
||||
let listener = TcpListener::bind(&addr).await.map_err(|e| {
|
||||
tracing::error!("Failed to bind to {addr}: {e}");
|
||||
e
|
||||
})?;
|
||||
tracing::info!("Successfully bound to {addr}");
|
||||
|
||||
// 构建 axum 路由
|
||||
tracing::info!("Initializing application state...");
|
||||
let state = AppState::new(app_config.clone()).await;
|
||||
tracing::info!("Application state initialized");
|
||||
|
||||
// 初始化 MCP 路由
|
||||
tracing::info!("Initializing MCP router...");
|
||||
let app = get_router(state.clone()).await?;
|
||||
tracing::info!("MCP router initialized");
|
||||
|
||||
info!("MCP proxy started: {addr}");
|
||||
info!("Health endpoint: http://{addr}/health");
|
||||
info!("MCP list endpoint: http://{addr}/mcp/list");
|
||||
|
||||
// 启动定时任务,定期检查MCP服务状态
|
||||
tokio::spawn(start_schedule_task());
|
||||
info!("Background schedule task started");
|
||||
info!("Log rotation configured (keep last {retain_days} files)");
|
||||
|
||||
// 打印系统信息
|
||||
tracing::info!("System info:");
|
||||
tracing::info!(" - OS: {}", std::env::consts::OS);
|
||||
tracing::info!(" - Arch: {}", std::env::consts::ARCH);
|
||||
tracing::info!(
|
||||
" - Working directory: {:?}",
|
||||
std::env::current_dir().unwrap_or_default()
|
||||
);
|
||||
|
||||
// 注册关闭处理函数,确保在程序退出前执行清理
|
||||
tokio::spawn(async move {
|
||||
// 确保在程序退出前执行清理
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
// 记录详细的 panic 信息
|
||||
warn!("Panic hook triggered");
|
||||
|
||||
// 记录 panic 消息
|
||||
if let Some(s) = panic_info.payload().downcast_ref::<String>() {
|
||||
error!("Panic reason: {s}");
|
||||
} else if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||
error!("Panic reason: {s}");
|
||||
} else {
|
||||
error!("Panic reason: <unknown>");
|
||||
}
|
||||
|
||||
// 记录 panic 位置
|
||||
if let Some(location) = panic_info.location() {
|
||||
error!("Panic location: {}:{}", location.file(), location.line());
|
||||
}
|
||||
|
||||
// 尝试获取堆栈跟踪
|
||||
error!("Panic backtrace:");
|
||||
let backtrace = Backtrace::new();
|
||||
error!("{backtrace:?}");
|
||||
}));
|
||||
});
|
||||
|
||||
// 预热 uv/deno 环境依赖
|
||||
tokio::spawn(async move {
|
||||
info!("Warming up uv/deno runtime dependencies...");
|
||||
match warm_up_all_envs(None, None, None, None).await {
|
||||
Ok(_) => info!("Runtime dependency warm-up completed"),
|
||||
Err(e) => error!("Runtime dependency warm-up failed: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器,监听多种信号以实现优雅关闭
|
||||
info!("Starting HTTP server...");
|
||||
let server =
|
||||
axum::serve(listener, app.into_make_service()).with_graceful_shutdown(shutdown_signal());
|
||||
|
||||
// 运行服务器
|
||||
if let Err(e) = server.await {
|
||||
error!("HTTP server exited with error: {e}");
|
||||
}
|
||||
|
||||
// 服务器关闭后执行清理逻辑
|
||||
warn!("Shutdown signal received, starting cleanup...");
|
||||
|
||||
// 清理所有SSE服务
|
||||
match get_proxy_manager().cleanup_all_resources().await {
|
||||
Ok(_) => info!("Resource cleanup completed"),
|
||||
Err(e) => error!("Resource cleanup failed: {e}"),
|
||||
}
|
||||
|
||||
// 等待一小段时间确保所有资源都被清理
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
info!("Shutdown complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 监听多种终止信号
|
||||
async fn shutdown_signal() {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_crypto_provider_install() {
|
||||
// 测试 CryptoProvider 可以正常安装(或已被安装)
|
||||
// 注意:CryptoProvider 全局只能安装一次,多次安装会返回错误
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// 无论首次安装还是已安装,只要能获取到默认 provider 即表示成功
|
||||
let provider = rustls::crypto::CryptoProvider::get_default();
|
||||
assert!(provider.is_some(), "CryptoProvider should be available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crypto_provider_get_default() {
|
||||
// 首先确保 CryptoProvider 已安装(忽略已安装的错误)
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// 测试可以正常获取默认 CryptoProvider
|
||||
let provider = rustls::crypto::CryptoProvider::get_default();
|
||||
assert!(
|
||||
provider.is_some(),
|
||||
"CryptoProvider should be available after installation"
|
||||
);
|
||||
}
|
||||
}
|
||||
245
qiming-mcp-proxy/mcp-proxy/src/mcp_error.rs
Normal file
245
qiming-mcp-proxy/mcp-proxy/src/mcp_error.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use axum::{
|
||||
Json,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use http::StatusCode;
|
||||
use mcp_common::t;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// MCP 应用错误类型
|
||||
///
|
||||
/// 包含错误码和详细错误信息,便于程序化处理
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
/// 服务未找到 (0001)
|
||||
#[error("{0}")]
|
||||
ServiceNotFound(String),
|
||||
|
||||
/// 服务在重启冷却期内 (0002)
|
||||
#[error("{0}")]
|
||||
ServiceRestartCooldown(String),
|
||||
|
||||
/// 服务正在启动中 (0003)
|
||||
#[error("{0}")]
|
||||
ServiceStartupInProgress(String),
|
||||
|
||||
/// 服务启动失败 (0004)
|
||||
#[error("{0}")]
|
||||
ServiceStartupFailed(String),
|
||||
|
||||
/// 后端连接错误 (0005)
|
||||
#[error("{0}")]
|
||||
BackendConnection(String),
|
||||
|
||||
/// 配置解析错误 (0006)
|
||||
#[error("{0}")]
|
||||
ConfigParse(String),
|
||||
|
||||
/// MCP 服务器错误 (0007)
|
||||
#[error("{0}")]
|
||||
McpServerError(String),
|
||||
|
||||
/// JSON 序列化错误 (0008)
|
||||
#[error("{0}")]
|
||||
SerdeJsonError(String),
|
||||
|
||||
/// IO 错误 (0009)
|
||||
#[error("{0}")]
|
||||
IoError(String),
|
||||
|
||||
/// 路由未找到 (0010)
|
||||
#[error("{0}")]
|
||||
RouteNotFound(String),
|
||||
|
||||
/// 无效的请求参数 (0011)
|
||||
#[error("{0}")]
|
||||
InvalidParameter(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
// ===========================================
|
||||
// 工厂方法 - 创建带国际化消息的错误
|
||||
// ===========================================
|
||||
|
||||
/// 创建服务未找到错误
|
||||
pub fn service_not_found(service: impl Into<String>) -> Self {
|
||||
Self::ServiceNotFound(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_not_found",
|
||||
service = service.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建服务重启冷却期错误
|
||||
pub fn service_restart_cooldown(service: impl Into<String>) -> Self {
|
||||
Self::ServiceRestartCooldown(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_restart_cooldown",
|
||||
service = service.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建服务启动中错误
|
||||
pub fn service_startup_in_progress(service: impl Into<String>) -> Self {
|
||||
Self::ServiceStartupInProgress(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_startup_in_progress",
|
||||
service = service.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建服务启动失败错误
|
||||
pub fn service_startup_failed(mcp_id: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
Self::ServiceStartupFailed(
|
||||
t!(
|
||||
"errors.mcp_proxy.service_startup_failed",
|
||||
mcp_id = mcp_id.into(),
|
||||
reason = reason.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建后端连接错误
|
||||
pub fn backend_connection(detail: impl Into<String>) -> Self {
|
||||
Self::BackendConnection(
|
||||
t!(
|
||||
"errors.mcp_proxy.backend_connection",
|
||||
detail = detail.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建配置解析错误
|
||||
pub fn config_parse(detail: impl Into<String>) -> Self {
|
||||
Self::ConfigParse(t!("errors.mcp_proxy.config_parse", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建 MCP 服务器错误
|
||||
pub fn mcp_server_error(detail: impl Into<String>) -> Self {
|
||||
Self::McpServerError(
|
||||
t!("errors.mcp_proxy.mcp_server_error", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 JSON 序列化错误
|
||||
pub fn json_serialization(detail: impl Into<String>) -> Self {
|
||||
Self::SerdeJsonError(
|
||||
t!(
|
||||
"errors.mcp_proxy.json_serialization",
|
||||
detail = detail.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 IO 错误
|
||||
pub fn io_error(detail: impl Into<String>) -> Self {
|
||||
Self::IoError(t!("errors.mcp_proxy.io_error", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建路由未找到错误
|
||||
pub fn route_not_found(path: impl Into<String>) -> Self {
|
||||
Self::RouteNotFound(t!("errors.mcp_proxy.route_not_found", path = path.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建无效参数错误
|
||||
pub fn invalid_parameter(detail: impl Into<String>) -> Self {
|
||||
Self::InvalidParameter(
|
||||
t!("errors.mcp_proxy.invalid_parameter", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// 错误码和状态码
|
||||
// ===========================================
|
||||
|
||||
/// 获取错误码
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ServiceNotFound(_) => "0001",
|
||||
Self::ServiceRestartCooldown(_) => "0002",
|
||||
Self::ServiceStartupInProgress(_) => "0003",
|
||||
Self::ServiceStartupFailed { .. } => "0004",
|
||||
Self::BackendConnection(_) => "0005",
|
||||
Self::ConfigParse(_) => "0006",
|
||||
Self::McpServerError(_) => "0007",
|
||||
Self::SerdeJsonError(_) => "0008",
|
||||
Self::IoError(_) => "0009",
|
||||
Self::RouteNotFound(_) => "0010",
|
||||
Self::InvalidParameter(_) => "0011",
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 HTTP 状态码
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::ServiceNotFound(_) => StatusCode::NOT_FOUND,
|
||||
Self::ServiceRestartCooldown(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::ServiceStartupInProgress(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Self::ServiceStartupFailed { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::BackendConnection(_) => StatusCode::BAD_GATEWAY,
|
||||
Self::ConfigParse(_) => StatusCode::BAD_REQUEST,
|
||||
Self::McpServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::SerdeJsonError(_) => StatusCode::BAD_REQUEST,
|
||||
Self::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::RouteNotFound(_) => StatusCode::NOT_FOUND,
|
||||
Self::InvalidParameter(_) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// 从外部错误类型转换
|
||||
// ===========================================
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self::mcp_server_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::json_serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::io_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorOutput {
|
||||
pub code: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl ErrorOutput {
|
||||
pub fn with_code(code: &'static str, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response<axum::body::Body> {
|
||||
(
|
||||
self.status_code(),
|
||||
Json(ErrorOutput::with_code(self.error_code(), self.to_string())),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
33
qiming-mcp-proxy/mcp-proxy/src/model/app_state_model.rs
Normal file
33
qiming-mcp-proxy/mcp-proxy/src/model/app_state_model.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::{ops::Deref, sync::Arc};
|
||||
|
||||
use crate::AppConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
inner: Arc<AppStateInner>,
|
||||
}
|
||||
#[allow(unused)]
|
||||
#[derive(Debug)]
|
||||
pub struct AppStateInner {
|
||||
pub addr: String,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
impl Deref for AppState {
|
||||
type Target = AppStateInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(AppStateInner {
|
||||
addr: format!("0.0.0.0:{}", config.server.port),
|
||||
config,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
978
qiming-mcp-proxy/mcp-proxy/src/model/global.rs
Normal file
978
qiming-mcp-proxy/mcp-proxy/src/model/global.rs
Normal file
@@ -0,0 +1,978 @@
|
||||
use axum::Router;
|
||||
use dashmap::DashMap;
|
||||
use log::{debug, error, info};
|
||||
use moka::future::Cache;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
use tokio::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::proxy::McpHandler;
|
||||
|
||||
use super::{CheckMcpStatusResponseStatus, McpConfig, McpProtocol, McpRouterPath, McpType};
|
||||
|
||||
// 全局单例路由表
|
||||
pub static GLOBAL_ROUTES: Lazy<Arc<DashMap<String, Router>>> =
|
||||
Lazy::new(|| Arc::new(DashMap::new()));
|
||||
|
||||
// 全局单例 ProxyHandlerManager
|
||||
pub static GLOBAL_PROXY_MANAGER: Lazy<ProxyHandlerManager> =
|
||||
Lazy::new(ProxyHandlerManager::default);
|
||||
|
||||
/// 动态路由服务
|
||||
#[derive(Clone)]
|
||||
pub struct DynamicRouterService(pub McpProtocol);
|
||||
|
||||
impl DynamicRouterService {
|
||||
// 注册动态 handler
|
||||
pub fn register_route(path: &str, handler: Router) {
|
||||
debug!("=== Register Route ===");
|
||||
debug!("Registration path: {}", path);
|
||||
GLOBAL_ROUTES.insert(path.to_string(), handler);
|
||||
debug!("=== Route registration completed ===");
|
||||
}
|
||||
|
||||
// 删除动态 handler
|
||||
pub fn delete_route(path: &str) {
|
||||
debug!("=== Delete route ===");
|
||||
debug!("Delete path: {}", path);
|
||||
GLOBAL_ROUTES.remove(path);
|
||||
debug!("=== Route deletion completed ===");
|
||||
}
|
||||
|
||||
// 获取动态 handler
|
||||
pub fn get_route(path: &str) -> Option<Router> {
|
||||
let result = GLOBAL_ROUTES.get(path).map(|entry| entry.value().clone());
|
||||
if result.is_some() {
|
||||
debug!("get_route('{}') = Some(Router)", path);
|
||||
} else {
|
||||
debug!("get_route('{}') = None", path);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// 获取所有已注册的路由(debug用)
|
||||
pub fn get_all_routes() -> Vec<String> {
|
||||
GLOBAL_ROUTES
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DynamicRouterService {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let routes = GLOBAL_ROUTES
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect::<Vec<_>>();
|
||||
write!(f, "DynamicRouterService {{ routes: {routes:?} }}")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RAII 进程管理设计
|
||||
// =============================================================================
|
||||
//
|
||||
// 设计目标:当 mcp_id 从 map 中移除时,自动释放对应的 MCP 进程资源
|
||||
//
|
||||
// 核心结构:
|
||||
// - McpProcessGuard: 进程生命周期守护器,实现 Drop trait 自动取消 CancellationToken
|
||||
// - McpService: 封装 McpHandler + McpProcessGuard + 服务状态,作为 map 的 value
|
||||
// - ProxyHandlerManager: 使用单一 DashMap<String, McpService> 管理所有服务
|
||||
//
|
||||
// 资源释放流程:
|
||||
// 1. 从 map 中 remove mcp_id
|
||||
// 2. McpService 被 drop
|
||||
// 3. McpProcessGuard::drop() 被调用
|
||||
// 4. CancellationToken 被 cancel
|
||||
// 5. 监听该 token 的 SseServer/子进程收到信号,自动退出
|
||||
// =============================================================================
|
||||
|
||||
/// MCP 进程生命周期守护器
|
||||
///
|
||||
/// 实现 RAII 模式:当此结构体被 drop 时,自动取消 CancellationToken,
|
||||
/// 触发关联的 SseServer 和子进程退出。
|
||||
///
|
||||
/// # 使用场景
|
||||
///
|
||||
/// 1. 从 `ProxyHandlerManager` 移除 mcp_id 时,自动清理进程
|
||||
/// 2. 服务重启时,旧服务自动被清理
|
||||
/// 3. 系统关闭时,所有服务自动清理
|
||||
pub struct McpProcessGuard {
|
||||
mcp_id: String,
|
||||
cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
impl McpProcessGuard {
|
||||
pub fn new(mcp_id: String, cancellation_token: CancellationToken) -> Self {
|
||||
debug!("[RAII] Create process daemon: mcp_id={}", mcp_id);
|
||||
Self {
|
||||
mcp_id,
|
||||
cancellation_token,
|
||||
}
|
||||
}
|
||||
|
||||
/// 克隆 CancellationToken(用于传递给异步任务)
|
||||
pub fn clone_token(&self) -> CancellationToken {
|
||||
self.cancellation_token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpProcessGuard {
|
||||
fn drop(&mut self) {
|
||||
info!(
|
||||
"[RAII] The process daemon was dropped and canceled CancellationToken: mcp_id={}",
|
||||
self.mcp_id
|
||||
);
|
||||
self.cancellation_token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// McpProcessGuard 不实现 Clone,确保唯一所有权
|
||||
impl std::fmt::Debug for McpProcessGuard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("McpProcessGuard")
|
||||
.field("mcp_id", &self.mcp_id)
|
||||
.field("is_cancelled", &self.cancellation_token.is_cancelled())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP 服务封装
|
||||
///
|
||||
/// 将 McpHandler、McpProcessGuard 和服务状态封装在一起,
|
||||
/// 作为 `ProxyHandlerManager` 中 DashMap 的 value。
|
||||
///
|
||||
/// # RAII 保证
|
||||
///
|
||||
/// 当 McpService 被 drop 时:
|
||||
/// 1. McpProcessGuard 被 drop,触发 CancellationToken 取消
|
||||
/// 2. 关联的子进程收到信号,自动退出
|
||||
pub struct McpService {
|
||||
/// 进程守护器(RAII 核心)
|
||||
process_guard: McpProcessGuard,
|
||||
/// MCP 透明代理处理器(可选,启动中时为 None)
|
||||
handler: Option<McpHandler>,
|
||||
/// 服务状态信息
|
||||
status: McpServiceStatusInfo,
|
||||
}
|
||||
|
||||
/// MCP 服务状态信息(不包含 CancellationToken,由 McpProcessGuard 管理)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpServiceStatusInfo {
|
||||
pub mcp_id: String,
|
||||
pub mcp_type: McpType,
|
||||
pub mcp_router_path: McpRouterPath,
|
||||
pub check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
pub last_accessed: Instant,
|
||||
pub mcp_config: Option<McpConfig>,
|
||||
/// 连续健康检查失败次数(用于容错机制)
|
||||
pub consecutive_probe_failures: u32,
|
||||
}
|
||||
|
||||
impl McpServiceStatusInfo {
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
) -> Self {
|
||||
Self {
|
||||
mcp_id,
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
check_mcp_status_response_status,
|
||||
last_accessed: Instant::now(),
|
||||
mcp_config: None,
|
||||
consecutive_probe_failures: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_last_accessed(&mut self) {
|
||||
self.last_accessed = Instant::now();
|
||||
}
|
||||
|
||||
/// 重置健康检查失败计数
|
||||
pub fn reset_probe_failures(&mut self) {
|
||||
self.consecutive_probe_failures = 0;
|
||||
}
|
||||
|
||||
/// 增加健康检查失败计数,返回增加后的值
|
||||
pub fn increment_probe_failures(&mut self) -> u32 {
|
||||
self.consecutive_probe_failures += 1;
|
||||
self.consecutive_probe_failures
|
||||
}
|
||||
}
|
||||
|
||||
impl McpService {
|
||||
/// 创建新的 MCP 服务
|
||||
///
|
||||
/// # 参数
|
||||
/// - `mcp_id`: 服务唯一标识
|
||||
/// - `mcp_type`: 服务类型
|
||||
/// - `mcp_router_path`: 路由路径
|
||||
/// - `cancellation_token`: 用于控制进程生命周期的取消令牌
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Self {
|
||||
let process_guard = McpProcessGuard::new(mcp_id.clone(), cancellation_token);
|
||||
let status = McpServiceStatusInfo::new(
|
||||
mcp_id,
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
);
|
||||
Self {
|
||||
process_guard,
|
||||
handler: None,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 MCP Handler
|
||||
pub fn set_handler(&mut self, handler: McpHandler) {
|
||||
self.handler = Some(handler);
|
||||
}
|
||||
|
||||
/// 获取 MCP Handler
|
||||
pub fn handler(&self) -> Option<&McpHandler> {
|
||||
self.handler.as_ref()
|
||||
}
|
||||
|
||||
/// 获取服务状态
|
||||
pub fn status(&self) -> &McpServiceStatusInfo {
|
||||
&self.status
|
||||
}
|
||||
|
||||
/// 获取可变服务状态
|
||||
pub fn status_mut(&mut self) -> &mut McpServiceStatusInfo {
|
||||
&mut self.status
|
||||
}
|
||||
|
||||
/// 克隆 CancellationToken
|
||||
pub fn clone_token(&self) -> CancellationToken {
|
||||
self.process_guard.clone_token()
|
||||
}
|
||||
|
||||
/// 更新服务状态
|
||||
pub fn update_status(&mut self, status: CheckMcpStatusResponseStatus) {
|
||||
self.status.check_mcp_status_response_status = status;
|
||||
}
|
||||
|
||||
/// 更新最后访问时间
|
||||
pub fn update_last_accessed(&mut self) {
|
||||
self.status.update_last_accessed();
|
||||
}
|
||||
|
||||
/// 设置 MCP 配置
|
||||
pub fn set_mcp_config(&mut self, config: McpConfig) {
|
||||
self.status.mcp_config = Some(config);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for McpService {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("McpService")
|
||||
.field("process_guard", &self.process_guard)
|
||||
.field("handler", &self.handler.is_some())
|
||||
.field("status", &self.status)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 兼容层:保留 McpServiceStatus 以兼容现有代码
|
||||
// =============================================================================
|
||||
|
||||
/// MCP 服务状态(兼容层)
|
||||
///
|
||||
/// 保留此结构体以兼容现有代码,内部委托给 McpServiceStatusInfo
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpServiceStatus {
|
||||
pub mcp_id: String,
|
||||
pub mcp_type: McpType,
|
||||
pub mcp_router_path: McpRouterPath,
|
||||
pub cancellation_token: CancellationToken,
|
||||
pub check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
pub last_accessed: Instant,
|
||||
pub mcp_config: Option<McpConfig>,
|
||||
/// 连续健康检查失败次数
|
||||
pub consecutive_probe_failures: u32,
|
||||
}
|
||||
|
||||
impl McpServiceStatus {
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
cancellation_token: CancellationToken,
|
||||
check_mcp_status_response_status: CheckMcpStatusResponseStatus,
|
||||
) -> Self {
|
||||
Self {
|
||||
mcp_id,
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
cancellation_token,
|
||||
check_mcp_status_response_status,
|
||||
last_accessed: Instant::now(),
|
||||
mcp_config: None,
|
||||
consecutive_probe_failures: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_mcp_config(mut self, mcp_config: McpConfig) -> Self {
|
||||
self.mcp_config = Some(mcp_config);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn update_last_accessed(&mut self) {
|
||||
self.last_accessed = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ProxyHandlerManager:使用 RAII 模式管理 MCP 服务
|
||||
// =============================================================================
|
||||
|
||||
/// MCP 代理管理器
|
||||
///
|
||||
/// 使用 RAII 模式管理 MCP 服务:
|
||||
/// - 从 map 中移除 mcp_id 时,自动释放对应的进程资源
|
||||
/// - 不需要显式调用 cleanup 方法(但仍提供显式清理接口)
|
||||
#[derive(Debug)]
|
||||
pub struct ProxyHandlerManager {
|
||||
/// 使用单一 DashMap 管理所有 MCP 服务(RAII 核心)
|
||||
services: DashMap<String, McpService>,
|
||||
}
|
||||
|
||||
impl Default for ProxyHandlerManager {
|
||||
fn default() -> Self {
|
||||
ProxyHandlerManager {
|
||||
services: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyHandlerManager {
|
||||
/// 添加 MCP 服务(RAII 模式)
|
||||
///
|
||||
/// 使用新的 RAII 结构创建服务,当服务被移除时会自动清理资源
|
||||
pub fn add_mcp_service(
|
||||
&self,
|
||||
mcp_id: String,
|
||||
mcp_type: McpType,
|
||||
mcp_router_path: McpRouterPath,
|
||||
cancellation_token: CancellationToken,
|
||||
) {
|
||||
let service = McpService::new(
|
||||
mcp_id.clone(),
|
||||
mcp_type,
|
||||
mcp_router_path,
|
||||
cancellation_token,
|
||||
);
|
||||
|
||||
// RAII: 如果已存在同名服务,insert 会返回旧服务,旧服务被 drop 时自动清理
|
||||
if let Some(old_service) = self.services.insert(mcp_id.clone(), service) {
|
||||
info!(
|
||||
"[RAII] Overwrite existing services, old services will be automatically cleaned up: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
drop(old_service);
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加 MCP 服务状态(兼容旧 API)
|
||||
///
|
||||
/// 保持与现有代码的兼容性,内部转换为新的 RAII 结构
|
||||
///
|
||||
/// 注意:`last_accessed` 会被重置为当前时间(插入视为新访问)
|
||||
pub fn add_mcp_service_status_and_proxy(
|
||||
&self,
|
||||
mcp_service_status: McpServiceStatus,
|
||||
proxy_handler: Option<McpHandler>,
|
||||
) {
|
||||
let mcp_id = mcp_service_status.mcp_id.clone();
|
||||
|
||||
// 创建 McpService 使用 RAII 模式
|
||||
// 注意:last_accessed 会在 McpServiceStatusInfo::new() 中重置为 Instant::now()
|
||||
let mut service = McpService::new(
|
||||
mcp_id.clone(),
|
||||
mcp_service_status.mcp_type,
|
||||
mcp_service_status.mcp_router_path,
|
||||
mcp_service_status.cancellation_token,
|
||||
);
|
||||
|
||||
// 设置初始状态
|
||||
service.status_mut().check_mcp_status_response_status =
|
||||
mcp_service_status.check_mcp_status_response_status;
|
||||
|
||||
// 设置配置(如果有)
|
||||
if let Some(config) = mcp_service_status.mcp_config {
|
||||
service.set_mcp_config(config);
|
||||
}
|
||||
|
||||
// 设置 handler(如果有)
|
||||
if let Some(handler) = proxy_handler {
|
||||
service.set_handler(handler);
|
||||
}
|
||||
|
||||
// RAII: 如果已存在同名服务,insert 会返回旧服务,旧服务被 drop 时自动清理
|
||||
if let Some(old_service) = self.services.insert(mcp_id.clone(), service) {
|
||||
info!(
|
||||
"[RAII] Overwrite existing services, old services will be automatically cleaned up: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
// old_service 在此作用域结束时 drop,触发 McpProcessGuard::drop()
|
||||
drop(old_service);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有的 MCP 服务状态(兼容旧 API)
|
||||
///
|
||||
/// 优化:先快速收集所有 keys,然后逐个获取详细信息
|
||||
/// 避免 iter() 长时间锁住多个分片,让其他写操作有机会执行
|
||||
pub fn get_all_mcp_service_status(&self) -> Vec<McpServiceStatus> {
|
||||
// 第一步:快速收集所有 keys(只 clone String,锁持有时间短)
|
||||
let keys: Vec<String> = self
|
||||
.services
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect();
|
||||
|
||||
// 第二步:逐个获取详细信息(每次只锁一个分片)
|
||||
keys.into_iter()
|
||||
.filter_map(|mcp_id| self.get_mcp_service_status(&mcp_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取 MCP 服务状态(兼容旧 API)
|
||||
pub fn get_mcp_service_status(&self, mcp_id: &str) -> Option<McpServiceStatus> {
|
||||
self.services.get(mcp_id).map(|entry| {
|
||||
let service = entry.value();
|
||||
let status = service.status();
|
||||
McpServiceStatus {
|
||||
mcp_id: status.mcp_id.clone(),
|
||||
mcp_type: status.mcp_type.clone(),
|
||||
mcp_router_path: status.mcp_router_path.clone(),
|
||||
cancellation_token: service.clone_token(),
|
||||
check_mcp_status_response_status: status.check_mcp_status_response_status.clone(),
|
||||
last_accessed: status.last_accessed,
|
||||
mcp_config: status.mcp_config.clone(),
|
||||
consecutive_probe_failures: status.consecutive_probe_failures,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新最后访问时间
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn update_last_accessed(&self, mcp_id: &str) {
|
||||
self.services
|
||||
.entry(mcp_id.to_string())
|
||||
.and_modify(|service| service.update_last_accessed());
|
||||
}
|
||||
|
||||
/// 修改 MCP 服务状态 (Ready/Pending/Error)
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn update_mcp_service_status(&self, mcp_id: &str, status: CheckMcpStatusResponseStatus) {
|
||||
self.services
|
||||
.entry(mcp_id.to_string())
|
||||
.and_modify(|service| service.update_status(status));
|
||||
}
|
||||
|
||||
/// 获取 MCP Handler
|
||||
pub fn get_proxy_handler(&self, mcp_id: &str) -> Option<McpHandler> {
|
||||
self.services
|
||||
.get(mcp_id)
|
||||
.and_then(|entry| entry.value().handler().cloned())
|
||||
}
|
||||
|
||||
/// 获取服务的 MCP 配置(用于自动重启)
|
||||
pub fn get_mcp_config(&self, mcp_id: &str) -> Option<McpConfig> {
|
||||
self.services
|
||||
.get(mcp_id)
|
||||
.and_then(|entry| entry.value().status().mcp_config.clone())
|
||||
}
|
||||
|
||||
/// 添加 MCP Handler 到已存在的服务
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn add_proxy_handler(&self, mcp_id: &str, proxy_handler: McpHandler) {
|
||||
match self.services.entry(mcp_id.to_string()) {
|
||||
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
||||
entry.get_mut().set_handler(proxy_handler);
|
||||
}
|
||||
dashmap::mapref::entry::Entry::Vacant(_) => {
|
||||
warn!(
|
||||
"[RAII] Trying to add handler to non-existent service: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查服务是否存在
|
||||
pub fn contains_service(&self, mcp_id: &str) -> bool {
|
||||
self.services.contains_key(mcp_id)
|
||||
}
|
||||
|
||||
/// 获取服务数量
|
||||
pub fn service_count(&self) -> usize {
|
||||
self.services.len()
|
||||
}
|
||||
|
||||
/// 注册 MCP 配置到缓存
|
||||
pub async fn register_mcp_config(&self, mcp_id: &str, config: McpConfig) {
|
||||
GLOBAL_MCP_CONFIG_CACHE
|
||||
.insert(mcp_id.to_string(), config)
|
||||
.await;
|
||||
info!("MCP configuration registered in cache: {}", mcp_id);
|
||||
}
|
||||
|
||||
/// 从缓存获取 MCP 配置
|
||||
pub async fn get_mcp_config_from_cache(&self, mcp_id: &str) -> Option<McpConfig> {
|
||||
if let Some(config) = GLOBAL_MCP_CONFIG_CACHE.get(mcp_id).await {
|
||||
debug!("Get MCP configuration from cache: {}", mcp_id);
|
||||
Some(config)
|
||||
} else {
|
||||
debug!("MCP configuration not found in cache: {}", mcp_id);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 从缓存删除 MCP 配置
|
||||
pub async fn unregister_mcp_config(&self, mcp_id: &str) {
|
||||
GLOBAL_MCP_CONFIG_CACHE.invalidate(mcp_id).await;
|
||||
info!("MCP configuration removed from cache: {}", mcp_id);
|
||||
}
|
||||
|
||||
/// 清理资源 (RAII 模式简化版)
|
||||
///
|
||||
/// 通过 RAII 模式,从 DashMap 中移除服务会自动:
|
||||
/// 1. 触发 McpProcessGuard::drop()
|
||||
/// 2. 取消 CancellationToken
|
||||
/// 3. 关联的子进程收到信号退出
|
||||
///
|
||||
/// 此方法额外清理路由和缓存
|
||||
pub async fn cleanup_resources(&self, mcp_id: &str) -> Result<()> {
|
||||
info!("[RAII] Start cleaning up resources: mcp_id={}", mcp_id);
|
||||
|
||||
// 创建路径以构建要删除的路由路径
|
||||
let mcp_sse_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Sse)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create SSE router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_sse_path = mcp_sse_router_path.base_path;
|
||||
|
||||
let mcp_stream_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Stream)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create Stream router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_stream_path = mcp_stream_router_path.base_path;
|
||||
|
||||
// 移除相关路由
|
||||
DynamicRouterService::delete_route(&base_sse_path);
|
||||
DynamicRouterService::delete_route(&base_stream_path);
|
||||
|
||||
// RAII 核心:从 DashMap 移除会触发 McpProcessGuard::drop()
|
||||
// 这会自动取消 CancellationToken,进而触发子进程退出
|
||||
if self.services.remove(mcp_id).is_some() {
|
||||
info!(
|
||||
"[RAII] The service has been removed from the map, McpProcessGuard will automatically cancel the token: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[RAII] Service does not exist, skip removal: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
// 清理配置缓存
|
||||
self.unregister_mcp_config(mcp_id).await;
|
||||
|
||||
// 清理健康状态缓存
|
||||
GLOBAL_RESTART_TRACKER.clear_health_status(mcp_id);
|
||||
|
||||
info!(
|
||||
"[RAII] MCP service resource cleanup completed: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 系统关闭,清理所有资源
|
||||
///
|
||||
/// RAII 模式下,清除 DashMap 会自动释放所有资源
|
||||
pub async fn cleanup_all_resources(&self) -> Result<()> {
|
||||
info!("[RAII] Start cleaning up all MCP service resources");
|
||||
|
||||
// 收集所有 mcp_id
|
||||
let mcp_ids: Vec<String> = self
|
||||
.services
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect();
|
||||
|
||||
let count = mcp_ids.len();
|
||||
|
||||
// 逐个清理(包括路由和缓存)
|
||||
for mcp_id in mcp_ids {
|
||||
if let Err(e) = self.cleanup_resources(&mcp_id).await {
|
||||
error!(
|
||||
"[RAII] Failed to clean up resources: mcp_id={}, error={}",
|
||||
mcp_id, e
|
||||
);
|
||||
// 继续清理其他资源
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"[RAII] All MCP service resources have been cleaned up, and a total of {} services have been cleaned up.",
|
||||
count
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 仅移除服务(依赖 RAII 自动清理进程)
|
||||
///
|
||||
/// 从 DashMap 中移除服务,触发 RAII 自动清理。
|
||||
/// 不会清理路由和缓存,适用于需要快速移除服务的场景。
|
||||
pub fn remove_service(&self, mcp_id: &str) -> bool {
|
||||
if self.services.remove(mcp_id).is_some() {
|
||||
info!(
|
||||
"[RAII] The service has been removed and the process will be automatically cleaned up: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
true
|
||||
} else {
|
||||
debug!("[RAII] Service does not exist: mcp_id={}", mcp_id);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置健康检查失败计数
|
||||
///
|
||||
/// 使用 get_mut 避免为不存在的服务创建空 entry
|
||||
pub fn reset_probe_failures(&self, mcp_id: &str) {
|
||||
if let Some(mut entry) = self.services.get_mut(mcp_id) {
|
||||
entry.value_mut().status_mut().reset_probe_failures();
|
||||
}
|
||||
}
|
||||
|
||||
/// 增加健康检查失败计数,返回增加后的值
|
||||
///
|
||||
/// 使用 entry API 确保原子性操作
|
||||
pub fn increment_probe_failures(&self, mcp_id: &str) -> u32 {
|
||||
self.services
|
||||
.get_mut(mcp_id)
|
||||
.map(|mut entry| entry.value_mut().status_mut().increment_probe_failures())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 清理资源用于重启(保留配置缓存)
|
||||
///
|
||||
/// 与 cleanup_resources 不同,此方法不会清理配置缓存,
|
||||
/// 允许后续使用缓存的配置重新启动服务。
|
||||
pub async fn cleanup_resources_for_restart(&self, mcp_id: &str) -> Result<()> {
|
||||
info!(
|
||||
"[RAII] Start cleaning up resources for restart: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
|
||||
// 创建路径以构建要删除的路由路径
|
||||
let mcp_sse_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Sse)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create SSE router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_sse_path = mcp_sse_router_path.base_path;
|
||||
|
||||
let mcp_stream_router_path = McpRouterPath::new(mcp_id.to_string(), McpProtocol::Stream)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create Stream router path for {}: {}", mcp_id, e)
|
||||
})?;
|
||||
let base_stream_path = mcp_stream_router_path.base_path;
|
||||
|
||||
// 移除相关路由
|
||||
DynamicRouterService::delete_route(&base_sse_path);
|
||||
DynamicRouterService::delete_route(&base_stream_path);
|
||||
|
||||
// RAII 核心:从 DashMap 移除会触发 McpProcessGuard::drop()
|
||||
if self.services.remove(mcp_id).is_some() {
|
||||
info!(
|
||||
"[RAII] The service has been removed from the map (for restart), McpProcessGuard will automatically cancel the token: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[RAII] Service does not exist, skip removal: mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
// 注意:不清理配置缓存,保留用于重启
|
||||
// self.unregister_mcp_config(mcp_id).await; // 不调用
|
||||
|
||||
// 清理健康状态缓存
|
||||
GLOBAL_RESTART_TRACKER.clear_health_status(mcp_id);
|
||||
|
||||
info!(
|
||||
"[RAII] MCP service resource cleanup completed (for restart): mcp_id={}",
|
||||
mcp_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP 配置缓存(使用 moka 实现 TTL)
|
||||
///
|
||||
/// ## 存储架构说明
|
||||
///
|
||||
/// MCP 配置存储在两个位置:
|
||||
///
|
||||
/// 1. **McpServiceStatus.mcp_config**(服务状态中)
|
||||
/// - 存储当前运行服务的配置
|
||||
/// - 随服务清理而被删除
|
||||
/// - 用于快速访问当前服务的配置
|
||||
///
|
||||
/// 2. **GLOBAL_MCP_CONFIG_CACHE**(全局缓存)
|
||||
/// - 独立于服务状态存储
|
||||
/// - 有 TTL(24 小时)
|
||||
/// - 用于服务重启时恢复配置
|
||||
///
|
||||
/// ## 为什么需要两处存储?
|
||||
///
|
||||
/// - 服务清理后,McpServiceStatus 被删除,但配置仍在缓存中
|
||||
/// - 下次请求到来时,可以从缓存恢复配置并重启服务
|
||||
/// - 实现了服务的自动重启能力
|
||||
///
|
||||
/// ## 优先级
|
||||
///
|
||||
/// 1. 请求 header 中的配置(最新)
|
||||
/// 2. 缓存中的配置(兜底)
|
||||
///
|
||||
/// ## TTL
|
||||
///
|
||||
/// - 24 小时(可配置)
|
||||
/// - max_capacity: 1000(防止内存溢出)
|
||||
pub struct McpConfigCache {
|
||||
cache: Cache<String, McpConfig>,
|
||||
}
|
||||
|
||||
impl McpConfigCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Cache::builder()
|
||||
.time_to_live(Duration::from_secs(24 * 60 * 60)) // 24 小时 TTL
|
||||
.max_capacity(1000) // 最多缓存 1000 个配置,防止内存溢出
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert(&self, mcp_id: String, config: McpConfig) {
|
||||
self.cache.insert(mcp_id.clone(), config).await;
|
||||
info!("MCP configuration cached: {} (TTL: 24h)", mcp_id);
|
||||
}
|
||||
|
||||
pub async fn get(&self, mcp_id: &str) -> Option<McpConfig> {
|
||||
self.cache.get(mcp_id).await
|
||||
}
|
||||
|
||||
pub async fn invalidate(&self, mcp_id: &str) {
|
||||
self.cache.invalidate(mcp_id).await;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn invalidate_all(&self) {
|
||||
self.cache.invalidate_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for McpConfigCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局配置缓存单例
|
||||
pub static GLOBAL_MCP_CONFIG_CACHE: Lazy<McpConfigCache> = Lazy::new(McpConfigCache::default);
|
||||
|
||||
/// MCP 服务重启追踪器
|
||||
///
|
||||
/// 用于防止服务频繁重启导致的无限循环
|
||||
///
|
||||
/// ## 重启限制
|
||||
///
|
||||
/// - 最小重启间隔:30 秒
|
||||
/// - 如果服务在 30 秒内被标记为需要重启,将跳过重启
|
||||
/// - 这防止了服务启动失败时的无限重启循环
|
||||
///
|
||||
/// ## 健康状态缓存
|
||||
///
|
||||
/// - 缓存后端健康状态,避免频繁检查
|
||||
/// - 缓存时间:5 秒(可配置)
|
||||
/// - 用于减少 `is_mcp_server_ready()` 调用频率
|
||||
pub struct RestartTracker {
|
||||
// mcp_id -> 最后重启时间
|
||||
last_restart: DashMap<String, Instant>,
|
||||
// mcp_id -> (健康状态, 检查时间)
|
||||
health_status: DashMap<String, (bool, Instant)>,
|
||||
// mcp_id -> 启动锁,防止并发启动同一服务
|
||||
startup_locks: DashMap<String, Arc<Mutex<()>>>,
|
||||
}
|
||||
|
||||
impl RestartTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_restart: DashMap::new(),
|
||||
health_status: DashMap::new(),
|
||||
startup_locks: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取缓存的健康状态
|
||||
///
|
||||
/// 如果缓存未过期(5秒内),返回缓存值
|
||||
/// 否则返回 None,表示需要重新检查
|
||||
pub fn get_cached_health_status(&self, mcp_id: &str) -> Option<bool> {
|
||||
let cache_duration = Duration::from_secs(5); // 5 秒缓存
|
||||
let now = Instant::now();
|
||||
|
||||
self.health_status.get(mcp_id).and_then(|entry| {
|
||||
let (is_healthy, check_time) = *entry.value();
|
||||
if now.duration_since(check_time) < cache_duration {
|
||||
Some(is_healthy)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新健康状态缓存
|
||||
pub fn update_health_status(&self, mcp_id: &str, is_healthy: bool) {
|
||||
self.health_status
|
||||
.insert(mcp_id.to_string(), (is_healthy, Instant::now()));
|
||||
}
|
||||
|
||||
/// 清除健康状态缓存
|
||||
pub fn clear_health_status(&self, mcp_id: &str) {
|
||||
self.health_status.remove(mcp_id);
|
||||
}
|
||||
|
||||
/// 检查是否可以重启服务
|
||||
///
|
||||
/// 返回 true 表示可以重启,false 表示在冷却期内
|
||||
///
|
||||
/// 注意:此方法仅检查是否可以重启,不会自动插入时间戳。
|
||||
/// 时间戳只在服务成功启动后通过 `record_restart()` 方法记录。
|
||||
pub fn can_restart(&self, mcp_id: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let min_restart_interval = Duration::from_secs(30); // 30 秒最小重启间隔
|
||||
|
||||
// 只检查,不自动插入时间戳
|
||||
if let Some(last_restart) = self.last_restart.get(mcp_id) {
|
||||
let elapsed = now.duration_since(*last_restart);
|
||||
if elapsed < min_restart_interval {
|
||||
warn!(
|
||||
"Service {} is in the cooldown period and is only {} seconds since its last restart. Restart is skipped.",
|
||||
mcp_id,
|
||||
elapsed.as_secs()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 不在冷却期内,但不自动更新时间戳
|
||||
true
|
||||
}
|
||||
|
||||
/// 记录服务成功重启
|
||||
///
|
||||
/// 此方法应在服务成功启动后调用,用于记录重启时间戳。
|
||||
/// 配合 `can_restart()` 使用,避免在服务启动失败时插入时间戳。
|
||||
pub fn record_restart(&self, mcp_id: &str) {
|
||||
self.last_restart.insert(mcp_id.to_string(), Instant::now());
|
||||
info!(
|
||||
"The service started successfully and the restart time was recorded: {}",
|
||||
mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
/// 清除重启时间戳
|
||||
///
|
||||
/// 当服务启动失败时,可选择调用此方法清除时间戳,
|
||||
/// 允许立即重试而不必等待冷却期。
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_restart(&self, mcp_id: &str) {
|
||||
self.last_restart.remove(mcp_id);
|
||||
info!("Restart timestamp cleared for service {}", mcp_id);
|
||||
}
|
||||
|
||||
/// 尝试获取服务启动锁
|
||||
///
|
||||
/// 返回 Some(OwnedMutexGuard) 表示获取成功,可以继续启动服务
|
||||
/// 返回 None 表示服务正在启动中,应该跳过本次启动
|
||||
///
|
||||
/// # 使用方式
|
||||
///
|
||||
/// ```ignore
|
||||
/// if let Some(_guard) = GLOBAL_RESTART_TRACKER.try_acquire_startup_lock(&mcp_id) {
|
||||
/// // 获取到锁,可以启动服务
|
||||
/// let result = start_service().await;
|
||||
/// // _guard 在作用域结束时自动释放
|
||||
/// } else {
|
||||
/// // 未获取到锁,服务正在启动中
|
||||
/// return Ok(Response::503);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn try_acquire_startup_lock(&self, mcp_id: &str) -> Option<OwnedMutexGuard<()>> {
|
||||
// 使用 entry API 确保原子性,避免竞态条件
|
||||
let lock = self
|
||||
.startup_locks
|
||||
.entry(mcp_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone();
|
||||
|
||||
// 尝试获取 owned 锁,锁会一直保持到返回的 guard 被 drop
|
||||
match lock.try_lock_owned() {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(_) => {
|
||||
// 锁被占用,服务正在启动中
|
||||
debug!("Service {} is starting, skip this startup", mcp_id);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理服务启动锁
|
||||
///
|
||||
/// 当服务启动完成或失败后,应该清理启动锁以允许后续重试
|
||||
/// 注意:正常情况下锁会随 MutexGuard 自动释放,此方法用于异常清理
|
||||
#[allow(dead_code)]
|
||||
pub fn cleanup_startup_lock(&self, mcp_id: &str) {
|
||||
self.startup_locks.remove(mcp_id);
|
||||
debug!("Cleaned startup lock for service {}", mcp_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RestartTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局重启追踪器单例
|
||||
pub static GLOBAL_RESTART_TRACKER: Lazy<RestartTracker> = Lazy::new(RestartTracker::default);
|
||||
|
||||
// 提供一个便捷的函数来获取全局 ProxyHandlerManager
|
||||
pub fn get_proxy_manager() -> &'static ProxyHandlerManager {
|
||||
&GLOBAL_PROXY_MANAGER
|
||||
}
|
||||
71
qiming-mcp-proxy/mcp-proxy/src/model/http_result.rs
Normal file
71
qiming-mcp-proxy/mcp-proxy/src/model/http_result.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HttpResult<T> {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub data: Option<T>,
|
||||
pub tid: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
impl<T> HttpResult<T> {
|
||||
pub fn success(data: T, tid: Option<String>) -> Self {
|
||||
HttpResult {
|
||||
code: "0000".to_string(),
|
||||
message: "成功".to_string(),
|
||||
data: Some(data),
|
||||
tid,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(code: &str, message: &str, tid: Option<String>) -> Self {
|
||||
HttpResult {
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
data: None,
|
||||
tid,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> Serialize for HttpResult<T> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut state = serializer.serialize_struct("HttpResult", 5)?;
|
||||
state.serialize_field("code", &self.code)?;
|
||||
state.serialize_field("message", &self.message)?;
|
||||
state.serialize_field("data", &self.data)?;
|
||||
state.serialize_field("tid", &self.tid)?;
|
||||
let is_success = self.code == "0000";
|
||||
state.serialize_field("success", &is_success)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> IntoResponse for HttpResult<T> {
|
||||
fn into_response(self) -> Response {
|
||||
match serde_json::to_string(&self) {
|
||||
Ok(body) => (
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
body,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/plain")],
|
||||
"Internal Server Error",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
104
qiming-mcp-proxy/mcp-proxy/src/model/mcp_check_status_model.rs
Normal file
104
qiming-mcp-proxy/mcp-proxy/src/model/mcp_check_status_model.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{McpProtocol, McpType};
|
||||
|
||||
//check mcp服务状态的请求参数
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct CheckMcpStatusRequestParams {
|
||||
//mcp的id,必须有
|
||||
#[serde(rename = "mcpId")]
|
||||
pub mcp_id: String,
|
||||
//mcp的json配置,必须有
|
||||
#[serde(rename = "mcpJsonConfig")]
|
||||
pub mcp_json_config: String,
|
||||
//mcp类型,必须有,默认:OneShot
|
||||
#[serde(rename = "mcpType", default = "default_mcp_type")]
|
||||
pub mcp_type: McpType,
|
||||
//后端MCP服务的协议类型(可选),用于指定连接到后端服务时使用的协议
|
||||
//如果不指定,则使用客户端协议(由路由路径决定)
|
||||
#[serde(rename = "backendProtocol")]
|
||||
#[allow(dead_code)] // 为未来的功能预留
|
||||
pub backend_protocol: Option<McpProtocol>,
|
||||
}
|
||||
|
||||
//默认的mcp类型
|
||||
fn default_mcp_type() -> McpType {
|
||||
McpType::OneShot
|
||||
}
|
||||
|
||||
//check mcp服务状态的响应参数
|
||||
#[derive(Deserialize, Debug, Serialize)]
|
||||
pub struct CheckMcpStatusResponseParams {
|
||||
//是否就绪, READY 状态,表示 true
|
||||
pub ready: bool,
|
||||
//状态
|
||||
pub status: McpStatusResponseEnum,
|
||||
//消息
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl CheckMcpStatusResponseParams {
|
||||
pub fn new(ready: bool, status: CheckMcpStatusResponseStatus, message: Option<String>) -> Self {
|
||||
//检查是否error,是的话,取error枚举里面的错误,放在 message里
|
||||
let mut message = message;
|
||||
if let CheckMcpStatusResponseStatus::Error(err) = status.clone() {
|
||||
message = Some(err.to_string());
|
||||
}
|
||||
let status = McpStatusResponseEnum::from(status);
|
||||
|
||||
Self {
|
||||
ready,
|
||||
status,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//check mcp服务状态的响应 status 枚举: READY,PENDING,ERROR
|
||||
#[derive(Deserialize, Debug, Serialize, Clone)]
|
||||
pub enum McpStatusResponseEnum {
|
||||
//就绪
|
||||
Ready,
|
||||
//处理中
|
||||
Pending,
|
||||
//错误
|
||||
Error,
|
||||
}
|
||||
|
||||
//check mcp服务状态的响应 status 枚举: READY,PENDING,ERROR
|
||||
#[derive(Deserialize, Debug, Serialize, Clone, Default)]
|
||||
pub enum CheckMcpStatusResponseStatus {
|
||||
//就绪
|
||||
Ready,
|
||||
//处理中
|
||||
#[default]
|
||||
Pending,
|
||||
//错误
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl From<CheckMcpStatusResponseStatus> for McpStatusResponseEnum {
|
||||
fn from(value: CheckMcpStatusResponseStatus) -> Self {
|
||||
match value {
|
||||
CheckMcpStatusResponseStatus::Ready => Self::Ready,
|
||||
CheckMcpStatusResponseStatus::Pending => Self::Pending,
|
||||
CheckMcpStatusResponseStatus::Error(_) => Self::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for CheckMcpStatusResponseParams {
|
||||
fn into_response(self) -> Response {
|
||||
if let Ok(body) = serde_json::to_string(&self) {
|
||||
(StatusCode::OK, body).into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"serde_json::to_string error".to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
98
qiming-mcp-proxy/mcp-proxy/src/model/mcp_config.rs
Normal file
98
qiming-mcp-proxy/mcp-proxy/src/model/mcp_config.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{McpProtocol, mcp_router_model::McpServerConfig};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpConfig {
|
||||
//mcp_id
|
||||
#[serde(rename = "mcpId")]
|
||||
pub mcp_id: String,
|
||||
//mcp_json_config,可能没有
|
||||
#[serde(rename = "mcpJsonConfig")]
|
||||
pub mcp_json_config: Option<String>,
|
||||
//mcp类型,默认为持续运行
|
||||
#[serde(default = "default_mcp_type", rename = "mcpType")]
|
||||
pub mcp_type: McpType,
|
||||
//客户端协议(用于暴露给客户端的API接口类型)
|
||||
#[serde(default = "default_mcp_protocol", rename = "clientProtocol")]
|
||||
pub client_protocol: McpProtocol,
|
||||
// 解析后的服务器配置(可选)
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub server_config: Option<McpServerConfig>,
|
||||
}
|
||||
|
||||
fn default_mcp_protocol() -> McpProtocol {
|
||||
McpProtocol::Sse
|
||||
}
|
||||
|
||||
fn default_mcp_type() -> McpType {
|
||||
McpType::OneShot
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub enum McpType {
|
||||
// 持续运行
|
||||
Persistent,
|
||||
// 一次性任务
|
||||
#[default]
|
||||
OneShot,
|
||||
}
|
||||
|
||||
impl FromStr for McpType {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"persistent" => Ok(McpType::Persistent),
|
||||
"oneShot" => Ok(McpType::OneShot),
|
||||
_ => Err(anyhow::anyhow!("无效的 MCP 类型: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConfig {
|
||||
pub fn new(
|
||||
mcp_id: String,
|
||||
mcp_json_config: Option<String>,
|
||||
mcp_type: McpType,
|
||||
client_protocol: McpProtocol,
|
||||
) -> Self {
|
||||
Self {
|
||||
mcp_id,
|
||||
mcp_json_config,
|
||||
mcp_type,
|
||||
client_protocol,
|
||||
server_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_json(json: &str) -> Result<Self> {
|
||||
let config: McpConfig = serde_json::from_str(json)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串创建并解析服务器配置
|
||||
pub fn from_json_with_server(
|
||||
mcp_id: String,
|
||||
mcp_json_config: String,
|
||||
mcp_type: McpType,
|
||||
client_protocol: McpProtocol,
|
||||
) -> Result<Self> {
|
||||
let mcp_json_server_parameters =
|
||||
crate::model::McpJsonServerParameters::from(mcp_json_config.clone());
|
||||
let server_config = mcp_json_server_parameters
|
||||
.try_get_first_mcp_server()
|
||||
.context("Failed to parse MCP server config")?;
|
||||
|
||||
Ok(Self {
|
||||
mcp_id,
|
||||
mcp_json_config: Some(mcp_json_config),
|
||||
mcp_type,
|
||||
client_protocol,
|
||||
server_config: Some(server_config),
|
||||
})
|
||||
}
|
||||
}
|
||||
1292
qiming-mcp-proxy/mcp-proxy/src/model/mcp_router_model.rs
Normal file
1292
qiming-mcp-proxy/mcp-proxy/src/model/mcp_router_model.rs
Normal file
File diff suppressed because it is too large
Load Diff
22
qiming-mcp-proxy/mcp-proxy/src/model/mod.rs
Normal file
22
qiming-mcp-proxy/mcp-proxy/src/model/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
mod app_state_model;
|
||||
mod global;
|
||||
mod http_result;
|
||||
mod mcp_check_status_model;
|
||||
mod mcp_config;
|
||||
mod mcp_router_model;
|
||||
|
||||
pub use app_state_model::AppState;
|
||||
pub use global::{
|
||||
DynamicRouterService, GLOBAL_RESTART_TRACKER, McpServiceStatus, ProxyHandlerManager,
|
||||
get_proxy_manager,
|
||||
};
|
||||
pub use http_result::HttpResult;
|
||||
pub use mcp_check_status_model::{
|
||||
CheckMcpStatusRequestParams, CheckMcpStatusResponseParams, CheckMcpStatusResponseStatus,
|
||||
};
|
||||
pub use mcp_config::{McpConfig, McpType};
|
||||
pub use mcp_router_model::{
|
||||
AddRouteParams, GLOBAL_SSE_MCP_ROUTES_PREFIX, GLOBAL_STREAM_MCP_ROUTES_PREFIX,
|
||||
McpJsonServerParameters, McpProtocol, McpProtocolPath, McpRouterPath, McpServerCommandConfig,
|
||||
McpServerConfig,
|
||||
};
|
||||
78
qiming-mcp-proxy/mcp-proxy/src/proxy/mod.rs
Normal file
78
qiming-mcp-proxy/mcp-proxy/src/proxy/mod.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Proxy module - re-exports handler types from proxy libraries
|
||||
//!
|
||||
//! This module provides a unified interface for proxy handlers by re-exporting
|
||||
//! types from mcp-sse-proxy, mcp-streamable-proxy, and mcp-common libraries.
|
||||
|
||||
// Re-export SseHandler as ProxyHandler for backward compatibility
|
||||
// SseHandler is used because it's based on rmcp 0.10 which supports both
|
||||
// SSE server mode and CLI stdio mode used in the main project
|
||||
pub use mcp_sse_proxy::SseHandler as ProxyHandler;
|
||||
|
||||
// Re-export SseServerHandler (unified handler for SSE server mode)
|
||||
pub use mcp_sse_proxy::SseServerHandler;
|
||||
|
||||
// Re-export StreamProxyHandler with an alias to distinguish from SSE ProxyHandler
|
||||
// Both mcp-sse-proxy and mcp-streamable-proxy export ProxyHandler, so we use an alias
|
||||
pub use mcp_streamable_proxy::ProxyHandler as StreamProxyHandler;
|
||||
|
||||
// Re-export ToolFilter from mcp-common
|
||||
pub use mcp_common::ToolFilter;
|
||||
|
||||
// Re-export client connection types for high-level API (each from its own library)
|
||||
pub use mcp_sse_proxy::{McpClientConfig, SseClientConnection};
|
||||
pub use mcp_streamable_proxy::StreamClientConnection;
|
||||
|
||||
// Re-export Builder APIs for server creation
|
||||
pub use mcp_sse_proxy::server_builder::{BackendConfig as SseBackendConfig, SseServerBuilder};
|
||||
pub use mcp_streamable_proxy::server_builder::{
|
||||
BackendConfig as StreamBackendConfig, StreamServerBuilder,
|
||||
};
|
||||
|
||||
/// Unified handler enum that can hold either SSE or Stream handler
|
||||
///
|
||||
/// This allows ProxyHandlerManager to store handlers of either type
|
||||
/// while providing a common interface for status checks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum McpHandler {
|
||||
/// SSE protocol handler (from mcp-sse-proxy)
|
||||
/// Holds SseServerHandler which unifies SseHandler and BackendSessionHandler
|
||||
Sse(Box<SseServerHandler>),
|
||||
/// Streamable HTTP protocol handler (from mcp-streamable-proxy)
|
||||
Stream(Box<StreamProxyHandler>),
|
||||
}
|
||||
|
||||
impl McpHandler {
|
||||
/// Check if the underlying MCP server is ready
|
||||
pub async fn is_mcp_server_ready(&self) -> bool {
|
||||
match self {
|
||||
McpHandler::Sse(h) => h.is_mcp_server_ready().await,
|
||||
McpHandler::Stream(h) => h.is_mcp_server_ready().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the backend connection is terminated
|
||||
pub async fn is_terminated_async(&self) -> bool {
|
||||
match self {
|
||||
McpHandler::Sse(h) => h.is_terminated_async().await,
|
||||
McpHandler::Stream(h) => h.is_terminated_async().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProxyHandler> for McpHandler {
|
||||
fn from(handler: ProxyHandler) -> Self {
|
||||
McpHandler::Sse(Box::new(SseServerHandler::Sse(handler)))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SseServerHandler> for McpHandler {
|
||||
fn from(handler: SseServerHandler) -> Self {
|
||||
McpHandler::Sse(Box::new(handler))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StreamProxyHandler> for McpHandler {
|
||||
fn from(handler: StreamProxyHandler) -> Self {
|
||||
McpHandler::Stream(Box::new(handler))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use axum::extract::Path;
|
||||
use log::{info, warn};
|
||||
|
||||
use crate::{
|
||||
AppError, get_proxy_manager,
|
||||
model::{CheckMcpStatusResponseParams, CheckMcpStatusResponseStatus, HttpResult},
|
||||
};
|
||||
|
||||
///根据 mcpId,检查 mcp 透明代理服务是否正在运行的状态
|
||||
// #[axum::debug_handler]
|
||||
pub async fn check_mcp_is_status_handler(
|
||||
Path(mcp_id): Path<String>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
info!("mcp_id: {mcp_id}");
|
||||
|
||||
let status = get_proxy_manager()
|
||||
.get_mcp_service_status(&mcp_id)
|
||||
.map(|mcp_service_status| mcp_service_status.check_mcp_status_response_status.clone());
|
||||
|
||||
if let Some(status) = status {
|
||||
match status.clone() {
|
||||
CheckMcpStatusResponseStatus::Ready => Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(true, status, None),
|
||||
None,
|
||||
)),
|
||||
CheckMcpStatusResponseStatus::Pending => Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(false, status, None),
|
||||
None,
|
||||
)),
|
||||
CheckMcpStatusResponseStatus::Error(err) => Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(false, status, Some(err)),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
warn!("mcp_id: {mcp_id} does not exist");
|
||||
Ok(HttpResult::success(
|
||||
CheckMcpStatusResponseParams::new(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Error("mcp_id 不存在".to_string()),
|
||||
None,
|
||||
),
|
||||
None,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use axum::{extract::Path, response::IntoResponse};
|
||||
|
||||
use crate::{AppError, get_proxy_manager, model::HttpResult};
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
|
||||
// #[axum::debug_handler]
|
||||
pub async fn delete_route_handler(
|
||||
Path(mcp_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 删除动态路由,以及清理资源
|
||||
get_proxy_manager()
|
||||
.cleanup_resources(&mcp_id)
|
||||
.await
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
|
||||
// 返回成功信息
|
||||
let data = json!({
|
||||
"mcp_id": mcp_id,
|
||||
"message": format!("已删除路由: {}", mcp_id)
|
||||
});
|
||||
|
||||
Ok(HttpResult::success(data, None))
|
||||
}
|
||||
11
qiming-mcp-proxy/mcp-proxy/src/server/handlers/health.rs
Normal file
11
qiming-mcp-proxy/mcp-proxy/src/server/handlers/health.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::AppError;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
///健康检查:health
|
||||
pub async fn get_health() -> Result<impl IntoResponse, AppError> {
|
||||
Ok("health".to_string())
|
||||
}
|
||||
|
||||
pub async fn get_ready() -> Result<impl IntoResponse, AppError> {
|
||||
Ok("ready".to_string())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::{Json, extract::State, response::IntoResponse};
|
||||
use http::Uri;
|
||||
use log::{debug, error};
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppError;
|
||||
use crate::model::{
|
||||
AddRouteParams, HttpResult, McpConfig, McpProtocolPath, McpServerConfig, McpType,
|
||||
};
|
||||
use crate::model::{AppState, McpRouterPath};
|
||||
use crate::server::task::integrate_server_with_axum;
|
||||
use serde_json::json;
|
||||
|
||||
// 修改 add_route_handler 函数,使用新的集成方法
|
||||
#[instrument]
|
||||
// #[axum::debug_handler]
|
||||
pub async fn add_route_handler(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Json(params): Json<AddRouteParams>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// 获取请求路径
|
||||
let request_path = uri.path().to_string();
|
||||
debug!("Request path: {}", request_path);
|
||||
debug!("Full URI: {:?}", uri);
|
||||
|
||||
let mcp_protocol = McpRouterPath::from_uri_prefix_protocol(&request_path);
|
||||
if let Some(mcp_protocol) = mcp_protocol {
|
||||
// 生成mcp_id, 使用uuid,去掉-
|
||||
let mcp_id = Uuid::now_v7().to_string().replace("-", "");
|
||||
|
||||
//根据 mcp_id 和协议,生成 mcp_router_path
|
||||
let mcp_router_path = McpRouterPath::new(mcp_id, mcp_protocol)
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
|
||||
let mcp_plugin_json = params.mcp_json_config;
|
||||
// 将mcp_plugin_json转换为 McpServerConfig 结构体
|
||||
let mcp_server_config =
|
||||
McpServerConfig::try_from(mcp_plugin_json.clone()).expect("解析 MCP 配置失败");
|
||||
|
||||
let mcp_type = params.mcp_type.unwrap_or(McpType::default());
|
||||
|
||||
debug!("Client protocol: {:?}", mcp_router_path.mcp_protocol);
|
||||
|
||||
// 构建完整的 McpConfig(用于自动重启)
|
||||
let full_mcp_config = McpConfig {
|
||||
mcp_id: mcp_router_path.mcp_id.clone(),
|
||||
client_protocol: mcp_router_path.mcp_protocol.clone(),
|
||||
mcp_type: mcp_type.clone(),
|
||||
mcp_json_config: Some(mcp_plugin_json),
|
||||
server_config: Some(mcp_server_config.clone()),
|
||||
};
|
||||
|
||||
// 使用新的集成方法,后端协议在函数内部解析
|
||||
let _ = integrate_server_with_axum(
|
||||
mcp_server_config.clone(),
|
||||
mcp_router_path.clone(),
|
||||
full_mcp_config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to start MCP service: {e}");
|
||||
AppError::mcp_server_error(e.to_string())
|
||||
})?;
|
||||
|
||||
//区分 mcp协议
|
||||
let mcp_protocol = mcp_router_path.mcp_protocol_path.clone();
|
||||
|
||||
let data = match mcp_protocol {
|
||||
McpProtocolPath::SsePath(sse_path) => {
|
||||
// 返回 mcp_id 和 sse_path
|
||||
let data = json!({
|
||||
"mcp_id": mcp_router_path.mcp_id,
|
||||
"sse_path": sse_path.sse_path,
|
||||
"message_path": sse_path.message_path,
|
||||
"mcp_type": mcp_type
|
||||
});
|
||||
data
|
||||
}
|
||||
|
||||
McpProtocolPath::StreamPath(stream_path) => {
|
||||
// 返回 mcp_id 和 stream_path
|
||||
let data = json!({
|
||||
"mcp_id": mcp_router_path.mcp_id,
|
||||
"stream_path": stream_path.stream_path,
|
||||
"mcp_type": mcp_type
|
||||
});
|
||||
data
|
||||
}
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(data, None))
|
||||
} else {
|
||||
//返回 bad request,400 错误
|
||||
return Err(AppError::invalid_parameter("无效的请求路径"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use anyhow::Result;
|
||||
use axum::{Json, extract::State, http::uri::Uri};
|
||||
use log::{debug, error, info};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
AppError, get_proxy_manager,
|
||||
model::{
|
||||
AppState, CheckMcpStatusRequestParams, CheckMcpStatusResponseParams,
|
||||
CheckMcpStatusResponseStatus, GLOBAL_RESTART_TRACKER, HttpResult, McpConfig, McpProtocol,
|
||||
McpRouterPath, McpServiceStatus, McpType,
|
||||
},
|
||||
server::mcp_start_task,
|
||||
};
|
||||
|
||||
/// 创建响应结果的辅助函数
|
||||
fn create_response(
|
||||
ready: bool,
|
||||
status: CheckMcpStatusResponseStatus,
|
||||
message: Option<String>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
let response = CheckMcpStatusResponseParams::new(ready, status, message);
|
||||
|
||||
Ok(HttpResult::success(response, None))
|
||||
}
|
||||
|
||||
/// 检查mcp服务状态,根据 mcp_id 获取有无对应的mcp透明代理服务,如果没有则取 mcp_json_config 中的配置,生成mcp透明代理服务;
|
||||
/// 这里根据 mcp_json_config配置,启动服务需要异步,不要阻塞,如果服务没准备好,返回 PENDING 状态;
|
||||
/// 如果服务启动失败,返回 ERROR 状态;
|
||||
/// 如果服务启动成功,返回 READY 状态;
|
||||
#[instrument]
|
||||
pub async fn check_mcp_status_handler(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Json(params): Json<CheckMcpStatusRequestParams>,
|
||||
mcp_protocol: McpProtocol,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
// 使用全局 ProxyHandlerManager
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// 检查服务状态
|
||||
let status = proxy_manager
|
||||
.get_mcp_service_status(¶ms.mcp_id)
|
||||
.map(|mcp_service_status| mcp_service_status.check_mcp_status_response_status.clone());
|
||||
|
||||
if let Some(status) = status {
|
||||
match status {
|
||||
CheckMcpStatusResponseStatus::Error(error_msg) => {
|
||||
// 如果有错误状态,返回错误信息,另外删除掉 ERROR 的记录,方便下次检查状态,重新启动服务
|
||||
// Error 状态不更新 last_accessed,因为服务已失败
|
||||
if let Err(e) = proxy_manager.cleanup_resources(¶ms.mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", params.mcp_id, e);
|
||||
}
|
||||
// 返回错误信息
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Error(error_msg),
|
||||
None,
|
||||
);
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Pending => {
|
||||
// 如果状态是 Pending,说明服务正在启动中,直接返回 Pending
|
||||
// 不要再次尝试启动!
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} status is Pending and the service is starting",
|
||||
params.mcp_id
|
||||
);
|
||||
// 更新最后访问时间,避免启动过程中因超时被清理
|
||||
// 用户调用 check_status 表明对服务有兴趣,应该延长超时
|
||||
proxy_manager.update_last_accessed(¶ms.mcp_id);
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
);
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Ready => {
|
||||
// 如果已经在运行,继续检查服务是否真的可用
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} status is Ready, check the backend health status",
|
||||
params.mcp_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 proxy_handler 是否存在
|
||||
let proxy_handler = proxy_manager.get_proxy_handler(¶ms.mcp_id);
|
||||
|
||||
if let Some(handler) = proxy_handler {
|
||||
// 调用透明代理的 is_mcp_server_ready 方法检查健康状态
|
||||
let ready_status = handler.is_mcp_server_ready().await;
|
||||
|
||||
// 使用 update 方法更新状态(不是修改克隆)
|
||||
if ready_status {
|
||||
proxy_manager
|
||||
.update_mcp_service_status(¶ms.mcp_id, CheckMcpStatusResponseStatus::Ready);
|
||||
}
|
||||
// 更新最后访问时间
|
||||
proxy_manager.update_last_accessed(¶ms.mcp_id);
|
||||
|
||||
let status = if ready_status {
|
||||
CheckMcpStatusResponseStatus::Ready
|
||||
} else {
|
||||
CheckMcpStatusResponseStatus::Pending
|
||||
};
|
||||
|
||||
return create_response(ready_status, status, None);
|
||||
}
|
||||
|
||||
// ===== 服务不存在,需要启动 =====
|
||||
// 使用启动锁防止并发启动同一服务
|
||||
let _startup_guard = match GLOBAL_RESTART_TRACKER.try_acquire_startup_lock(¶ms.mcp_id) {
|
||||
Some(guard) => {
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} Obtained the startup lock successfully and started to start the service",
|
||||
params.mcp_id
|
||||
);
|
||||
guard
|
||||
}
|
||||
None => {
|
||||
// 锁被占用,服务正在启动中
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} The startup lock is occupied and the service is starting. Return to Pending.",
|
||||
params.mcp_id
|
||||
);
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 双重检查:获取锁后再次检查服务是否已存在
|
||||
if proxy_manager.get_proxy_handler(¶ms.mcp_id).is_some() {
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} Double check found that the service already exists, return Ready",
|
||||
params.mcp_id
|
||||
);
|
||||
return create_response(true, CheckMcpStatusResponseStatus::Ready, None);
|
||||
}
|
||||
|
||||
// 如果服务状态已存在(可能是 Pending),也不要重复启动
|
||||
if proxy_manager
|
||||
.get_mcp_service_status(¶ms.mcp_id)
|
||||
.is_some()
|
||||
{
|
||||
debug!(
|
||||
"[check_mcp_status] mcp_id={} The service status already exists and may be starting. Return to Pending.",
|
||||
params.mcp_id
|
||||
);
|
||||
return create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// 启动服务(持有锁的情况下)
|
||||
spawn_mcp_service(
|
||||
¶ms.mcp_id,
|
||||
params.mcp_json_config,
|
||||
params.mcp_type,
|
||||
mcp_protocol.clone(),
|
||||
)?;
|
||||
|
||||
// 返回 PENDING 状态,锁会在函数返回时自动释放
|
||||
create_response(
|
||||
false,
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
Some("服务正在启动中...".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
// SSE协议专用的状态检查处理函数
|
||||
#[instrument]
|
||||
// #[axum::debug_handler]
|
||||
pub async fn check_mcp_status_handler_sse(
|
||||
state: State<AppState>,
|
||||
uri: Uri,
|
||||
params: Json<CheckMcpStatusRequestParams>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
check_mcp_status_handler(state, uri, params, McpProtocol::Sse).await
|
||||
}
|
||||
|
||||
// Stream协议专用的状态检查处理函数
|
||||
#[instrument]
|
||||
// #[axum::debug_handler]
|
||||
pub async fn check_mcp_status_handler_stream(
|
||||
state: State<AppState>,
|
||||
uri: Uri,
|
||||
params: Json<CheckMcpStatusRequestParams>,
|
||||
) -> Result<HttpResult<CheckMcpStatusResponseParams>, AppError> {
|
||||
check_mcp_status_handler(state, uri, params, McpProtocol::Stream).await
|
||||
}
|
||||
|
||||
/// 异步启动MCP服务
|
||||
///
|
||||
/// 注意:此函数假设调用方已持有启动锁,不会重复检查!
|
||||
///
|
||||
/// # 参数
|
||||
/// - `mcp_id`: MCP服务的唯一标识
|
||||
/// - `mcp_json_config`: MCP服务的JSON配置
|
||||
/// - `mcp_type`: MCP服务类型(OneShot或Persistent)
|
||||
/// - `client_protocol`: 客户端使用的协议(决定暴露的API接口类型)
|
||||
fn spawn_mcp_service(
|
||||
mcp_id: &str,
|
||||
mcp_json_config: String,
|
||||
mcp_type: McpType,
|
||||
client_protocol: McpProtocol,
|
||||
) -> Result<(), AppError> {
|
||||
let mcp_id = mcp_id.to_string();
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} Start starting the service",
|
||||
mcp_id
|
||||
);
|
||||
|
||||
// 使用全局 ProxyHandlerManager
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// 设置初始化状态 - 使用客户端协议创建路由路径
|
||||
let mcp_router_path = McpRouterPath::new(mcp_id.clone(), client_protocol.clone())
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
let mcp_service_status = McpServiceStatus::new(
|
||||
mcp_id.clone(),
|
||||
mcp_type.clone(),
|
||||
mcp_router_path,
|
||||
CancellationToken::new(),
|
||||
CheckMcpStatusResponseStatus::Pending,
|
||||
);
|
||||
|
||||
// RAII: 如果已存在同名服务,会自动清理旧服务
|
||||
proxy_manager.add_mcp_service_status_and_proxy(mcp_service_status, None);
|
||||
|
||||
// 异步启动 mcp 透明代理服务
|
||||
let mcp_id_clone = mcp_id.clone();
|
||||
|
||||
// 使用客户端协议创建配置
|
||||
let mcp_config: McpConfig = McpConfig::new(
|
||||
mcp_id_clone.clone(),
|
||||
Some(mcp_json_config),
|
||||
mcp_type,
|
||||
client_protocol,
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} tokio::spawn starts executing mcp_start_task",
|
||||
mcp_id_clone
|
||||
);
|
||||
match mcp_start_task(mcp_config).await {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} mcp_start_task successful, set status to Ready",
|
||||
mcp_id_clone
|
||||
);
|
||||
get_proxy_manager()
|
||||
.update_mcp_service_status(&mcp_id_clone, CheckMcpStatusResponseStatus::Ready);
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("启动MCP服务失败: {e}");
|
||||
error!(
|
||||
"[spawn_mcp_service] mcp_id={} mcp_start_task failed: {}",
|
||||
mcp_id_clone, e
|
||||
);
|
||||
get_proxy_manager().update_mcp_service_status(
|
||||
&mcp_id_clone,
|
||||
CheckMcpStatusResponseStatus::Error(error_msg),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
"[spawn_mcp_service] mcp_id={} Service startup task has been submitted",
|
||||
mcp_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
13
qiming-mcp-proxy/mcp-proxy/src/server/handlers/mod.rs
Normal file
13
qiming-mcp-proxy/mcp-proxy/src/server/handlers/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
mod check_mcp_is_status;
|
||||
mod delete_route_handler;
|
||||
mod health;
|
||||
mod mcp_add_handler;
|
||||
mod mcp_check_status_handler;
|
||||
pub mod run_code_handler;
|
||||
pub use check_mcp_is_status::check_mcp_is_status_handler;
|
||||
pub use delete_route_handler::delete_route_handler;
|
||||
pub use health::get_health;
|
||||
pub use health::get_ready;
|
||||
pub use mcp_add_handler::add_route_handler;
|
||||
pub use mcp_check_status_handler::{check_mcp_status_handler_sse, check_mcp_status_handler_stream};
|
||||
pub use run_code_handler::run_code_handler;
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use http::StatusCode;
|
||||
use log::{debug, error, info};
|
||||
use run_code_rmcp::{CodeExecutor, LanguageScript, RunCodeHttpResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
///代码运行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunCodeMessageRequest {
|
||||
//js运行参数
|
||||
pub json_param: HashMap<String, Value>,
|
||||
//运行的代码
|
||||
pub code: String,
|
||||
//前端生成的随机uid,用于查找websocket连接,发送执行过程中的log日志
|
||||
pub uid: String,
|
||||
pub engine_type: String,
|
||||
}
|
||||
|
||||
impl RunCodeMessageRequest {
|
||||
//获取语言脚本
|
||||
pub fn get_language_script(&self) -> LanguageScript {
|
||||
match self.engine_type.as_str() {
|
||||
"js" => LanguageScript::Js,
|
||||
"ts" => LanguageScript::Ts,
|
||||
"python" => LanguageScript::Python,
|
||||
_ => LanguageScript::Js,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行js/ts/python代码,通过 uv/deno 命令方式执行
|
||||
// #[axum::debug_handler]
|
||||
pub async fn run_code_handler(
|
||||
Json(run_code_message_request): Json<RunCodeMessageRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
//json_param: HashMap<String, Value> 转换为 json 对象 Value
|
||||
let params = match serde_json::to_value(run_code_message_request.json_param.clone()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Run_code_handler parameter serialization failed: {e:?}");
|
||||
return Err(AppError::from(e));
|
||||
}
|
||||
};
|
||||
|
||||
//执行代码
|
||||
let code = run_code_message_request.code.clone();
|
||||
let language = run_code_message_request.get_language_script();
|
||||
|
||||
debug!("run_code_handler language:{language:?}");
|
||||
debug!("run_code_handler code:{code:?}");
|
||||
debug!("run_code_handler params:{params:?}");
|
||||
let result = match CodeExecutor::execute_with_params(&code, language, Some(params), None).await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("run_code_handler execution failed: {e:?}");
|
||||
return Err(AppError::from(e));
|
||||
}
|
||||
};
|
||||
|
||||
if !result.success {
|
||||
let error_message = result
|
||||
.error
|
||||
.as_deref()
|
||||
.unwrap_or("run_code_handler执行失败但未提供错误信息");
|
||||
error!("run_code_handler execution returns failure: {error_message}");
|
||||
}
|
||||
|
||||
let data = match serde_json::to_value(&result) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("run_code_handler serialization result failed: {e:?}");
|
||||
return Err(AppError::from(e));
|
||||
}
|
||||
};
|
||||
//打印结果
|
||||
info!("run_code_handler result:{:?}", &result.success);
|
||||
debug!("run_code_handler result:{:?}", &data);
|
||||
//返回结果,使用 RunCodeHttpResult 封装执行结果
|
||||
let run_code_http_result = RunCodeHttpResult {
|
||||
data,
|
||||
success: result.success,
|
||||
error: result.error.clone(),
|
||||
};
|
||||
let body = serde_json::to_string(&run_code_http_result)?;
|
||||
Ok((StatusCode::OK, body).into_response())
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use log::{debug, error, info, warn};
|
||||
use tower::Service;
|
||||
|
||||
use crate::{
|
||||
DynamicRouterService, get_proxy_manager, mcp_start_task,
|
||||
model::{
|
||||
CheckMcpStatusResponseStatus, GLOBAL_RESTART_TRACKER, HttpResult, McpConfig, McpRouterPath,
|
||||
McpType,
|
||||
},
|
||||
server::middlewares::extract_trace_id,
|
||||
};
|
||||
|
||||
impl Service<Request<Body>> for DynamicRouterService {
|
||||
type Response = Response;
|
||||
type Error = Infallible;
|
||||
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<Body>) -> Self::Future {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
let headers = req.headers().clone();
|
||||
|
||||
// DEBUG: 详细路径解析日志
|
||||
debug!("=== Path analysis begins ===");
|
||||
debug!("Original request path: {}", path);
|
||||
debug!("Path contains wildcard parameters: {:?}", req.extensions());
|
||||
|
||||
// 提取 trace_id
|
||||
let trace_id = extract_trace_id();
|
||||
|
||||
// 创建根 span (使用 debug_span 减少日志量)
|
||||
let span = tracing::debug_span!(
|
||||
"DynamicRouterService",
|
||||
otel.name = "HTTP Request",
|
||||
http.method = %method,
|
||||
http.route = %path,
|
||||
http.url = %req.uri(),
|
||||
mcp.protocol = format!("{:?}", self.0),
|
||||
trace_id = %trace_id,
|
||||
);
|
||||
|
||||
// 记录请求头信息
|
||||
if let Some(content_type) = headers.get("content-type") {
|
||||
span.record("http.request.content_type", format!("{:?}", content_type));
|
||||
}
|
||||
if let Some(content_length) = headers.get("content-length") {
|
||||
span.record(
|
||||
"http.request.content_length",
|
||||
format!("{:?}", content_length),
|
||||
);
|
||||
}
|
||||
|
||||
debug!("Request path: {path}");
|
||||
|
||||
// 解析路由路径
|
||||
let mcp_router_path = McpRouterPath::from_url(&path);
|
||||
|
||||
match mcp_router_path {
|
||||
Some(mcp_router_path) => {
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
let base_path = mcp_router_path.base_path.clone();
|
||||
|
||||
span.record("mcp.id", &mcp_id);
|
||||
span.record("mcp.base_path", &base_path);
|
||||
|
||||
debug!("=== Path analysis results ===");
|
||||
debug!("Parsed mcp_id: {}", mcp_id);
|
||||
debug!("Parsed base_path: {}", base_path);
|
||||
debug!("Request path: {} vs base_path: {}", path, base_path);
|
||||
debug!("=== Path analysis ends ===");
|
||||
|
||||
Box::pin(async move {
|
||||
let _guard = span.enter();
|
||||
|
||||
// 先尝试查找已注册的路由
|
||||
debug!("===Route lookup process ===");
|
||||
debug!("Find base_path: '{}'", base_path);
|
||||
|
||||
if let Some(router_entry) = DynamicRouterService::get_route(&base_path) {
|
||||
debug!(
|
||||
"✅ Find the registered route: base_path={}, path={}",
|
||||
base_path, path
|
||||
);
|
||||
|
||||
// ===== 检查后端健康状态 =====
|
||||
let mcp_id_for_check = McpRouterPath::from_url(&path);
|
||||
if let Some(router_path) = mcp_id_for_check {
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// ===== 首先检查服务状态 =====
|
||||
// 如果服务状态是 Pending,说明服务正在初始化中(uvx/npx 下载中)
|
||||
// 此时不应该做健康检查,应该等待
|
||||
if let Some(service_status) =
|
||||
proxy_manager.get_mcp_service_status(&router_path.mcp_id)
|
||||
{
|
||||
match &service_status.check_mcp_status_response_status {
|
||||
CheckMcpStatusResponseStatus::Pending => {
|
||||
debug!(
|
||||
"[MCP status check] mcp_id={} The status is Pending, the service is being initialized, and 503 is returned.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let message = format!(
|
||||
"服务 {} 正在初始化中,请稍后再试",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Error(err) => {
|
||||
// Error 状态:只清理,不重启
|
||||
// 避免有问题的 MCP 服务无限重启循环
|
||||
warn!(
|
||||
"[MCP status check] mcp_id={} status is Error: {}, clean up resources and return error",
|
||||
router_path.mcp_id, err
|
||||
);
|
||||
// 清理资源
|
||||
if let Err(e) = proxy_manager
|
||||
.cleanup_resources(&router_path.mcp_id)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"[MCP status check] mcp_id={} Failed to clean up resources: {}",
|
||||
router_path.mcp_id, e
|
||||
);
|
||||
}
|
||||
// 返回错误,不尝试重启
|
||||
let message = format!(
|
||||
"服务 {} 启动失败: {}",
|
||||
router_path.mcp_id, err
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0005", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
CheckMcpStatusResponseStatus::Ready => {
|
||||
debug!(
|
||||
"[MCP status check] mcp_id={} status is Ready, continue to check the backend health status",
|
||||
router_path.mcp_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 检查启动锁状态 =====
|
||||
// 如果锁被占用,说明服务正在启动中
|
||||
let startup_guard = GLOBAL_RESTART_TRACKER
|
||||
.try_acquire_startup_lock(&router_path.mcp_id);
|
||||
|
||||
if startup_guard.is_none() {
|
||||
// 锁被占用,服务正在启动中,返回 503
|
||||
debug!(
|
||||
"[Startup lock check] mcp_id={} The startup lock is occupied, the service is starting, and 503 is returned.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
span.record("mcp.startup_in_progress", true);
|
||||
let message =
|
||||
format!("服务 {} 正在启动中,请稍后再试", router_path.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
span.record("http.response.status_code", 503u16);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 获取到锁,现在可以安全地检查健康状态
|
||||
let _startup_guard = startup_guard.unwrap();
|
||||
debug!(
|
||||
"[Start lock check] mcp_id={} Successfully obtained the startup lock and started health check",
|
||||
router_path.mcp_id
|
||||
);
|
||||
|
||||
if let Some(handler) =
|
||||
proxy_manager.get_proxy_handler(&router_path.mcp_id)
|
||||
{
|
||||
// ===== 健康检查(带缓存)=====
|
||||
let is_healthy = if let Some(cached) = GLOBAL_RESTART_TRACKER
|
||||
.get_cached_health_status(&router_path.mcp_id)
|
||||
{
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} Use cache status: is_healthy={}",
|
||||
router_path.mcp_id, cached
|
||||
);
|
||||
cached
|
||||
} else {
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} Cache miss, start actual health check...",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let status = handler.is_mcp_server_ready().await;
|
||||
GLOBAL_RESTART_TRACKER
|
||||
.update_health_status(&router_path.mcp_id, status);
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} Actual health check result: is_healthy={}",
|
||||
router_path.mcp_id, status
|
||||
);
|
||||
status
|
||||
};
|
||||
|
||||
if is_healthy {
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} The backend service is normal, release the lock and use routing",
|
||||
router_path.mcp_id
|
||||
);
|
||||
// 释放锁,使用路由
|
||||
drop(_startup_guard);
|
||||
debug!("=== Route search ended (successful) ===");
|
||||
return handle_request_with_router(req, router_entry, &path)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 不健康,获取服务类型以决定是否重启
|
||||
let mcp_type = proxy_manager
|
||||
.get_mcp_service_status(&router_path.mcp_id)
|
||||
.map(|s| s.mcp_type.clone());
|
||||
|
||||
// 清理资源
|
||||
warn!(
|
||||
"[Health check] mcp_id={} The backend service is unhealthy, clean up resources.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
if let Err(e) =
|
||||
proxy_manager.cleanup_resources(&router_path.mcp_id).await
|
||||
{
|
||||
error!(
|
||||
"[Clean up resources] mcp_id={} Failed to clean up resources: error={}",
|
||||
router_path.mcp_id, e
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[Clean up resources] mcp_id={} Clean up resources successfully",
|
||||
router_path.mcp_id
|
||||
);
|
||||
}
|
||||
|
||||
// OneShot 类型:只清理,不重启
|
||||
// OneShot 服务执行完成后进程会退出,这是正常行为,不应该自动重启
|
||||
// 用户需要通过 check_status 接口显式启动新的 OneShot 服务
|
||||
if matches!(mcp_type, Some(McpType::OneShot)) {
|
||||
debug!(
|
||||
"[Health Check] mcp_id={} is a OneShot type, does not automatically restart, and returns that the service has ended",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let message = format!(
|
||||
"OneShot 服务 {} 已结束,请重新启动",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0006", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// Persistent 类型:清理后重启
|
||||
info!(
|
||||
"[Restart process] mcp_id={} is Persistent type, start to restart the service",
|
||||
router_path.mcp_id
|
||||
);
|
||||
|
||||
// 从配置获取 mcp_config 并启动服务
|
||||
// 优先从请求 header 获取配置
|
||||
if let Some(mcp_config) =
|
||||
req.extensions().get::<McpConfig>().cloned()
|
||||
&& mcp_config.mcp_json_config.is_some()
|
||||
{
|
||||
info!(
|
||||
"[Restart process] mcp_id={} Use the request header to configure the restart service",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
proxy_manager
|
||||
.register_mcp_config(&mcp_config.mcp_id, mcp_config.clone())
|
||||
.await;
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 从缓存获取配置
|
||||
if let Some(mcp_config) = proxy_manager
|
||||
.get_mcp_config_from_cache(&router_path.mcp_id)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
"[Restart process] mcp_id={} Restart the service using cache configuration",
|
||||
router_path.mcp_id
|
||||
);
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 无法获取配置
|
||||
warn!(
|
||||
"[Restart Process] mcp_id={} Unable to obtain the configuration and unable to restart the service",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let message =
|
||||
format!("服务 {} 不健康且无法获取配置", router_path.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0004", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
} else {
|
||||
// handler 不存在,但路由存在
|
||||
// 检查服务类型,OneShot 不自动重启
|
||||
let mcp_type = proxy_manager
|
||||
.get_mcp_service_status(&router_path.mcp_id)
|
||||
.map(|s| s.mcp_type.clone());
|
||||
|
||||
if matches!(mcp_type, Some(McpType::OneShot)) {
|
||||
debug!(
|
||||
"[Service Check] mcp_id={} is OneShot type and the handler does not exist, so it will not restart automatically.",
|
||||
router_path.mcp_id
|
||||
);
|
||||
// 清理残留状态
|
||||
if let Err(e) =
|
||||
proxy_manager.cleanup_resources(&router_path.mcp_id).await
|
||||
{
|
||||
error!(
|
||||
"[Clean up resources] mcp_id={} Failed to clean up resources: {}",
|
||||
router_path.mcp_id, e
|
||||
);
|
||||
}
|
||||
let message = format!(
|
||||
"OneShot 服务 {} 已结束,请重新启动",
|
||||
router_path.mcp_id
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0006", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// Persistent 类型:继续进入启动流程
|
||||
warn!(
|
||||
"The route exists but the handler does not exist. Enter the restart process: base_path={}",
|
||||
base_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 无法解析路由路径,直接使用路由
|
||||
debug!("=== Route search ended (successful) ===");
|
||||
return handle_request_with_router(req, router_entry, &path).await;
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"❌ No registered route found: base_path='{}', path='{}'",
|
||||
base_path, path
|
||||
);
|
||||
|
||||
// 显示所有已注册的路由
|
||||
let all_routes = DynamicRouterService::get_all_routes();
|
||||
debug!("Currently registered route: {:?}", all_routes);
|
||||
debug!("=== Route search ended (failed) ===");
|
||||
}
|
||||
|
||||
// 未找到路由,尝试启动服务
|
||||
warn!(
|
||||
"No matching path found, try to start the service: base_path={base_path}, path={path}"
|
||||
);
|
||||
span.record("error.route_not_found", true);
|
||||
|
||||
// ===== 提前解析 mcp_id 用于配置获取 =====
|
||||
let mcp_router_path_for_config = McpRouterPath::from_url(&path);
|
||||
|
||||
// ===== 配置获取优先级 =====
|
||||
let proxy_manager = get_proxy_manager();
|
||||
|
||||
// 优先级 1: 从请求 header 中获取配置(最新)
|
||||
if let Some(mcp_config) = req.extensions().get::<McpConfig>().cloned()
|
||||
&& mcp_config.mcp_json_config.is_some()
|
||||
{
|
||||
// 检查重启限制(防止无限循环)
|
||||
if !GLOBAL_RESTART_TRACKER.can_restart(&mcp_config.mcp_id) {
|
||||
warn!(
|
||||
"Service {} skips startup during restart cooldown period",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
span.record("error.restart_in_cooldown", true);
|
||||
let message =
|
||||
format!("服务 {} 在重启冷却期内,请稍后再试", mcp_config.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0002", &message, None);
|
||||
span.record("http.response.status_code", 429u16); // Too Many Requests
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 尝试获取启动锁,防止并发启动同一服务
|
||||
let _startup_guard = match GLOBAL_RESTART_TRACKER
|
||||
.try_acquire_startup_lock(&mcp_config.mcp_id)
|
||||
{
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
warn!(
|
||||
"Service {} is starting, skip this startup",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
span.record("error.startup_in_progress", true);
|
||||
let message =
|
||||
format!("服务 {} 正在启动中,请稍后再试", mcp_config.mcp_id);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
span.record("http.response.status_code", 503u16); // Service Unavailable
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Use request header configuration to start the service: {}",
|
||||
mcp_config.mcp_id
|
||||
);
|
||||
// 同时更新缓存
|
||||
proxy_manager
|
||||
.register_mcp_config(&mcp_config.mcp_id, mcp_config.clone())
|
||||
.await;
|
||||
|
||||
// _startup_guard 会在作用域结束时自动释放
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 优先级 2: 从 moka 缓存中获取配置(兜底)
|
||||
// 注意:OneShot 类型不从缓存自动启动,需要用户显式请求(带 header 配置)
|
||||
if let Some(mcp_id_for_cache) =
|
||||
mcp_router_path_for_config.as_ref().map(|p| &p.mcp_id)
|
||||
&& let Some(mcp_config) = proxy_manager
|
||||
.get_mcp_config_from_cache(mcp_id_for_cache)
|
||||
.await
|
||||
{
|
||||
// OneShot 类型不从缓存自动启动
|
||||
// 避免已回收的 OneShot 服务被意外重启
|
||||
if matches!(mcp_config.mcp_type, McpType::OneShot) {
|
||||
info!(
|
||||
"[Startup check] mcp_id={} is a OneShot type, does not automatically start from the cache, and requires an explicit request from the user",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
let message = format!(
|
||||
"OneShot 服务 {} 需要通过 check_status 接口启动",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0007", &message, None);
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 检查重启限制(防止无限循环)
|
||||
if !GLOBAL_RESTART_TRACKER.can_restart(mcp_id_for_cache) {
|
||||
warn!(
|
||||
"Service {} skips startup during restart cooldown period",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
span.record("error.restart_in_cooldown", true);
|
||||
let message =
|
||||
format!("服务 {} 在重启冷却期内,请稍后再试", mcp_id_for_cache);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0002", &message, None);
|
||||
span.record("http.response.status_code", 429u16); // Too Many Requests
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
|
||||
// 尝试获取启动锁,防止并发启动同一服务
|
||||
let _startup_guard = match GLOBAL_RESTART_TRACKER
|
||||
.try_acquire_startup_lock(mcp_id_for_cache)
|
||||
{
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
warn!(
|
||||
"Service {} is starting, skip this startup",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
span.record("error.startup_in_progress", true);
|
||||
let message =
|
||||
format!("服务 {} 正在启动中,请稍后再试", mcp_id_for_cache);
|
||||
let http_result: HttpResult<String> =
|
||||
HttpResult::error("0003", &message, None);
|
||||
span.record("http.response.status_code", 503u16); // Service Unavailable
|
||||
return Ok(http_result.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"Start the service using cached configuration: {}",
|
||||
mcp_id_for_cache
|
||||
);
|
||||
// _startup_guard 会在作用域结束时自动释放
|
||||
return start_mcp_and_handle_request(req, mcp_config).await;
|
||||
}
|
||||
|
||||
// 优先级 3: 无法获取配置,返回错误
|
||||
warn!(
|
||||
"No matching path was found, and the configuration was not obtained, so the MCP service could not be started: {path}"
|
||||
);
|
||||
span.record("error.mcp_config_missing", true);
|
||||
|
||||
let message =
|
||||
format!("未找到匹配的路径,且未获取到配置,无法启动MCP服务: {path}");
|
||||
let http_result: HttpResult<String> = HttpResult::error("0001", &message, None);
|
||||
span.record("http.response.status_code", 404u16);
|
||||
span.record("error.message", &message);
|
||||
Ok(http_result.into_response())
|
||||
})
|
||||
}
|
||||
None => {
|
||||
warn!("Request path resolution failed: {path}");
|
||||
span.record("error.path_parse_failed", true);
|
||||
|
||||
let message = format!("请求路径解析失败: {path}");
|
||||
let http_result: HttpResult<String> = HttpResult::error("0001", &message, None);
|
||||
Box::pin(async move {
|
||||
let _guard = span.enter();
|
||||
span.record("http.response.status_code", 400u16);
|
||||
span.record("error.message", &message);
|
||||
Ok(http_result.into_response())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用给定的路由处理请求
|
||||
async fn handle_request_with_router(
|
||||
req: Request<Body>,
|
||||
router_entry: axum::Router,
|
||||
path: &str,
|
||||
) -> Result<Response, Infallible> {
|
||||
// 获取匹配路径的Router,并处理请求
|
||||
let trace_id = extract_trace_id();
|
||||
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
|
||||
info!(
|
||||
"[handle_request_with_router] Handle request: {} {}",
|
||||
method, path
|
||||
);
|
||||
|
||||
// 记录请求头中的关键信息
|
||||
if let Some(content_type) = req.headers().get("content-type")
|
||||
&& let Ok(content_type_str) = content_type.to_str()
|
||||
{
|
||||
debug!(
|
||||
"[handle_request_with_router] Content-Type: {}",
|
||||
content_type_str
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(content_length) = req.headers().get("content-length")
|
||||
&& let Ok(content_length_str) = content_length.to_str()
|
||||
{
|
||||
debug!(
|
||||
"[handle_request_with_router] Content-Length: {}",
|
||||
content_length_str
|
||||
);
|
||||
}
|
||||
|
||||
// 记录 x-mcp-json 头信息(如果存在)
|
||||
if let Some(mcp_json) = req.headers().get("x-mcp-json")
|
||||
&& let Ok(mcp_json_str) = mcp_json.to_str()
|
||||
{
|
||||
debug!(
|
||||
"[handle_request_with_router] MCP-JSON Header: {}",
|
||||
mcp_json_str
|
||||
);
|
||||
}
|
||||
|
||||
// 记录查询参数
|
||||
if let Some(query) = uri.query() {
|
||||
debug!("[handle_request_with_router] Query: {}", query);
|
||||
}
|
||||
|
||||
// 使用 debug_span 减少日志量,因为 DynamicRouterService 已经记录了请求信息
|
||||
// 移除 #[tracing::instrument] 避免 span 嵌套导致的日志膨胀问题
|
||||
let span = tracing::debug_span!(
|
||||
"handle_request_with_router",
|
||||
otel.name = "Handle Request with Router",
|
||||
component = "router",
|
||||
trace_id = %trace_id,
|
||||
http.method = %method,
|
||||
http.path = %path,
|
||||
);
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let mut service = router_entry.into_service();
|
||||
match service.call(req).await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
|
||||
// 记录响应头信息
|
||||
debug!(
|
||||
"[handle_request_with_router]Response status: {}, response header: {response:?}",
|
||||
status
|
||||
);
|
||||
|
||||
span.record("http.response.status_code", status.as_u16());
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => {
|
||||
span.record("error.router_service_error", true);
|
||||
span.record("error.message", format!("{:?}", error));
|
||||
error!("[handle_request_with_router] error: {error:?}");
|
||||
Ok(axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动MCP服务并处理请求
|
||||
async fn start_mcp_and_handle_request(
|
||||
req: Request<Body>,
|
||||
mcp_config: McpConfig,
|
||||
) -> Result<Response, Infallible> {
|
||||
let request_path = req.uri().path().to_string();
|
||||
let trace_id = extract_trace_id();
|
||||
debug!("Request path: {request_path}");
|
||||
|
||||
// 使用 debug_span 减少日志量,移除 #[tracing::instrument] 避免 span 嵌套
|
||||
let span = tracing::debug_span!(
|
||||
"start_mcp_and_handle_request",
|
||||
otel.name = "Start MCP and Handle Request",
|
||||
component = "mcp_startup",
|
||||
mcp.id = %mcp_config.mcp_id,
|
||||
mcp.type = ?mcp_config.mcp_type,
|
||||
mcp.config.has_config = mcp_config.mcp_json_config.is_some(),
|
||||
trace_id = %trace_id,
|
||||
);
|
||||
|
||||
let _guard = span.enter();
|
||||
|
||||
let ret = mcp_start_task(mcp_config).await;
|
||||
|
||||
if let Ok((router, _)) = ret {
|
||||
span.record("mcp.startup.success", true);
|
||||
handle_request_with_router(req, router, &request_path).await
|
||||
} else {
|
||||
span.record("mcp.startup.failed", true);
|
||||
span.record("error.mcp_startup_failed", true);
|
||||
span.record("error.message", format!("{:?}", ret));
|
||||
warn!("MCP service startup failed: {ret:?}");
|
||||
Ok(axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
}
|
||||
177
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/auth.rs
Normal file
177
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/auth.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
// use super::TokenVerify;
|
||||
// use axum::{
|
||||
// extract::{FromRequestParts, Query, Request, State},
|
||||
// http::{request::Parts, StatusCode},
|
||||
// middleware::Next,
|
||||
// response::{IntoResponse, Response},
|
||||
// };
|
||||
// use axum_extra::{
|
||||
// headers::{authorization::Bearer, Authorization},
|
||||
// TypedHeader,
|
||||
// };
|
||||
// use serde::Deserialize;
|
||||
// use tracing::warn;
|
||||
//
|
||||
// #[derive(Debug, Deserialize)]
|
||||
// struct Params {
|
||||
// token: String,
|
||||
// }
|
||||
//
|
||||
// pub async fn verify_token<T>(State(state): State<T>, req: Request, next: Next) -> Response
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// let (mut parts, body) = req.into_parts();
|
||||
// match extract_token(&state, &mut parts).await {
|
||||
// Ok(token) => {
|
||||
// let mut req = Request::from_parts(parts, body);
|
||||
// match set_user(&state, &token, &mut req) {
|
||||
// Ok(_) => next.run(req).await,
|
||||
// Err(msg) => (StatusCode::FORBIDDEN, msg).into_response(),
|
||||
// }
|
||||
// }
|
||||
// Err(msg) => (StatusCode::UNAUTHORIZED, msg).into_response(),
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// pub async fn extract_user<T>(State(state): State<T>, req: Request, next: Next) -> Response
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// let (mut parts, body) = req.into_parts();
|
||||
// let req = if let Ok(token) = extract_token(&state, &mut parts).await {
|
||||
// let mut req = Request::from_parts(parts, body);
|
||||
// let _ = set_user(&state, &token, &mut req);
|
||||
// req
|
||||
// } else {
|
||||
// Request::from_parts(parts, body)
|
||||
// };
|
||||
//
|
||||
// next.run(req).await
|
||||
// }
|
||||
//
|
||||
// async fn extract_token<T>(state: &T, parts: &mut Parts) -> Result<String, String>
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// match TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state).await {
|
||||
// Ok(TypedHeader(Authorization(bearer))) => Ok(bearer.token().to_string()),
|
||||
// Err(e) => {
|
||||
// if e.is_missing() {
|
||||
// match Query::<Params>::from_request_parts(parts, &state).await {
|
||||
// Ok(params) => Ok(params.token.clone()),
|
||||
// Err(e) => {
|
||||
// let msg = format!("parse query params failed: {}", e);
|
||||
// warn!(msg);
|
||||
// Err(msg)
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// let msg = format!("parse Authorization header failed: {}", e);
|
||||
// warn!(msg);
|
||||
// Err(msg)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fn set_user<T>(state: &T, token: &str, req: &mut Request) -> Result<(), String>
|
||||
// where
|
||||
// T: TokenVerify + Clone + Send + Sync + 'static,
|
||||
// {
|
||||
// match state.verify(token) {
|
||||
// Ok(user) => {
|
||||
// req.extensions_mut().insert(user);
|
||||
// Ok(())
|
||||
// }
|
||||
// Err(e) => {
|
||||
// let msg = format!("verify token failed: {:?}", e);
|
||||
// warn!(msg);
|
||||
// Err(msg)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use super::*;
|
||||
// use crate::{DecodingKey, EncodingKey, User};
|
||||
// use anyhow::Result;
|
||||
// use axum::{body::Body, middleware::from_fn_with_state, routing::get, Router};
|
||||
// use std::sync::Arc;
|
||||
// use tower::ServiceExt;
|
||||
//
|
||||
// #[derive(Clone)]
|
||||
// struct AppState(Arc<AppStateInner>);
|
||||
//
|
||||
// struct AppStateInner {
|
||||
// ek: EncodingKey,
|
||||
// dk: DecodingKey,
|
||||
// }
|
||||
//
|
||||
// impl TokenVerify for AppState {
|
||||
// type Error = ();
|
||||
//
|
||||
// fn verify(&self, token: &str) -> Result<User, Self::Error> {
|
||||
// self.0.dk.verify(token).map_err(|_| ())
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// async fn handler(_req: Request) -> impl IntoResponse {
|
||||
// (StatusCode::OK, "ok")
|
||||
// }
|
||||
//
|
||||
// #[tokio::test]
|
||||
// async fn verify_token_middleware_should_work() -> Result<()> {
|
||||
// let encoding_pem = include_str!("../../fixtures/encoding.pem");
|
||||
// let decoding_pem = include_str!("../../fixtures/decoding.pem");
|
||||
// // let ek = EncodingKey::load(encoding_pem)?;
|
||||
// // let dk = DecodingKey::load(decoding_pem)?;
|
||||
// let state = AppState(Arc::new(AppStateInner { ek, dk }));
|
||||
//
|
||||
// let user = User::new(1, "soddy", "soddygo@acme.org");
|
||||
// let token = state.0.ek.sign(user)?;
|
||||
//
|
||||
// let app = Router::new()
|
||||
// .route("/", get(handler))
|
||||
// .layer(from_fn_with_state(state.clone(), verify_token::<AppState>))
|
||||
// .with_state(state);
|
||||
//
|
||||
// // good token
|
||||
// let req = Request::builder()
|
||||
// .uri("/")
|
||||
// .header("Authorization", format!("Bearer {}", token))
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::OK);
|
||||
//
|
||||
// // good token in query params
|
||||
// let req = Request::builder()
|
||||
// .uri(format!("/?token={}", token))
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::OK);
|
||||
//
|
||||
// // no token
|
||||
// let req = Request::builder().uri("/").body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
//
|
||||
// // bad token
|
||||
// let req = Request::builder()
|
||||
// .uri("/")
|
||||
// .header("Authorization", "Bearer bad-token")
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.clone().oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||
//
|
||||
// // bad token in query params
|
||||
// let req = Request::builder()
|
||||
// .uri("/?token=bad-token")
|
||||
// .body(Body::empty())?;
|
||||
// let res = app.oneshot(req).await?;
|
||||
// assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||
//
|
||||
// Ok(())
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,147 @@
|
||||
//! HTTP request/response logging middleware
|
||||
//!
|
||||
//! This middleware logs all HTTP requests and responses at TRACE level.
|
||||
//! Using TRACE level avoids excessive log output for frequent MCP API calls.
|
||||
//!
|
||||
//! To enable HTTP logging, set:
|
||||
//! - `RUST_LOG=trace` (global)
|
||||
//! - `RUST_LOG=mcp_proxy=trace` (module-specific)
|
||||
|
||||
use axum::{
|
||||
extract::{MatchedPath, Request},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use tracing::trace;
|
||||
|
||||
/// HTTP request/response logging middleware
|
||||
///
|
||||
/// Logs incoming requests with details (method, uri, route, headers)
|
||||
/// and outgoing responses (status, duration, content length).
|
||||
///
|
||||
/// # Log Level
|
||||
/// Uses TRACE level because MCP API calls are very frequent.
|
||||
/// Enable with `RUST_LOG=trace` or `RUST_LOG=mcp_proxy=trace`.
|
||||
///
|
||||
/// # Middleware Order
|
||||
/// Should be placed before `opentelemetry_tracing_middleware` to log
|
||||
/// request entry first, while avoiding duplication of completion logs.
|
||||
pub async fn http_logging_middleware(request: Request, next: Next) -> Response {
|
||||
// Extract all needed data before moving the request
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
let version = request.version();
|
||||
|
||||
// Get matched route path (extract to owned String to avoid borrow)
|
||||
let route = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(|path| path.as_str().to_owned())
|
||||
.unwrap_or_else(|| "<unknown>".to_owned());
|
||||
|
||||
// Get request headers of interest (extract to owned Strings to avoid borrow)
|
||||
let headers = request.headers();
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>")
|
||||
.to_owned();
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>")
|
||||
.to_owned();
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>")
|
||||
.to_owned();
|
||||
|
||||
// Log incoming request at TRACE level (for frequent MCP API calls)
|
||||
trace!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
route = %route,
|
||||
version = ?version,
|
||||
content_type = %content_type,
|
||||
content_length = %content_length,
|
||||
user_agent = %user_agent,
|
||||
"HTTP request received"
|
||||
);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let response = next.run(request).await;
|
||||
let duration = start.elapsed();
|
||||
|
||||
// Get response details
|
||||
let status = response.status();
|
||||
let response_content_length = response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("<none>");
|
||||
|
||||
// Log response at TRACE level
|
||||
trace!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
route = %route,
|
||||
status = %status,
|
||||
duration_ms = %duration.as_millis(),
|
||||
response_content_length = %response_content_length,
|
||||
"HTTP response sent"
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, body::Body, http::StatusCode, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_logging_middleware() {
|
||||
// Create a test handler
|
||||
async fn test_handler() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/test", get(test_handler))
|
||||
.layer(axum::middleware::from_fn(http_logging_middleware));
|
||||
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_logging_with_headers() {
|
||||
async fn test_handler() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/test", get(test_handler))
|
||||
.layer(axum::middleware::from_fn(http_logging_middleware));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/test")
|
||||
.header("content-type", "application/json")
|
||||
.header("user-agent", "test-agent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::model::{McpConfig, McpRouterPath, McpType};
|
||||
|
||||
/// 提取mcp的json配置,从请求的header上,可能没有,也可能有
|
||||
pub(crate) async fn mcp_json_config_extract(
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let path = req.uri().path().to_string();
|
||||
debug!("Request path: {path}");
|
||||
//检查请求路径,是否 /mcp 开头
|
||||
let check_mcp_path = McpRouterPath::check_mcp_path(&path);
|
||||
if check_mcp_path {
|
||||
//请求路径,可能是: /mcp/{mcp_id}/sse,或者 /mcp/{mcp_id}/message
|
||||
let mcp_router_path = McpRouterPath::from_url(&path);
|
||||
if let Some(mcp_router_path) = mcp_router_path {
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
// 解析header中的 x-mcp-json 字段,这个对应的是 mcp 的json配置
|
||||
// 现在这个字段是base64编码过的,需要先解码
|
||||
let mcp_json_config = req
|
||||
.headers()
|
||||
.get("x-mcp-json")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|encoded| {
|
||||
// 将 base64 编码的值解码为原始 JSON 字符串
|
||||
let decoded = BASE64
|
||||
.decode(encoded)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok());
|
||||
debug!("Parsed MCP configuration, x-mcp-json={:?}", &decoded);
|
||||
|
||||
decoded
|
||||
});
|
||||
|
||||
// 解析header中的 x-mcp-type 字段,这个对应的是 mcp 的类型
|
||||
let mcp_type = req
|
||||
.headers()
|
||||
.get("x-mcp-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|s| McpType::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mcp_protocol = mcp_router_path.mcp_protocol.clone();
|
||||
let mcp_config = McpConfig::new(mcp_id, mcp_json_config, mcp_type, mcp_protocol);
|
||||
|
||||
req.extensions_mut().insert(mcp_config);
|
||||
}
|
||||
}
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::extract::Request;
|
||||
use log::debug;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
use crate::{
|
||||
get_proxy_manager,
|
||||
model::{AppState, McpRouterPath},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MySseRouterLayer {
|
||||
state: AppState,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct MySseRouterService<S> {
|
||||
inner: S,
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl MySseRouterLayer {
|
||||
pub fn new(state: AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for MySseRouterLayer {
|
||||
type Service = MySseRouterService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
MySseRouterService {
|
||||
inner,
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, B> Service<Request<B>> for MySseRouterService<S>
|
||||
where
|
||||
S: Service<Request<B>>,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = S::Future;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<B>) -> Self::Future {
|
||||
let path = req.uri().path().to_string();
|
||||
|
||||
// ===== 复用现有逻辑:检查是否为 MCP 请求 =====
|
||||
let check_mcp_path = McpRouterPath::check_mcp_path(&path);
|
||||
if check_mcp_path && let Some(mcp_router_path) = McpRouterPath::from_url(&path) {
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
|
||||
// ===== 更新最后访问时间 =====
|
||||
debug!("Update last access time, request access MCP ID: {}", mcp_id);
|
||||
get_proxy_manager().update_last_accessed(&mcp_id);
|
||||
}
|
||||
|
||||
self.inner.call(req)
|
||||
}
|
||||
}
|
||||
47
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/mod.rs
Normal file
47
qiming-mcp-proxy/mcp-proxy/src/server/middlewares/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
mod auth;
|
||||
mod http_logging;
|
||||
mod mcp_router_json;
|
||||
mod mcp_update_latest_layer;
|
||||
mod opentelemetry_middleware;
|
||||
mod server_time;
|
||||
|
||||
use crate::model::AppState;
|
||||
use axum::Router;
|
||||
use axum::middleware::from_fn;
|
||||
use http_logging::http_logging_middleware;
|
||||
use mcp_router_json::mcp_json_config_extract;
|
||||
use opentelemetry_middleware::opentelemetry_tracing_middleware;
|
||||
use server_time::ServerTimeLayer;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
pub use mcp_update_latest_layer::MySseRouterLayer;
|
||||
pub use opentelemetry_middleware::extract_trace_id;
|
||||
|
||||
// pub use auth::{extract_user, verify_token};
|
||||
|
||||
// pub trait TokenVerify {
|
||||
// type Error: fmt::Debug;
|
||||
// fn verify(&self, token: &str) -> Result<User, Self::Error>;
|
||||
// }
|
||||
|
||||
const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const SERVER_TIME_HEADER: &str = "x-server-time";
|
||||
|
||||
pub fn set_layer(app: Router, state: AppState) -> Router {
|
||||
app.layer(
|
||||
ServiceBuilder::new()
|
||||
// HTTP 请求/响应日志中间件 (TRACE level, 用于调试频繁的 MCP API 调用)
|
||||
.layer(from_fn(http_logging_middleware))
|
||||
// OpenTelemetry 追踪中间件 - 自动生成 trace_id 和 span
|
||||
.layer(from_fn(opentelemetry_tracing_middleware))
|
||||
// MCP 配置提取中间件
|
||||
.layer(from_fn(mcp_json_config_extract))
|
||||
// HTTP 压缩
|
||||
.layer(CompressionLayer::new().gzip(true).br(true).deflate(true))
|
||||
// 服务器时间响应头
|
||||
.layer(ServerTimeLayer)
|
||||
// SSE 路由层
|
||||
.layer(MySseRouterLayer::new(state.clone())),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use axum::{
|
||||
extract::{MatchedPath, Request},
|
||||
http::{HeaderMap, HeaderValue},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use opentelemetry::{Context, trace::TraceContextExt};
|
||||
use std::time::Instant;
|
||||
use tracing::{Instrument, debug_span};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
use super::{REQUEST_ID_HEADER, SERVER_TIME_HEADER};
|
||||
|
||||
/// OpenTelemetry 追踪中间件
|
||||
///
|
||||
/// 功能:
|
||||
/// 1. 自动创建 OpenTelemetry span 和 trace
|
||||
/// 2. 在响应头中添加 x-request-id (trace_id)
|
||||
/// 3. 在响应头中添加 x-server-time (请求处理时间)
|
||||
/// 4. 记录 HTTP 请求的语义化属性
|
||||
pub async fn opentelemetry_tracing_middleware(request: Request, next: Next) -> Response {
|
||||
let start_time = Instant::now();
|
||||
|
||||
// 提取请求信息
|
||||
let method = request.method().to_string();
|
||||
let uri = request.uri().to_string();
|
||||
let route = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(|matched_path| matched_path.as_str().to_owned())
|
||||
.unwrap_or_else(|| request.uri().path().to_owned());
|
||||
let version = format!("{:?}", request.version());
|
||||
let user_agent = request
|
||||
.headers()
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// 创建 OpenTelemetry span
|
||||
let span = debug_span!(
|
||||
"http_request",
|
||||
otel.name = format!("{} {}", method, route).as_str(),
|
||||
otel.kind = "server",
|
||||
http.method = method.as_str(),
|
||||
http.url = uri.as_str(),
|
||||
http.route = route.as_str(),
|
||||
http.scheme = "http",
|
||||
http.version = version.as_str(),
|
||||
http.user_agent = user_agent,
|
||||
);
|
||||
|
||||
// 设置 OpenTelemetry 属性
|
||||
let otel_cx = Context::current();
|
||||
if let Err(error) = span.set_parent(otel_cx) {
|
||||
tracing::warn!(target: "telemetry", %method, %uri, ?error, "failed to attach OpenTelemetry parent context");
|
||||
}
|
||||
|
||||
// 获取 trace_id
|
||||
let trace_id = span.context().span().span_context().trace_id().to_string();
|
||||
|
||||
// 如果 trace_id 全为0,生成一个随机的 trace_id
|
||||
let trace_id = if trace_id == "00000000000000000000000000000000" {
|
||||
use uuid::Uuid;
|
||||
Uuid::new_v4().simple().to_string()
|
||||
} else {
|
||||
trace_id
|
||||
};
|
||||
|
||||
async move {
|
||||
// 执行请求处理
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
// 计算处理时间
|
||||
let duration = start_time.elapsed();
|
||||
let duration_micros = duration.as_micros();
|
||||
|
||||
// 记录响应状态码
|
||||
let status_code = response.status().as_u16();
|
||||
|
||||
// 添加响应头
|
||||
let headers = response.headers_mut();
|
||||
|
||||
// 添加 trace_id 到响应头
|
||||
if let Ok(trace_header) = HeaderValue::from_str(&trace_id) {
|
||||
headers.insert(REQUEST_ID_HEADER, trace_header);
|
||||
}
|
||||
|
||||
// 添加服务器处理时间到响应头 (微秒)
|
||||
if let Ok(time_header) = HeaderValue::from_str(&duration_micros.to_string()) {
|
||||
headers.insert(SERVER_TIME_HEADER, time_header);
|
||||
}
|
||||
|
||||
// 记录请求完成日志(改为 debug 级别,减少日志量)
|
||||
tracing::debug!(
|
||||
method = %method,
|
||||
uri = %uri,
|
||||
status = %status_code,
|
||||
duration_micros = %duration_micros,
|
||||
trace_id = %trace_id,
|
||||
"HTTP request completed"
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 从当前 OpenTelemetry 上下文中提取 trace_id
|
||||
///
|
||||
/// 这个函数可以在任何地方调用来获取当前请求的 trace_id
|
||||
pub fn extract_trace_id() -> String {
|
||||
let current_span = tracing::Span::current();
|
||||
let context = current_span.context();
|
||||
let span_ref = context.span();
|
||||
let span_context = span_ref.span_context();
|
||||
|
||||
let trace_id = if span_context.is_valid() {
|
||||
span_context.trace_id().to_string()
|
||||
} else {
|
||||
"00000000000000000000000000000000".to_string()
|
||||
};
|
||||
|
||||
// 如果 trace_id 全为0,生成一个随机的 trace_id
|
||||
if trace_id == "00000000000000000000000000000000" {
|
||||
use uuid::Uuid;
|
||||
Uuid::new_v4().simple().to_string()
|
||||
} else {
|
||||
trace_id
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求头中提取现有的 trace_id(如果有的话)
|
||||
///
|
||||
/// 支持标准的 OpenTelemetry 传播头:
|
||||
/// - traceparent (W3C Trace Context)
|
||||
/// - x-trace-id (自定义)
|
||||
#[allow(dead_code)]
|
||||
pub fn extract_trace_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
// 尝试从 W3C Trace Context 中提取
|
||||
if let Some(traceparent) = headers.get("traceparent")
|
||||
&& let Ok(traceparent_str) = traceparent.to_str()
|
||||
{
|
||||
// traceparent 格式: 00-{trace_id}-{span_id}-{flags}
|
||||
let parts: Vec<&str> = traceparent_str.split('-').collect();
|
||||
if parts.len() >= 2 {
|
||||
return Some(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从自定义头中提取
|
||||
if let Some(trace_id) = headers.get("x-trace-id")
|
||||
&& let Ok(trace_id_str) = trace_id.to_str()
|
||||
{
|
||||
return Some(trace_id_str.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
#[test]
|
||||
fn test_extract_trace_from_headers_traceparent() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"traceparent",
|
||||
HeaderValue::from_static("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
|
||||
);
|
||||
|
||||
let trace_id = extract_trace_from_headers(&headers);
|
||||
assert_eq!(
|
||||
trace_id,
|
||||
Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_trace_from_headers_custom() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-trace-id",
|
||||
HeaderValue::from_static("custom-trace-id-123"),
|
||||
);
|
||||
|
||||
let trace_id = extract_trace_from_headers(&headers);
|
||||
assert_eq!(trace_id, Some("custom-trace-id-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_trace_from_headers_none() {
|
||||
let headers = HeaderMap::new();
|
||||
let trace_id = extract_trace_from_headers(&headers);
|
||||
assert_eq!(trace_id, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::{REQUEST_ID_HEADER, SERVER_TIME_HEADER};
|
||||
use axum::{extract::Request, response::Response};
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use tower::{Layer, Service};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerTimeLayer;
|
||||
|
||||
impl<S> Layer<S> for ServerTimeLayer {
|
||||
type Service = ServerTimeMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
ServerTimeMiddleware { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerTimeMiddleware<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> Service<Request> for ServerTimeMiddleware<S>
|
||||
where
|
||||
S: Service<Request, Response = Response> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
// `BoxFuture` is a type alias for `Pin<Box<dyn Future + Send + 'a>>`
|
||||
type Future =
|
||||
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, request: Request) -> Self::Future {
|
||||
let start = Instant::now();
|
||||
let future = self.inner.call(request);
|
||||
Box::pin(async move {
|
||||
let mut res: Response = future.await?;
|
||||
let elapsed = format!("{}us", start.elapsed().as_micros());
|
||||
match elapsed.parse() {
|
||||
Ok(v) => {
|
||||
res.headers_mut().insert(SERVER_TIME_HEADER, v);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Parse elapsed time failed: {} for request {:?}",
|
||||
e,
|
||||
res.headers().get(REQUEST_ID_HEADER)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
18
qiming-mcp-proxy/mcp-proxy/src/server/mod.rs
Normal file
18
qiming-mcp-proxy/mcp-proxy/src/server/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
pub mod handlers;
|
||||
mod mcp_dynamic_router_service;
|
||||
mod middlewares;
|
||||
pub mod protocol_detector;
|
||||
mod router_layer;
|
||||
mod task;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use handlers::{get_health, get_ready};
|
||||
|
||||
pub use middlewares::set_layer;
|
||||
|
||||
pub use protocol_detector::{detect_mcp_protocol, detect_mcp_protocol_with_headers};
|
||||
pub use router_layer::get_router;
|
||||
pub use task::{mcp_start_task, schedule_check_mcp_live, start_schedule_task};
|
||||
pub use telemetry::{
|
||||
create_telemetry_layer, init_tracer_provider, log_service_info, shutdown_telemetry,
|
||||
};
|
||||
87
qiming-mcp-proxy/mcp-proxy/src/server/protocol_detector.rs
Normal file
87
qiming-mcp-proxy/mcp-proxy/src/server/protocol_detector.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Protocol Detection (Re-export Layer)
|
||||
//!
|
||||
//! This module re-exports protocol detection functions and provides
|
||||
//! a convenient combined detection function.
|
||||
//!
|
||||
//! Detection logic: If SSE detected → Sse, else → Stream (default fallback)
|
||||
|
||||
// Re-export the detection functions
|
||||
pub use mcp_sse_proxy::is_sse_with_headers;
|
||||
|
||||
use crate::model::McpProtocol;
|
||||
use anyhow::Result;
|
||||
use log::info;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Automatically detect the MCP service protocol type
|
||||
///
|
||||
/// Convenience wrapper around [`detect_mcp_protocol_with_headers`] that passes no
|
||||
/// custom headers.
|
||||
pub async fn detect_mcp_protocol(url: &str) -> Result<McpProtocol> {
|
||||
detect_mcp_protocol_with_headers(url, None).await
|
||||
}
|
||||
|
||||
/// Automatically detect the MCP service protocol type, with optional custom headers
|
||||
///
|
||||
/// Detection logic:
|
||||
/// 1. First try to detect SSE protocol (GET /sse returns text/event-stream)
|
||||
/// 2. If not SSE, default to Streamable HTTP (modern MCP standard)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - The URL to detect
|
||||
/// * `headers` - Optional custom headers to include in the detection request
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the detected protocol type (Sse or Stream)
|
||||
pub async fn detect_mcp_protocol_with_headers(
|
||||
url: &str,
|
||||
headers: Option<&HashMap<String, String>>,
|
||||
) -> Result<McpProtocol> {
|
||||
info!(
|
||||
"Start automatically detecting MCP service protocol: {}",
|
||||
url
|
||||
);
|
||||
|
||||
// Try SSE first
|
||||
if is_sse_with_headers(url, headers).await {
|
||||
info!("SSE protocol detected: {}", url);
|
||||
return Ok(McpProtocol::Sse);
|
||||
}
|
||||
|
||||
// Default to Streamable HTTP (modern MCP standard)
|
||||
info!("Default uses Streamable HTTP protocol: {}", url);
|
||||
Ok(McpProtocol::Stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detect_invalid_url() {
|
||||
// Invalid URL should default to Stream
|
||||
let result = detect_mcp_protocol("not-a-url").await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), McpProtocol::Stream);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detect_nonexistent_server() {
|
||||
// Non-existent server should default to Stream
|
||||
let result = detect_mcp_protocol("http://localhost:99999/mcp").await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), McpProtocol::Stream);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_detect_with_headers_nonexistent_server() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
|
||||
let result =
|
||||
detect_mcp_protocol_with_headers("http://localhost:99999/mcp", Some(&headers)).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), McpProtocol::Stream);
|
||||
}
|
||||
}
|
||||
82
qiming-mcp-proxy/mcp-proxy/src/server/router_layer.rs
Normal file
82
qiming-mcp-proxy/mcp-proxy/src/server/router_layer.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use http::Method;
|
||||
use tower_http::cors::{self, CorsLayer};
|
||||
|
||||
use crate::{
|
||||
AppError, AppState, DynamicRouterService,
|
||||
model::{GLOBAL_SSE_MCP_ROUTES_PREFIX, GLOBAL_STREAM_MCP_ROUTES_PREFIX, McpProtocol},
|
||||
server::handlers::check_mcp_is_status_handler,
|
||||
};
|
||||
|
||||
use super::{
|
||||
get_health, get_ready,
|
||||
handlers::{
|
||||
add_route_handler, check_mcp_status_handler_sse, check_mcp_status_handler_stream,
|
||||
delete_route_handler, run_code_handler,
|
||||
},
|
||||
set_layer,
|
||||
};
|
||||
|
||||
/// 获取路由
|
||||
pub async fn get_router(state: AppState) -> Result<Router, AppError> {
|
||||
let health = Router::new()
|
||||
.route("/health", get(get_health))
|
||||
.route("/ready", get(get_ready));
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
// allow `GET` and `POST` when accessing the resource
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
Method::PUT,
|
||||
])
|
||||
.allow_origin(cors::Any)
|
||||
.allow_headers(cors::Any);
|
||||
|
||||
let api = Router::new()
|
||||
// .layer(from_fn_with_state(state.clone(), verify_token::<AppState>))
|
||||
//mcp sse 协议路由
|
||||
.route_service(
|
||||
&format!("{GLOBAL_SSE_MCP_ROUTES_PREFIX}/proxy/{{*path}}"),
|
||||
DynamicRouterService(McpProtocol::Sse),
|
||||
)
|
||||
.route(
|
||||
&format!("{GLOBAL_SSE_MCP_ROUTES_PREFIX}/add"),
|
||||
post(add_route_handler),
|
||||
)
|
||||
.route("/mcp/config/delete/{mcp_id}", delete(delete_route_handler))
|
||||
.route(
|
||||
"/mcp/check/status/{mcp_id}",
|
||||
get(check_mcp_is_status_handler),
|
||||
)
|
||||
.route(
|
||||
&format!("{GLOBAL_SSE_MCP_ROUTES_PREFIX}/check_status"),
|
||||
post(check_mcp_status_handler_sse),
|
||||
)
|
||||
//mcp stream 协议路由
|
||||
.route_service(
|
||||
&format!("{GLOBAL_STREAM_MCP_ROUTES_PREFIX}/proxy/{{*path}}"),
|
||||
DynamicRouterService(McpProtocol::Stream),
|
||||
)
|
||||
.route(
|
||||
&format!("{GLOBAL_STREAM_MCP_ROUTES_PREFIX}/check_status"),
|
||||
post(check_mcp_status_handler_stream),
|
||||
)
|
||||
.route("/api/run_code_with_log", post(run_code_handler))
|
||||
.layer(DefaultBodyLimit::max(20 * 1024 * 1024))
|
||||
.layer(cors);
|
||||
|
||||
// 创建基本路由
|
||||
let app: Router<AppState> = Router::new().merge(health).merge(api);
|
||||
|
||||
// 添加状态
|
||||
let app = app.with_state(state.clone());
|
||||
let router = set_layer(app, state);
|
||||
Ok(router)
|
||||
}
|
||||
592
qiming-mcp-proxy/mcp-proxy/src/server/task/mcp_start_task.rs
Normal file
592
qiming-mcp-proxy/mcp-proxy/src/server/task/mcp_start_task.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
//! MCP Service Start Task
|
||||
//!
|
||||
//! This module handles starting MCP services using the Builder APIs from
|
||||
//! mcp-sse-proxy and mcp-streamable-proxy libraries.
|
||||
//!
|
||||
//! The refactored implementation removes direct rmcp dependency by delegating
|
||||
//! protocol-specific logic to the proxy libraries.
|
||||
|
||||
use crate::{
|
||||
AppError, DynamicRouterService, get_proxy_manager,
|
||||
model::GLOBAL_RESTART_TRACKER,
|
||||
model::{
|
||||
CheckMcpStatusResponseStatus, McpConfig, McpProtocol, McpProtocolPath, McpRouterPath,
|
||||
McpServerCommandConfig, McpServerConfig, McpServiceStatus, McpType,
|
||||
},
|
||||
proxy::{
|
||||
McpHandler, SseBackendConfig, SseServerBuilder, StreamBackendConfig, StreamServerBuilder,
|
||||
},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use log::{debug, info};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Start an MCP service based on configuration
|
||||
///
|
||||
/// This function creates and configures an MCP proxy service based on the
|
||||
/// provided configuration. It supports both SSE and Streamable HTTP client
|
||||
/// protocols, with automatic backend protocol detection for URL-based services.
|
||||
pub async fn mcp_start_task(
|
||||
mcp_config: McpConfig,
|
||||
) -> Result<(axum::Router, tokio_util::sync::CancellationToken)> {
|
||||
let mcp_id = mcp_config.mcp_id.clone();
|
||||
let client_protocol = mcp_config.client_protocol.clone();
|
||||
|
||||
// Create router path based on client protocol (determines exposed API interface)
|
||||
let mcp_router_path: McpRouterPath = McpRouterPath::new(mcp_id, client_protocol)
|
||||
.map_err(|e| AppError::mcp_server_error(e.to_string()))?;
|
||||
|
||||
let mcp_json_config = mcp_config
|
||||
.mcp_json_config
|
||||
.clone()
|
||||
.expect("mcp_json_config is required");
|
||||
|
||||
let mcp_server_config = McpServerConfig::try_from(mcp_json_config)?;
|
||||
|
||||
// Use the integrated method to create the server
|
||||
integrate_server_with_axum(
|
||||
mcp_server_config.clone(),
|
||||
mcp_router_path.clone(),
|
||||
mcp_config.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Integrate MCP server with axum router
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Determines backend protocol (stdio, SSE, or Streamable HTTP)
|
||||
/// 2. Creates the appropriate server using Builder APIs
|
||||
/// 3. Registers the handler with ProxyManager
|
||||
/// 4. Sets up dynamic routing
|
||||
pub async fn integrate_server_with_axum(
|
||||
mcp_config: McpServerConfig,
|
||||
mcp_router_path: McpRouterPath,
|
||||
full_mcp_config: McpConfig,
|
||||
) -> Result<(axum::Router, tokio_util::sync::CancellationToken)> {
|
||||
let mcp_type = full_mcp_config.mcp_type.clone();
|
||||
let base_path = mcp_router_path.base_path.clone();
|
||||
let mcp_id = mcp_router_path.mcp_id.clone();
|
||||
|
||||
// Determine backend protocol from configuration
|
||||
let backend_protocol = match &mcp_config {
|
||||
// Command-line config: use stdio protocol
|
||||
McpServerConfig::Command(_) => McpProtocol::Stdio,
|
||||
// URL config: parse type field or auto-detect
|
||||
McpServerConfig::Url(url_config) => {
|
||||
// Merge headers + auth_token for protocol detection
|
||||
let mut detection_headers = normalize_headers(&url_config.headers).unwrap_or_default();
|
||||
if let Some(auth_token) = &url_config.auth_token {
|
||||
let value = if auth_token.starts_with("Bearer ") {
|
||||
auth_token.clone()
|
||||
} else {
|
||||
format!("Bearer {}", auth_token)
|
||||
};
|
||||
detection_headers.insert("Authorization".to_string(), value);
|
||||
}
|
||||
let detection_headers_ref = if detection_headers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&detection_headers)
|
||||
};
|
||||
|
||||
// Check type field first
|
||||
if let Some(type_str) = &url_config.r#type {
|
||||
match type_str.parse::<McpProtocol>() {
|
||||
Ok(protocol) => {
|
||||
debug!(
|
||||
"Using configured protocol type: {} -> {:?}",
|
||||
type_str, protocol
|
||||
);
|
||||
protocol
|
||||
}
|
||||
Err(_) => {
|
||||
// If parsing fails, auto-detect
|
||||
debug!("Protocol type '{}' unrecognized, auto-detecting", type_str);
|
||||
let detected_protocol = crate::server::detect_mcp_protocol_with_headers(
|
||||
url_config.get_url(),
|
||||
detection_headers_ref,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Protocol type '{}' unrecognized and auto-detection failed: {}",
|
||||
type_str,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
debug!(
|
||||
"Auto-detected protocol: {:?} (original config: '{}')",
|
||||
detected_protocol, type_str
|
||||
);
|
||||
detected_protocol
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No type field, auto-detect
|
||||
debug!("No type field specified, auto-detecting protocol");
|
||||
|
||||
crate::server::detect_mcp_protocol_with_headers(
|
||||
url_config.get_url(),
|
||||
detection_headers_ref,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Auto-detection failed: {}", e))?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"MCP ID: {}, client protocol: {:?}, backend protocol: {:?}",
|
||||
mcp_id, mcp_router_path.mcp_protocol, backend_protocol
|
||||
);
|
||||
|
||||
// Create server based on client protocol using Builder APIs
|
||||
let (router, ct, handler) = match mcp_router_path.mcp_protocol.clone() {
|
||||
// ================ Client uses SSE protocol ================
|
||||
McpProtocol::Sse => {
|
||||
let sse_path = match &mcp_router_path.mcp_protocol_path {
|
||||
McpProtocolPath::SsePath(sse_path) => sse_path,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Build backend config for SSE
|
||||
let backend_config = if matches!(backend_protocol, McpProtocol::Stream) {
|
||||
// Streamable HTTP backend: connect via mcp-streamable-proxy (rmcp 1.4.0),
|
||||
// then pass as BackendBridge to decouple mcp-sse-proxy from mcp-streamable-proxy
|
||||
let bridge = connect_stream_backend(&mcp_config, &mcp_id).await?;
|
||||
SseBackendConfig::BackendBridge(bridge)
|
||||
} else {
|
||||
build_sse_backend_config(&mcp_config, backend_protocol)?
|
||||
};
|
||||
|
||||
debug!(
|
||||
"Creating SSE server, sse_path={}, post_path={}",
|
||||
sse_path.sse_path, sse_path.message_path
|
||||
);
|
||||
|
||||
// 对于 OneShot 服务,使用更短的 keep_alive 间隔(5秒)来保持后端活跃
|
||||
// 防止后端进程因空闲超时而退出
|
||||
let keep_alive_secs = if matches!(mcp_type, McpType::OneShot) {
|
||||
5
|
||||
} else {
|
||||
15
|
||||
};
|
||||
|
||||
// 对于 OneShot 服务,禁用 stateful 模式以加快响应速度
|
||||
// stateful=false 会跳过 MCP 初始化步骤,直接处理请求
|
||||
let stateful = !matches!(mcp_type, McpType::OneShot);
|
||||
|
||||
let (router, ct, handler) = SseServerBuilder::new(backend_config)
|
||||
.mcp_id(mcp_id.clone())
|
||||
.sse_path(sse_path.sse_path.clone())
|
||||
.post_path(sse_path.message_path.clone())
|
||||
.keep_alive(keep_alive_secs)
|
||||
.stateful(stateful)
|
||||
.build()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"SSE server build failed - MCP ID: {}, type: {:?}",
|
||||
mcp_id, mcp_type
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"SSE server started - MCP ID: {}, type: {:?}",
|
||||
mcp_router_path.mcp_id, mcp_type
|
||||
);
|
||||
|
||||
(router, ct, McpHandler::Sse(Box::new(handler)))
|
||||
}
|
||||
|
||||
// ================ Client uses Streamable HTTP protocol ================
|
||||
McpProtocol::Stream => {
|
||||
// Build backend config for Stream
|
||||
let backend_config = build_stream_backend_config(&mcp_config, backend_protocol)?;
|
||||
|
||||
let (router, ct, handler) = StreamServerBuilder::new(backend_config)
|
||||
.mcp_id(mcp_id.clone())
|
||||
.stateful(false)
|
||||
.build()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Stream server build failed - MCP ID: {}, type: {:?}",
|
||||
mcp_id, mcp_type
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Streamable HTTP server started - MCP ID: {}, type: {:?}",
|
||||
mcp_router_path.mcp_id, mcp_type
|
||||
);
|
||||
|
||||
(router, ct, McpHandler::Stream(Box::new(handler)))
|
||||
}
|
||||
|
||||
// Client stdio protocol is not supported in server mode
|
||||
McpProtocol::Stdio => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Client protocol cannot be Stdio. McpRouterPath::new does not support creating Stdio protocol router paths"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Clone cancellation token for monitoring
|
||||
let ct_clone = ct.clone();
|
||||
let mcp_id_clone = mcp_id.clone();
|
||||
|
||||
// Store MCP service status with full mcp_config for auto-restart
|
||||
let mcp_service_status = McpServiceStatus::new(
|
||||
mcp_id_clone.clone(),
|
||||
mcp_type.clone(),
|
||||
mcp_router_path.clone(),
|
||||
ct_clone.clone(),
|
||||
CheckMcpStatusResponseStatus::Ready,
|
||||
)
|
||||
.with_mcp_config(full_mcp_config.clone());
|
||||
|
||||
// Add MCP service status and proxy handler to global manager
|
||||
let proxy_manager = get_proxy_manager();
|
||||
proxy_manager.add_mcp_service_status_and_proxy(mcp_service_status, Some(handler));
|
||||
|
||||
// ===== 新增:注册配置到缓存 =====
|
||||
proxy_manager
|
||||
.register_mcp_config(&mcp_id, full_mcp_config.clone())
|
||||
.await;
|
||||
|
||||
// Add base path fallback handler for SSE protocol
|
||||
let router = if matches!(mcp_router_path.mcp_protocol, McpProtocol::Sse) {
|
||||
let modified_router = router.fallback(base_path_fallback_handler);
|
||||
info!("SSE base path handler added, base_path: {}", base_path);
|
||||
modified_router
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
// Register route to global route table
|
||||
info!(
|
||||
"Registering route: base_path={}, mcp_id={}",
|
||||
base_path, mcp_id
|
||||
);
|
||||
info!(
|
||||
"SSE path config: sse_path={}, post_path={}",
|
||||
match &mcp_router_path.mcp_protocol_path {
|
||||
McpProtocolPath::SsePath(sse_path) => &sse_path.sse_path,
|
||||
_ => "N/A",
|
||||
},
|
||||
match &mcp_router_path.mcp_protocol_path {
|
||||
McpProtocolPath::SsePath(sse_path) => &sse_path.message_path,
|
||||
_ => "N/A",
|
||||
}
|
||||
);
|
||||
DynamicRouterService::register_route(&base_path, router.clone());
|
||||
info!("Route registration complete: base_path={}", base_path);
|
||||
|
||||
// 记录重启时间戳(仅在服务成功启动后)
|
||||
GLOBAL_RESTART_TRACKER.record_restart(&mcp_id);
|
||||
|
||||
Ok((router, ct))
|
||||
}
|
||||
|
||||
/// Connect to a Streamable HTTP backend and return a BackendBridge
|
||||
///
|
||||
/// This lives in mcp-proxy (not mcp-sse-proxy) because it uses mcp-streamable-proxy types.
|
||||
/// The returned `Arc<dyn BackendBridge>` is protocol-agnostic, allowing mcp-sse-proxy
|
||||
/// to use it without depending on mcp-streamable-proxy.
|
||||
async fn connect_stream_backend(
|
||||
mcp_config: &McpServerConfig,
|
||||
mcp_id: &str,
|
||||
) -> Result<std::sync::Arc<dyn mcp_common::BackendBridge>> {
|
||||
use crate::proxy::{StreamClientConnection, StreamProxyHandler};
|
||||
|
||||
let url_config = match mcp_config {
|
||||
McpServerConfig::Url(url_config) => url_config,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Stream backend requires URL-based config"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let url = url_config.get_url();
|
||||
info!(
|
||||
"Connecting to Streamable HTTP backend (SSE frontend) \
|
||||
- MCP ID: {}, URL: {}",
|
||||
mcp_id, url
|
||||
);
|
||||
|
||||
let mut config = mcp_common::McpClientConfig::new(url.to_string());
|
||||
let normalized = normalize_headers(&url_config.headers);
|
||||
if let Some(ref headers) = normalized {
|
||||
for (k, v) in headers {
|
||||
config = config.with_header(k, v);
|
||||
}
|
||||
}
|
||||
// auth_token 合并到 Authorization header(与 build_sse_backend_config 逻辑一致)
|
||||
if let Some(ref auth_token) = url_config.auth_token {
|
||||
let value = if auth_token.starts_with("Bearer ") {
|
||||
auth_token.clone()
|
||||
} else {
|
||||
format!("Bearer {}", auth_token)
|
||||
};
|
||||
config = config.with_header("Authorization", value);
|
||||
}
|
||||
|
||||
let conn = StreamClientConnection::connect(config).await?;
|
||||
let proxy_handler =
|
||||
StreamProxyHandler::with_mcp_id(conn.into_running_service(), mcp_id.to_string());
|
||||
|
||||
Ok(std::sync::Arc::new(proxy_handler))
|
||||
}
|
||||
|
||||
/// Build SSE backend configuration from MCP server config
|
||||
fn build_sse_backend_config(
|
||||
mcp_config: &McpServerConfig,
|
||||
backend_protocol: McpProtocol,
|
||||
) -> Result<SseBackendConfig> {
|
||||
match mcp_config {
|
||||
McpServerConfig::Command(cmd_config) => {
|
||||
log_command_details(cmd_config);
|
||||
Ok(SseBackendConfig::Stdio {
|
||||
command: cmd_config.command.clone(),
|
||||
args: cmd_config.args.clone(),
|
||||
env: cmd_config.env.clone(),
|
||||
})
|
||||
}
|
||||
McpServerConfig::Url(url_config) => match backend_protocol {
|
||||
McpProtocol::Stdio => Err(anyhow::anyhow!(
|
||||
"URL-based MCP service cannot use Stdio protocol"
|
||||
)),
|
||||
McpProtocol::Sse => {
|
||||
info!("Connecting to SSE backend: {}", url_config.get_url());
|
||||
Ok(SseBackendConfig::SseUrl {
|
||||
url: url_config.get_url().to_string(),
|
||||
headers: normalize_headers(&url_config.headers),
|
||||
})
|
||||
}
|
||||
McpProtocol::Stream => Err(anyhow::anyhow!(
|
||||
"Stream backend should be handled via connect_stream_backend(), \
|
||||
not build_sse_backend_config()"
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build Stream backend configuration from MCP server config
|
||||
fn build_stream_backend_config(
|
||||
mcp_config: &McpServerConfig,
|
||||
backend_protocol: McpProtocol,
|
||||
) -> Result<StreamBackendConfig> {
|
||||
match mcp_config {
|
||||
McpServerConfig::Command(cmd_config) => {
|
||||
log_command_details(cmd_config);
|
||||
Ok(StreamBackendConfig::Stdio {
|
||||
command: cmd_config.command.clone(),
|
||||
args: cmd_config.args.clone(),
|
||||
env: cmd_config.env.clone(),
|
||||
})
|
||||
}
|
||||
McpServerConfig::Url(url_config) => {
|
||||
match backend_protocol {
|
||||
McpProtocol::Stdio => Err(anyhow::anyhow!(
|
||||
"URL-based MCP service cannot use Stdio protocol"
|
||||
)),
|
||||
McpProtocol::Sse => {
|
||||
// Note: StreamServerBuilder currently only supports Streamable HTTP URL backend
|
||||
// SSE backend with Stream frontend would require protocol conversion
|
||||
// For now, we return an error for this combination
|
||||
Err(anyhow::anyhow!(
|
||||
"SSE backend with Streamable HTTP frontend is not yet supported. \
|
||||
Please use SSE frontend or configure a Streamable HTTP backend."
|
||||
))
|
||||
}
|
||||
McpProtocol::Stream => {
|
||||
info!(
|
||||
"Connecting to Streamable HTTP backend: {}",
|
||||
url_config.get_url()
|
||||
);
|
||||
Ok(StreamBackendConfig::Url {
|
||||
url: url_config.get_url().to_string(),
|
||||
headers: normalize_headers(&url_config.headers),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范化 headers:确保 Authorization header 有 "Bearer " 前缀
|
||||
///
|
||||
/// 与 client 模式 (`convert.rs:build_mcp_config`) 行为一致,
|
||||
/// 对没有 "Bearer " 前缀的 Authorization header 自动添加前缀。
|
||||
fn normalize_headers(headers: &Option<HashMap<String, String>>) -> Option<HashMap<String, String>> {
|
||||
headers.as_ref().map(|h| {
|
||||
h.iter()
|
||||
.map(|(k, v)| {
|
||||
if k.eq_ignore_ascii_case("Authorization") && !v.starts_with("Bearer ") {
|
||||
(k.clone(), format!("Bearer {}", v))
|
||||
} else {
|
||||
(k.clone(), v.clone())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Log command execution details for debugging
|
||||
fn log_command_details(mcp_config: &McpServerCommandConfig) {
|
||||
let args_str = mcp_config
|
||||
.args
|
||||
.as_ref()
|
||||
.map_or(String::new(), |args| args.join(" "));
|
||||
|
||||
info!("Executing command: {} {}", mcp_config.command, args_str);
|
||||
|
||||
// 只输出 env 变量的 key 列表,避免泄露敏感 value
|
||||
if let Some(env_vars) = &mcp_config.env {
|
||||
let keys: Vec<&String> = env_vars.keys().collect();
|
||||
if !keys.is_empty() {
|
||||
debug!("Config env keys: {:?}", keys);
|
||||
}
|
||||
}
|
||||
|
||||
// 输出进程级关键环境变量(PATH 摘要 + 镜像变量)
|
||||
debug!(
|
||||
"Process PATH: {}",
|
||||
mcp_common::diagnostic::format_path_summary(3)
|
||||
);
|
||||
for (key, val) in mcp_common::diagnostic::collect_mirror_env_vars() {
|
||||
debug!("Process env: {}={}", key, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Base path fallback handler - supports direct access to base path with automatic redirection
|
||||
#[axum::debug_handler]
|
||||
async fn base_path_fallback_handler(
|
||||
method: axum::http::Method,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let path = uri.path();
|
||||
info!("Base path handler: {} {}", method, path);
|
||||
|
||||
// Determine if SSE or Stream protocol
|
||||
if path.contains("/sse/proxy/") {
|
||||
// SSE protocol handling
|
||||
match method {
|
||||
axum::http::Method::GET => {
|
||||
// Extract MCP ID from path
|
||||
let mcp_id = path.split("/sse/proxy/").nth(1);
|
||||
|
||||
if let Some(mcp_id) = mcp_id {
|
||||
// Check if MCP service exists
|
||||
let proxy_manager = get_proxy_manager();
|
||||
if proxy_manager.get_mcp_service_status(mcp_id).is_none() {
|
||||
// MCP service not found
|
||||
(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
format!("MCP service '{}' not found", mcp_id).to_string(),
|
||||
)
|
||||
} else {
|
||||
// MCP service exists, check Accept header
|
||||
let accept_header = headers.get("accept");
|
||||
if let Some(accept) = accept_header {
|
||||
let accept_str = accept.to_str().unwrap_or("");
|
||||
if accept_str.contains("text/event-stream") {
|
||||
// Correct Accept header, redirect to /sse
|
||||
let redirect_uri = format!("{}/sse", path);
|
||||
info!("SSE redirect to: {}", redirect_uri);
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
[("Location", redirect_uri.to_string())],
|
||||
"Redirecting to SSE endpoint".to_string(),
|
||||
)
|
||||
} else {
|
||||
// Incorrect Accept header
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"SSE error: Invalid Accept header, expected 'text/event-stream'".to_string(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No Accept header
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"SSE error: Missing Accept header, expected 'text/event-stream'"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cannot extract MCP ID from path
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"SSE error: Invalid SSE path".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
axum::http::Method::POST => {
|
||||
// POST request redirect to /message
|
||||
let redirect_uri = format!("{}/message", path);
|
||||
info!("SSE redirect to: {}", redirect_uri);
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
[("Location", redirect_uri.to_string())],
|
||||
"Redirecting to message endpoint".to_string(),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// Other methods return 405 Method Not Allowed
|
||||
(
|
||||
axum::http::StatusCode::METHOD_NOT_ALLOWED,
|
||||
[("Allow", "GET, POST".to_string())],
|
||||
"Only GET and POST methods are allowed".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if path.contains("/stream/proxy/") {
|
||||
// Stream protocol handling - return success directly without redirect
|
||||
match method {
|
||||
axum::http::Method::GET => {
|
||||
// GET request returns server info
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[("Content-Type", "application/json".to_string())],
|
||||
r#"{"jsonrpc":"2.0","result":{"info":"Streamable MCP Server","version":"1.0"}}"#.to_string(),
|
||||
)
|
||||
}
|
||||
axum::http::Method::POST => {
|
||||
// POST request returns success, let StreamableHttpService handle
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[("Content-Type", "application/json".to_string())],
|
||||
r#"{"jsonrpc":"2.0","result":{"message":"Stream request received","protocol":"streamable-http"}}"#.to_string(),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// Other methods return 405 Method Not Allowed
|
||||
(
|
||||
axum::http::StatusCode::METHOD_NOT_ALLOWED,
|
||||
[("Allow", "GET, POST".to_string())],
|
||||
"Only GET and POST methods are allowed".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown protocol
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[("Content-Type", "text/plain".to_string())],
|
||||
"Unknown protocol or path".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
qiming-mcp-proxy/mcp-proxy/src/server/task/mod.rs
Normal file
7
qiming-mcp-proxy/mcp-proxy/src/server/task/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod mcp_start_task;
|
||||
mod schedule_check_mcp_live;
|
||||
mod schedule_task;
|
||||
|
||||
pub use mcp_start_task::{integrate_server_with_axum, mcp_start_task};
|
||||
pub use schedule_check_mcp_live::schedule_check_mcp_live;
|
||||
pub use schedule_task::start_schedule_task;
|
||||
@@ -0,0 +1,195 @@
|
||||
use crate::get_proxy_manager;
|
||||
use crate::model::{CheckMcpStatusResponseStatus, GLOBAL_RESTART_TRACKER, McpType};
|
||||
use crate::server::task::mcp_start_task::mcp_start_task;
|
||||
use tokio::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// OneShot 服务超时时间:5分钟无活动则清理
|
||||
const ONESHOT_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
// 连续健康检查失败阈值:连续失败 3 次才触发重启
|
||||
const MAX_PROBE_FAILURES: u32 = 3;
|
||||
|
||||
/// 定期检查全局动态 router 里的 MCP 服务状态
|
||||
///
|
||||
/// ## 处理逻辑
|
||||
///
|
||||
/// 1. Error 状态 → 清理资源
|
||||
/// 2. 空闲超时(5分钟)→ 清理资源(资源回收)
|
||||
/// 3. 健康检查(只对 Ready 状态)
|
||||
/// - Pending → 跳过(等待启动完成)
|
||||
/// - Ready → 执行探测
|
||||
/// - 成功 → 重置失败计数
|
||||
/// - 失败 → 失败计数 + 1
|
||||
/// - 连续失败 >= 3 → 重启后端服务
|
||||
pub async fn schedule_check_mcp_live() {
|
||||
// 获取全局动态 router
|
||||
let proxy_manager = get_proxy_manager();
|
||||
// 获取所有 mcp 服务状态
|
||||
let mcp_service_statuses = proxy_manager.get_all_mcp_service_status();
|
||||
|
||||
// 打印当前有多少个 mcp 插件服务在运行
|
||||
info!(
|
||||
"There are currently {} mcp plug-in services running",
|
||||
mcp_service_statuses.len()
|
||||
);
|
||||
|
||||
// 遍历所有 mcp 服务状态
|
||||
for mcp_service_status in mcp_service_statuses {
|
||||
// 获取服务信息
|
||||
let mcp_id = mcp_service_status.mcp_id.clone();
|
||||
let mcp_type = mcp_service_status.mcp_type.clone();
|
||||
let cancellation_token = mcp_service_status.cancellation_token.clone();
|
||||
|
||||
// 1. 如果 mcp 的状态是 ERROR,则清理资源
|
||||
if let CheckMcpStatusResponseStatus::Error(_) =
|
||||
mcp_service_status.check_mcp_status_response_status
|
||||
{
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 根据 MCP 类型进行不同处理
|
||||
match mcp_type {
|
||||
McpType::Persistent => {
|
||||
// 检查持久化服务是否已被取消或子进程已终止
|
||||
if cancellation_token.is_cancelled() {
|
||||
info!(
|
||||
"The persistent MCP service {mcp_id} has been manually canceled and resources are being cleaned up."
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查子进程是否还在运行
|
||||
if let Some(handler) = proxy_manager.get_proxy_handler(&mcp_id)
|
||||
&& handler.is_terminated_async().await
|
||||
{
|
||||
info!(
|
||||
"The persistent MCP service {mcp_id} child process ended abnormally and cleaned up resources."
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
McpType::OneShot => {
|
||||
// 2. 检查空闲超时(基于所有请求的活动)
|
||||
let idle_time = mcp_service_status.last_accessed.elapsed();
|
||||
|
||||
// 空闲超时 → 清理资源
|
||||
if idle_time > ONESHOT_TIMEOUT {
|
||||
info!(
|
||||
"OneShot service {} idle timeout (idle time: {} seconds), clean up resources",
|
||||
mcp_id,
|
||||
idle_time.as_secs()
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(&mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 健康检查(只对 Ready 状态)
|
||||
// Pending 状态跳过探测,等待启动完成
|
||||
if !matches!(
|
||||
mcp_service_status.check_mcp_status_response_status,
|
||||
CheckMcpStatusResponseStatus::Ready
|
||||
) {
|
||||
// Pending 状态跳过探测
|
||||
continue;
|
||||
}
|
||||
|
||||
// 执行健康探测
|
||||
let handler = proxy_manager.get_proxy_handler(&mcp_id);
|
||||
if let Some(handler) = handler {
|
||||
let is_terminated = handler.is_terminated_async().await;
|
||||
|
||||
if is_terminated {
|
||||
let failures = proxy_manager.increment_probe_failures(&mcp_id);
|
||||
info!(
|
||||
"OneShot service {} health check failed ({}/{})",
|
||||
mcp_id, failures, MAX_PROBE_FAILURES
|
||||
);
|
||||
|
||||
if failures >= MAX_PROBE_FAILURES {
|
||||
info!(
|
||||
"OneShot service {} failed continuously {} times, triggering a restart",
|
||||
mcp_id, failures
|
||||
);
|
||||
restart_mcp_service(&mcp_id, proxy_manager).await;
|
||||
}
|
||||
} else {
|
||||
// 探测成功,重置失败计数
|
||||
proxy_manager.reset_probe_failures(&mcp_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重启 MCP 服务
|
||||
///
|
||||
/// ## 重启流程
|
||||
///
|
||||
/// 1. 检查重启冷却期(30秒)
|
||||
/// 2. 获取配置(从服务状态或缓存)
|
||||
/// 3. 清理旧资源(保留配置缓存)
|
||||
/// 4. 重新启动服务(复用 mcp_start_task)
|
||||
async fn restart_mcp_service(mcp_id: &str, proxy_manager: &crate::model::ProxyHandlerManager) {
|
||||
// 1. 检查重启冷却期
|
||||
if !GLOBAL_RESTART_TRACKER.can_restart(mcp_id) {
|
||||
info!(
|
||||
"Service {} is skipped during the restart cooling period.",
|
||||
mcp_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取配置(优先从服务状态,其次从缓存)
|
||||
let mcp_config = proxy_manager.get_mcp_config(mcp_id);
|
||||
let mcp_config = match mcp_config {
|
||||
Some(config) => Some(config),
|
||||
None => proxy_manager.get_mcp_config_from_cache(mcp_id).await,
|
||||
};
|
||||
|
||||
let Some(mcp_config) = mcp_config else {
|
||||
warn!(
|
||||
"Service {} has no configuration and cannot be restarted. Clean up resources.",
|
||||
mcp_id
|
||||
);
|
||||
if let Err(e) = proxy_manager.cleanup_resources(mcp_id).await {
|
||||
error!("Failed to cleanup resources for {}: {}", mcp_id, e);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// 3. 清理旧资源(保留配置缓存)
|
||||
if let Err(e) = proxy_manager.cleanup_resources_for_restart(mcp_id).await {
|
||||
error!("Cleanup service {} resource failed: {}", mcp_id, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 重新启动服务(复用 mcp_start_task,自动设置 Pending 状态)
|
||||
match mcp_start_task(mcp_config).await {
|
||||
Ok((_router, _cancellation_token)) => {
|
||||
// 重置失败计数(已在新的服务实例中初始化为 0)
|
||||
// 注意:此时 mcp_id 对应的是新的服务实例
|
||||
proxy_manager.reset_probe_failures(mcp_id);
|
||||
// 记录重启时间
|
||||
GLOBAL_RESTART_TRACKER.record_restart(mcp_id);
|
||||
info!("Service {} restarted successfully", mcp_id);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Service {} failed to restart: {}", mcp_id, e);
|
||||
// 重启失败,设置 Error 状态
|
||||
// 注意:此时服务已被清理,无法设置状态,只能记录日志
|
||||
// 下次请求到来时会触发重新启动
|
||||
}
|
||||
}
|
||||
}
|
||||
65
qiming-mcp-proxy/mcp-proxy/src/server/task/schedule_task.rs
Normal file
65
qiming-mcp-proxy/mcp-proxy/src/server/task/schedule_task.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use crate::server::task::schedule_check_mcp_live;
|
||||
use log::{debug, info, warn};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::time::{Duration, interval};
|
||||
|
||||
/// 启动定时任务,定期检查MCP服务状态
|
||||
///
|
||||
/// 这个函数会创建一个tokio定时任务,每隔指定的时间间隔执行一次`schedule_check_mcp_live`函数
|
||||
/// 用于检查和清理不再需要的MCP服务资源
|
||||
pub async fn start_schedule_task() {
|
||||
info!("Start the MCP service status check scheduled task");
|
||||
|
||||
// 创建一个tokio定时器,每20秒执行一次
|
||||
let mut interval = interval(Duration::from_secs(20));
|
||||
|
||||
// 使用原子布尔值来跟踪任务是否正在执行
|
||||
let is_running = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// 启动一个新的异步任务
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
// 等待下一个时间点
|
||||
interval.tick().await;
|
||||
|
||||
// 检查是否有任务正在执行
|
||||
if is_running.load(Ordering::SeqCst) {
|
||||
warn!(
|
||||
"The last MCP service status check task has not been completed and this execution will be skipped."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 标记任务开始执行
|
||||
is_running.store(true, Ordering::SeqCst);
|
||||
|
||||
// 执行MCP服务状态检查
|
||||
debug!("Perform periodic checks on MCP service status...");
|
||||
|
||||
// 在一个新的任务中执行检查,这样可以捕获任何异常
|
||||
let is_running_clone = is_running.clone();
|
||||
tokio::spawn(async move {
|
||||
// 执行检查任务
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(10), // 设置超时时间为10秒,小于间隔时间
|
||||
schedule_check_mcp_live(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
debug!("MCP service status check completed");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("MCP service status check task timed out");
|
||||
}
|
||||
}
|
||||
|
||||
// 无论成功还是失败,都标记任务已完成
|
||||
is_running_clone.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
info!("MCP service status check scheduled task has been started");
|
||||
}
|
||||
60
qiming-mcp-proxy/mcp-proxy/src/server/telemetry.rs
Normal file
60
qiming-mcp-proxy/mcp-proxy/src/server/telemetry.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use anyhow::Result;
|
||||
use opentelemetry::global;
|
||||
use opentelemetry_sdk::trace::{RandomIdGenerator, Sampler, SdkTracerProvider};
|
||||
|
||||
/// 初始化 OpenTelemetry tracer provider
|
||||
///
|
||||
/// 这个函数必须在创建 telemetry layer 之前调用
|
||||
pub fn init_tracer_provider(_service_name: &str, _service_version: &str) -> Result<()> {
|
||||
// 创建 tracer provider
|
||||
let tracer_provider = SdkTracerProvider::builder()
|
||||
.with_sampler(Sampler::AlwaysOn)
|
||||
.with_id_generator(RandomIdGenerator::default())
|
||||
.build();
|
||||
|
||||
// 设置全局 tracer provider
|
||||
global::set_tracer_provider(tracer_provider);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 创建增强的 OpenTelemetry layer
|
||||
///
|
||||
/// 这个函数创建一个配置好的 OpenTelemetry layer,可以与现有的 tracing 配置集成
|
||||
/// 注意:必须先调用 init_tracer_provider()
|
||||
pub fn create_telemetry_layer() -> impl tracing_subscriber::Layer<tracing_subscriber::Registry> {
|
||||
tracing_opentelemetry::layer()
|
||||
}
|
||||
|
||||
/// 记录服务启动信息
|
||||
///
|
||||
/// 在 telemetry 系统初始化后调用,记录服务的基本信息
|
||||
pub fn log_service_info(service_name: &str, service_version: &str) -> Result<()> {
|
||||
tracing::info!(
|
||||
service_name = %service_name,
|
||||
service_version = %service_version,
|
||||
"Service started with OpenTelemetry tracing enabled"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 优雅关闭 OpenTelemetry
|
||||
pub fn shutdown_telemetry() {
|
||||
tracing::info!("Shutting down OpenTelemetry");
|
||||
// 注意:在新版本的 OpenTelemetry 中,shutdown 方法可能不同
|
||||
// 这里我们简单地记录关闭信息
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_log_service_info() {
|
||||
let result = log_service_info("test-service", "0.1.0");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// 清理
|
||||
shutdown_telemetry();
|
||||
}
|
||||
}
|
||||
157
qiming-mcp-proxy/mcp-proxy/src/tests/coze_mcp_test.rs
Normal file
157
qiming-mcp-proxy/mcp-proxy/src/tests/coze_mcp_test.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! Coze MCP Service Integration Tests
|
||||
//!
|
||||
//! This module tests the protocol conversion from Streamable HTTP (backend) to SSE (frontend)
|
||||
//! when connecting to Coze MCP services.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! These tests require the following environment variables:
|
||||
//!
|
||||
//! - `COZE_PLUGIN_ID` - Your Coze plugin ID
|
||||
//! - `COZE_BEARER_TOKEN` - Your Coze API Bearer token
|
||||
//!
|
||||
//! # Running the tests
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Set environment variables and run the test
|
||||
//! COZE_PLUGIN_ID="your_plugin_id" \
|
||||
//! COZE_BEARER_TOKEN="your_bearer_token" \
|
||||
//! cargo test -p mcp-stdio-proxy test_coze_streamable_to_sse_proxy
|
||||
//!
|
||||
//! # Or run with logging
|
||||
//! COZE_PLUGIN_ID="your_plugin_id" \
|
||||
//! COZE_BEARER_TOKEN="your_bearer_token" \
|
||||
//! RUST_LOG=debug cargo test -p mcp-stdio-proxy test_coze_streamable_to_sse_proxy
|
||||
//! ```
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{
|
||||
mcp_start_task,
|
||||
model::{McpConfig, McpProtocol, McpType},
|
||||
proxy::{McpClientConfig, SseClientConnection},
|
||||
};
|
||||
|
||||
/// Builds Coze MCP configuration from environment variables
|
||||
fn get_coze_config() -> Result<String> {
|
||||
let plugin_id = std::env::var("COZE_PLUGIN_ID")
|
||||
.map_err(|_| anyhow::anyhow!("COZE_PLUGIN_ID environment variable not set"))?;
|
||||
let bearer_token = std::env::var("COZE_BEARER_TOKEN")
|
||||
.map_err(|_| anyhow::anyhow!("COZE_BEARER_TOKEN environment variable not set"))?;
|
||||
|
||||
let config = r#"{
|
||||
"mcpServers": {
|
||||
"coze_plugin_tianyancha": {
|
||||
"url": "https://mcp.coze.cn/v1/plugins/PLUGIN_ID",
|
||||
"headers": {
|
||||
"Authorization": "Bearer BEARER_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
Ok(config
|
||||
.replace("PLUGIN_ID", &plugin_id)
|
||||
.replace("BEARER_TOKEN", &bearer_token))
|
||||
}
|
||||
|
||||
/// Test: Streamable HTTP backend to SSE frontend protocol conversion
|
||||
///
|
||||
/// This test verifies that the mcp-proxy correctly:
|
||||
/// 1. Configures SSE protocol for the client (frontend)
|
||||
/// 2. Auto-detects Streamable HTTP protocol for the Coze backend
|
||||
/// 3. Transparently converts between the two protocols
|
||||
/// 4. Returns valid tools/list responses
|
||||
#[tokio::test]
|
||||
#[ignore] // Mark as ignored since it requires network access
|
||||
async fn test_coze_streamable_to_sse_proxy() -> Result<()> {
|
||||
// Initialize logging for test
|
||||
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
|
||||
|
||||
println!("🧪 Starting Coze MCP test: Streamable HTTP -> SSE conversion");
|
||||
|
||||
// Step 1: Create configuration with SSE client protocol
|
||||
// The backend protocol (Streamable HTTP) will be auto-detected
|
||||
let coze_config = get_coze_config()?;
|
||||
let mcp_config = McpConfig::from_json_with_server(
|
||||
"coze_plugin_tianyancha".to_string(),
|
||||
coze_config,
|
||||
McpType::OneShot,
|
||||
McpProtocol::Sse, // SSE frontend (client protocol)
|
||||
)?;
|
||||
|
||||
println!("✅ Configuration created with SSE client protocol");
|
||||
|
||||
// Step 2: Start MCP service
|
||||
// The proxy will auto-detect the backend protocol and create appropriate routes
|
||||
let (router, ct) = mcp_start_task(mcp_config).await?;
|
||||
println!("✅ MCP service started");
|
||||
|
||||
// Step 3: Start HTTP server with the router
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
println!("✅ HTTP server listening on 127.0.0.1:{}", port);
|
||||
|
||||
// Spawn server in background
|
||||
let server_handle = tokio::spawn(async move {
|
||||
axum::serve(listener, router.into_make_service())
|
||||
.await
|
||||
.expect("Server error");
|
||||
});
|
||||
|
||||
// Step 4: Wait for backend to be ready
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
println!("✅ Backend ready");
|
||||
|
||||
// Step 5: Construct SSE endpoint path
|
||||
// The SSE server exposes endpoints at /mcp/sse/proxy/{mcp_id}/sse
|
||||
let sse_url = format!(
|
||||
"http://127.0.0.1:{}/mcp/sse/proxy/coze_plugin_tianyancha/sse",
|
||||
port
|
||||
);
|
||||
|
||||
// Step 6: Connect SSE client
|
||||
let client_config = McpClientConfig::new(sse_url.to_string());
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
SseClientConnection::connect(client_config.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("SSE connection timeout (30s)"))?
|
||||
.map_err(|e| anyhow::anyhow!("SSE connection failed: {}", e))?;
|
||||
println!("✅ SSE client connected to {}", sse_url);
|
||||
|
||||
// Step 7: Get tools list using the high-level API
|
||||
let tools = tokio::time::timeout(Duration::from_secs(30), conn.list_tools())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("list_tools timeout (30s)"))?
|
||||
.map_err(|e| anyhow::anyhow!("list_tools failed: {}", e))?;
|
||||
println!("📋 Received tools/list response: {} tools", tools.len());
|
||||
|
||||
// Step 8: Verify response structure
|
||||
if tools.is_empty() {
|
||||
println!("⚠️ Warning: tools/list returned empty array");
|
||||
} else {
|
||||
println!("✅ Found {} tools:", tools.len());
|
||||
for tool in &tools {
|
||||
let desc = tool.description.as_deref().unwrap_or("no description");
|
||||
println!(" - {} : {}", tool.name, desc);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9: Verify tool structure
|
||||
for tool in &tools {
|
||||
assert!(!tool.name.is_empty(), "Tool name should not be empty");
|
||||
// Description is optional, so we just check the name
|
||||
println!(" ✓ Tool '{}' has valid structure", tool.name);
|
||||
}
|
||||
|
||||
// Step 10: Cleanup
|
||||
ct.cancel();
|
||||
server_handle.abort();
|
||||
println!("🧹 Cleanup complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
12
qiming-mcp-proxy/mcp-proxy/src/tests/mod.rs
Normal file
12
qiming-mcp-proxy/mcp-proxy/src/tests/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#[cfg(test)]
|
||||
pub mod test_utils {
|
||||
// Tests utility module for common test setup
|
||||
}
|
||||
|
||||
// Coze MCP integration tests - Streamable HTTP to SSE conversion
|
||||
#[cfg(test)]
|
||||
pub mod coze_mcp_test;
|
||||
|
||||
// Protocol detection tests - SSE vs Streamable HTTP
|
||||
#[cfg(test)]
|
||||
pub mod protocol_detection_test;
|
||||
270
qiming-mcp-proxy/mcp-proxy/src/tests/protocol_detection_test.rs
Normal file
270
qiming-mcp-proxy/mcp-proxy/src/tests/protocol_detection_test.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
//! Protocol Detection Integration Tests
|
||||
//!
|
||||
//! Tests protocol detection (SSE vs Streamable HTTP) against real MCP services.
|
||||
//!
|
||||
//! # Running the tests
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Run the zimage streamable HTTP test (requires DASHSCOPE_API_KEY)
|
||||
//! DASHSCOPE_API_KEY=sk-xxx cargo test -p mcp-stdio-proxy test_zimage_protocol_detection -- --ignored --nocapture
|
||||
//!
|
||||
//! # Run the howtocook SSE test
|
||||
//! cargo test -p mcp-stdio-proxy test_howtocook_sse_protocol_detection -- --ignored --nocapture
|
||||
//!
|
||||
//! # Run all protocol detection network tests
|
||||
//! DASHSCOPE_API_KEY=sk-xxx cargo test -p mcp-stdio-proxy protocol_detection_test -- --ignored --nocapture
|
||||
//!
|
||||
//! # Run with debug logging
|
||||
//! DASHSCOPE_API_KEY=sk-xxx RUST_LOG=debug cargo test -p mcp-stdio-proxy protocol_detection_test -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use crate::model::{McpJsonServerParameters, McpProtocol, McpServerConfig};
|
||||
|
||||
/// The howtocook SSE MCP JSON config (SSE protocol, 测试环境密钥)
|
||||
const HOWTOCOOK_MCP_JSON: &str = r#"{
|
||||
"mcpServers": {
|
||||
"howtocook-跳跳糖": {
|
||||
"type": "sse",
|
||||
"url": "https://testagent.xspaceagi.com/api/mcp/sse?ak=ak-27b83516dfd4417a82f764fe3e859a6e"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// Build zimage MCP JSON config with Authorization token from environment variable
|
||||
///
|
||||
/// 环境变量: `DASHSCOPE_API_KEY`
|
||||
/// 运行网络测试前需设置: `export DASHSCOPE_API_KEY=sk-xxx`
|
||||
fn build_zimage_mcp_json(api_key: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"zimage": {{
|
||||
"type": "streamableHttp",
|
||||
"description": "Z-Image-Turbo 图像生成模型",
|
||||
"isActive": true,
|
||||
"name": "阿里云百炼_Z Image 图像生成",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1/mcps/zimage/mcp",
|
||||
"headers": {{
|
||||
"Authorization": "Bearer {}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}"#,
|
||||
api_key
|
||||
)
|
||||
}
|
||||
|
||||
/// 用于本地解析测试的占位密钥(不需要真实密钥)
|
||||
const ZIMAGE_TEST_PLACEHOLDER_KEY: &str = "test-placeholder-key";
|
||||
|
||||
// ==================== 本地解析测试(无网络) ====================
|
||||
|
||||
#[test]
|
||||
fn test_zimage_config_type_parsed_as_stream() {
|
||||
let zimage_json = build_zimage_mcp_json(ZIMAGE_TEST_PLACEHOLDER_KEY);
|
||||
let params = McpJsonServerParameters::from(zimage_json);
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
match config {
|
||||
McpServerConfig::Url(url_config) => {
|
||||
// 1. 原始 type 字段值
|
||||
assert_eq!(url_config.r#type, Some("streamableHttp".to_string()));
|
||||
|
||||
// 2. get_protocol_type() 返回 Some,且 is_streamable
|
||||
let protocol_type = url_config.get_protocol_type();
|
||||
assert!(
|
||||
protocol_type.is_some(),
|
||||
"streamableHttp should be recognized"
|
||||
);
|
||||
assert!(protocol_type.as_ref().unwrap().is_streamable());
|
||||
|
||||
// 3. 转换为 McpProtocol::Stream
|
||||
assert_eq!(
|
||||
protocol_type.unwrap().to_mcp_protocol(),
|
||||
McpProtocol::Stream
|
||||
);
|
||||
|
||||
// 4. FromStr 解析也能识别 "streamableHttp"
|
||||
assert_eq!(
|
||||
"streamableHttp".parse::<McpProtocol>(),
|
||||
Ok(McpProtocol::Stream)
|
||||
);
|
||||
|
||||
// 5. URL 正确解析
|
||||
assert_eq!(
|
||||
url_config.get_url(),
|
||||
"https://dashscope.aliyuncs.com/api/v1/mcps/zimage/mcp"
|
||||
);
|
||||
|
||||
// 6. headers 包含 Authorization
|
||||
let headers = url_config.headers.as_ref().unwrap();
|
||||
assert!(headers.contains_key("Authorization"));
|
||||
assert_eq!(
|
||||
headers["Authorization"],
|
||||
format!("Bearer {}", ZIMAGE_TEST_PLACEHOLDER_KEY)
|
||||
);
|
||||
}
|
||||
McpServerConfig::Command(_) => panic!("Expected URL config"),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 网络探测测试(需要网络,默认 ignore) ====================
|
||||
|
||||
/// Test: SSE detector should return false for a Streamable HTTP service
|
||||
///
|
||||
/// 使用真实 zimage 服务验证:
|
||||
/// - is_sse_with_headers → false(不是 SSE)
|
||||
/// - is_streamable_http_with_headers → true(是 Streamable HTTP)
|
||||
/// - detect_mcp_protocol_with_headers → Stream
|
||||
///
|
||||
/// 需要设置环境变量: `DASHSCOPE_API_KEY`
|
||||
#[tokio::test]
|
||||
#[ignore] // 需要网络访问 + DASHSCOPE_API_KEY 环境变量
|
||||
async fn test_zimage_protocol_detection() {
|
||||
let api_key = std::env::var("DASHSCOPE_API_KEY")
|
||||
.expect("需要设置 DASHSCOPE_API_KEY 环境变量才能运行此测试");
|
||||
let zimage_json = build_zimage_mcp_json(&api_key);
|
||||
let params = McpJsonServerParameters::from(zimage_json);
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
let (url, headers) = match config {
|
||||
McpServerConfig::Url(url_config) => {
|
||||
let url = url_config.get_url().to_string();
|
||||
let headers = url_config.headers.clone().unwrap_or_default();
|
||||
(url, headers)
|
||||
}
|
||||
_ => panic!("Expected URL config"),
|
||||
};
|
||||
|
||||
println!("=== Protocol detection test: {} ===", url);
|
||||
|
||||
// 1. SSE 探测应返回 false
|
||||
println!("\\n--- SSE Probing ---");
|
||||
let is_sse = mcp_sse_proxy::is_sse_with_headers(&url, Some(&headers)).await;
|
||||
println!("is_sse_with_headers = {}", is_sse);
|
||||
assert!(!is_sse, "Streamable HTTP 服务不应被识别为 SSE");
|
||||
|
||||
// 2. Streamable HTTP 探测应返回 true
|
||||
println!("\\n--- Streamable HTTP probe ---");
|
||||
let is_stream =
|
||||
mcp_streamable_proxy::is_streamable_http_with_headers(&url, Some(&headers)).await;
|
||||
println!("is_streamable_http_with_headers = {}", is_stream);
|
||||
assert!(
|
||||
is_stream,
|
||||
"zimage 服务应被识别为 Streamable HTTP(需要传递 Authorization header)"
|
||||
);
|
||||
|
||||
// 3. 综合探测应返回 Stream
|
||||
println!("\\n--- Comprehensive protocol detection ---");
|
||||
let detected = crate::server::detect_mcp_protocol_with_headers(&url, Some(&headers))
|
||||
.await
|
||||
.unwrap();
|
||||
println!("detect_mcp_protocol_with_headers = {:?}", detected);
|
||||
assert_eq!(detected, McpProtocol::Stream, "综合探测应返回 Stream 协议");
|
||||
|
||||
println!("\\n=== All detection results are correct ===");
|
||||
}
|
||||
|
||||
// ==================== howtocook SSE 本地解析测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_howtocook_config_type_parsed_as_sse() {
|
||||
let params = McpJsonServerParameters::from(HOWTOCOOK_MCP_JSON.to_string());
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
match config {
|
||||
McpServerConfig::Url(url_config) => {
|
||||
// 1. 原始 type 字段值
|
||||
assert_eq!(url_config.r#type, Some("sse".to_string()));
|
||||
|
||||
// 2. get_protocol_type() 返回 Some,且不是 streamable
|
||||
let protocol_type = url_config.get_protocol_type();
|
||||
assert!(protocol_type.is_some(), "sse should be recognized");
|
||||
assert!(!protocol_type.as_ref().unwrap().is_streamable());
|
||||
|
||||
// 3. 转换为 McpProtocol::Sse
|
||||
assert_eq!(protocol_type.unwrap().to_mcp_protocol(), McpProtocol::Sse);
|
||||
|
||||
// 4. FromStr 解析也能识别 "sse"
|
||||
assert_eq!("sse".parse::<McpProtocol>(), Ok(McpProtocol::Sse));
|
||||
assert_eq!("SSE".parse::<McpProtocol>(), Ok(McpProtocol::Sse));
|
||||
|
||||
// 5. URL 正确解析(使用 url 字段而非 baseUrl)
|
||||
assert_eq!(
|
||||
url_config.get_url(),
|
||||
"https://testagent.xspaceagi.com/api/mcp/sse?ak=ak-27b83516dfd4417a82f764fe3e859a6e"
|
||||
);
|
||||
|
||||
// 6. 无 headers
|
||||
assert!(url_config.headers.is_none());
|
||||
}
|
||||
McpServerConfig::Command(_) => panic!("Expected URL config"),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== howtocook SSE 网络探测测试 ====================
|
||||
|
||||
/// Test: SSE detector should return true for a real SSE service
|
||||
///
|
||||
/// 使用真实 howtocook SSE 服务验证:
|
||||
/// - is_sse_with_headers → true(是 SSE,应探测到 event: endpoint)
|
||||
/// - detect_mcp_protocol_with_headers → Sse
|
||||
#[tokio::test]
|
||||
#[ignore] // 需要网络访问,使用 --ignored 运行
|
||||
async fn test_howtocook_sse_protocol_detection() {
|
||||
let params = McpJsonServerParameters::from(HOWTOCOOK_MCP_JSON.to_string());
|
||||
let config = params.try_get_first_mcp_server().unwrap();
|
||||
|
||||
let url = match config {
|
||||
McpServerConfig::Url(url_config) => url_config.get_url().to_string(),
|
||||
_ => panic!("Expected URL config"),
|
||||
};
|
||||
|
||||
println!("=== SSE protocol detection test: {} ===", url);
|
||||
|
||||
// 1. SSE 探测应返回 true(该服务是真实 MCP SSE,会发送 event: endpoint)
|
||||
println!("\\n--- SSE Probing ---");
|
||||
let is_sse = mcp_sse_proxy::is_sse(&url).await;
|
||||
println!("is_sse = {}", is_sse);
|
||||
assert!(
|
||||
is_sse,
|
||||
"howtocook SSE 服务应被识别为 SSE(发现 event: endpoint)"
|
||||
);
|
||||
|
||||
// 2. Streamable HTTP 探测应返回 false(SSE 服务不应被识别为 Streamable HTTP)
|
||||
println!("\\n--- Streamable HTTP probe ---");
|
||||
let is_stream = mcp_streamable_proxy::is_streamable_http(&url).await;
|
||||
println!("is_streamable_http = {}", is_stream);
|
||||
assert!(!is_stream, "SSE 服务不应被识别为 Streamable HTTP");
|
||||
|
||||
// 3. 综合探测应返回 Sse
|
||||
println!("\\n--- Comprehensive protocol detection ---");
|
||||
let detected = crate::server::detect_mcp_protocol(&url).await.unwrap();
|
||||
println!("detect_mcp_protocol = {:?}", detected);
|
||||
assert_eq!(detected, McpProtocol::Sse, "综合探测应返回 Sse 协议");
|
||||
|
||||
println!("\\n=== SSE detection result is correct ===");
|
||||
}
|
||||
|
||||
/// Test: protocol detection without headers should still default to Stream
|
||||
///
|
||||
/// 不传 Authorization header,探测可能失败,但应兜底为 Stream
|
||||
#[tokio::test]
|
||||
#[ignore] // 需要网络访问
|
||||
async fn test_zimage_detection_without_headers() {
|
||||
let url = "https://dashscope.aliyuncs.com/api/v1/mcps/zimage/mcp";
|
||||
|
||||
println!("=== No header detection test: {} ===", url);
|
||||
|
||||
// SSE 探测应返回 false
|
||||
let is_sse = mcp_sse_proxy::is_sse(url).await;
|
||||
println!("is_sse = {}", is_sse);
|
||||
assert!(!is_sse);
|
||||
|
||||
// 综合探测应兜底为 Stream
|
||||
let detected = crate::server::detect_mcp_protocol(url).await.unwrap();
|
||||
println!("detect_mcp_protocol = {:?}", detected);
|
||||
assert_eq!(detected, McpProtocol::Stream, "无 header 时应兜底为 Stream");
|
||||
|
||||
println!("=== No header, the detection result is correct ===");
|
||||
}
|
||||
Reference in New Issue
Block a user