提交qiming-mcp-proxy
This commit is contained in:
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