提交qiming-mcp-proxy

This commit is contained in:
Codex
2026-06-01 13:03:20 +08:00
parent 9e9486b7c2
commit afb3d9f4e6
394 changed files with 124494 additions and 0 deletions

View 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(())
}

View 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;

View 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 探测应返回 falseSSE 服务不应被识别为 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 ===");
}