Files
cc-switch/src-tauri/tests/mcp_commands.rs
Jason 7ea2c3452b feat(opencode): complete Phase 4 - MCP sync module
Add mcp/opencode.rs with format conversion between CC Switch and OpenCode:
- stdio ↔ local type conversion
- command+args ↔ command array format
- env ↔ environment field mapping
- sse/http ↔ remote type conversion

Public API:
- sync_enabled_to_opencode: Batch sync all enabled servers
- sync_single_server_to_opencode: Sync individual server
- remove_server_from_opencode: Remove from live config
- import_from_opencode: Import servers from OpenCode config

Also fix test files to include new opencode field in McpApps struct.
All 4 unit tests pass for format conversion.
2026-01-15 16:11:25 +08:00

563 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::fs;
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppType, McpApps, McpServer, McpService, MultiAppConfig,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn import_default_config_claude_persists_provider() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let settings_path = get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent).expect("create claude settings dir");
}
let settings = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "test-key",
"ANTHROPIC_BASE_URL": "https://api.test"
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&settings).expect("serialize settings"),
)
.expect("seed claude settings.json");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Claude);
let state = create_test_state_with_config(&config).expect("create test state");
import_default_config_test_hook(&state, AppType::Claude)
.expect("import default config succeeds");
// 验证内存状态
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("default"));
let default_provider = providers.get("default").expect("default provider");
assert_eq!(
default_provider.settings_config, settings,
"default provider should capture live settings"
);
// 验证数据已持久化到数据库v3.7.0+ 使用 SQLite 而非 config.json
let db_path = home.join(".cc-switch").join("cc-switch.db");
assert!(
db_path.exists(),
"importing default config should persist to cc-switch.db"
);
}
#[test]
fn import_default_config_without_live_file_returns_error() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
let err = import_default_config_test_hook(&state, AppType::Claude)
.expect_err("missing live file should error");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("Claude Code 配置文件不存在"),
"unexpected error message: {zh}"
),
AppError::Message(msg) => assert!(
msg.contains("Claude Code 配置文件不存在"),
"unexpected error message: {msg}"
),
other => panic!("unexpected error variant: {other:?}"),
}
// 使用数据库架构,不再检查 config.json
// 失败的导入不应该向数据库写入任何供应商
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
providers.is_empty(),
"failed import should not create any providers in database"
);
}
#[test]
fn import_mcp_from_claude_creates_config_and_enables_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mcp_path = get_claude_mcp_path();
let claude_json = json!({
"mcpServers": {
"echo": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
&mcp_path,
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed ~/.claude.json");
let config = MultiAppConfig::default();
let state = create_test_state_with_config(&config).expect("create test state");
let changed = McpService::import_from_claude(&state).expect("import mcp from claude succeeds");
assert!(
changed > 0,
"import should report inserted or normalized entries"
);
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers
.get("echo")
.expect("server imported into unified structure");
assert!(
entry.apps.claude,
"imported server should have Claude app enabled"
);
// 验证数据已持久化到数据库
let db_path = home.join(".cc-switch").join("cc-switch.db");
assert!(
db_path.exists(),
"state.save should persist to cc-switch.db when changes detected"
);
}
#[test]
fn import_mcp_from_claude_invalid_json_preserves_state() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mcp_path = get_claude_mcp_path();
fs::write(&mcp_path, "{\"mcpServers\":") // 不完整 JSON
.expect("seed invalid ~/.claude.json");
let state = create_test_state().expect("create test state");
let err =
McpService::import_from_claude(&state).expect_err("invalid json should bubble up error");
match err {
AppError::McpValidation(msg) => assert!(
msg.contains("解析 ~/.claude.json 失败"),
"unexpected error message: {msg}"
),
other => panic!("unexpected error variant: {other:?}"),
}
// 使用数据库架构,检查 MCP 服务器未被写入
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
assert!(
servers.is_empty(),
"failed import should not persist any MCP servers to database"
);
}
#[test]
fn set_mcp_enabled_for_codex_writes_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 创建 Codex 配置目录和文件
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("auth.json"),
r#"{"OPENAI_API_KEY":"test-key"}"#,
)
.expect("create auth.json");
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex);
// v3.7.0: 使用统一结构
config.mcp.servers = Some(HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"codex-server".into(),
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false, // 初始未启用
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = create_test_state_with_config(&config).expect("create test state");
// v3.7.0: 使用 toggle_app 替代 set_enabled
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("codex-server").expect("codex server exists");
assert!(
entry.apps.codex,
"server should have Codex app enabled after toggle"
);
let toml_path = cc_switch_lib::get_codex_config_path();
assert!(
toml_path.exists(),
"enabling server should trigger sync to ~/.codex/config.toml"
);
let toml_text = fs::read_to_string(&toml_path).expect("read codex config");
assert!(
toml_text.contains("codex-server"),
"codex config should include the enabled server definition"
);
}
#[test]
fn enabling_codex_mcp_skips_when_codex_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Codex 配置目录不存在(模拟“未安装/未运行过 Codex CLI”
assert!(
!home.join(".codex").exists(),
"~/.codex should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Codex 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Codex目录缺失时应跳过写入不创建 ~/.codex/config.toml
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle codex should succeed even when ~/.codex is missing");
assert!(
!home.join(".codex").exists(),
"~/.codex should still not exist after skipped sync"
);
}
#[test]
fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create ~/.claude dir");
// 先创建一个启用 Claude 的 MCP 服务器
let state = support::create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert should sync to Claude live config");
// 确认已写入 ~/.claude.json
let mcp_path = get_claude_mcp_path();
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_some(),
"echo should exist in Claude live config after enabling"
);
// 再次 upsert取消勾选 Claudeapps.claude=false应从 Claude live 配置中移除
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert disabling app should remove from Claude live config");
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json after disable");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_none(),
"echo should be removed from Claude live config after disabling"
);
}
#[test]
fn import_mcp_from_multiple_apps_merges_enabled_flags() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 1) Claude: ~/.claude.json
let mcp_path = get_claude_mcp_path();
let claude_json = json!({
"mcpServers": {
"shared": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
&mcp_path,
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed ~/.claude.json");
// 2) Codex: ~/.codex/config.toml
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("config.toml"),
r#"[mcp_servers.shared]
type = "stdio"
command = "echo"
"#,
)
.expect("seed ~/.codex/config.toml");
let state = support::create_test_state().expect("create test state");
McpService::import_from_claude(&state).expect("import from claude");
McpService::import_from_codex(&state).expect("import from codex");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("shared").expect("shared server exists");
assert!(entry.apps.claude, "shared should enable Claude");
assert!(entry.apps.codex, "shared should enable Codex");
}
#[test]
fn import_mcp_from_gemini_sse_url_only_is_valid() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Gemini MCP 位于 ~/.gemini/settings.json
let gemini_dir = home.join(".gemini");
fs::create_dir_all(&gemini_dir).expect("create gemini dir");
let settings_path = gemini_dir.join("settings.json");
// Gemini SSE只包含 urlGemini 不使用 type 字段)
let gemini_settings = json!({
"mcpServers": {
"sse-server": {
"url": "https://example.com/sse"
}
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"),
)
.expect("seed ~/.gemini/settings.json");
let state = support::create_test_state().expect("create test state");
let changed = McpService::import_from_gemini(&state).expect("import from gemini");
assert!(changed > 0, "should import at least 1 server");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("sse-server").expect("sse-server exists");
assert!(entry.apps.gemini, "imported server should enable Gemini");
assert_eq!(
entry.server.get("type").and_then(|v| v.as_str()),
Some("sse"),
"Gemini url-only server should be normalized to type=sse in unified structure"
);
}
#[test]
fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Gemini 配置目录不存在(模拟“未安装/未运行过 Gemini CLI”
assert!(
!home.join(".gemini").exists(),
"~/.gemini should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Gemini 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "gemini-server".to_string(),
name: "Gemini Server".to_string(),
server: json!({
"type": "sse",
"url": "https://example.com/sse"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Gemini目录缺失时应跳过写入不创建 ~/.gemini/settings.json
McpService::toggle_app(&state, "gemini-server", AppType::Gemini, true)
.expect("toggle gemini should succeed even when ~/.gemini is missing");
assert!(
!home.join(".gemini").exists(),
"~/.gemini should still not exist after skipped sync"
);
}
#[test]
fn enabling_claude_mcp_skips_when_claude_config_absent() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Claude 相关目录/文件都不存在(模拟“未安装/未运行过 Claude”
assert!(
!home.join(".claude").exists(),
"~/.claude should not exist in fresh test environment"
);
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Claude 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "claude-server".to_string(),
name: "Claude Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Claude配置缺失时应跳过写入不创建 ~/.claude.json
McpService::toggle_app(&state, "claude-server", AppType::Claude, true)
.expect("toggle claude should succeed even when ~/.claude is missing");
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should still not exist after skipped sync"
);
}