Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,348 @@
use std::collections::HashMap;
use nomifun_common::McpSource;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::McpServerTransport;
use super::cli_helpers::{
DETECT_TIMEOUT, MUTATE_TIMEOUT, is_cli_installed, normalize_detection_status, run_cli, strip_ansi,
};
const CLI_NAME: &str = "claude";
/// Scopes to try when removing a server (user → local → project).
const REMOVE_SCOPES: &[&str] = &["user", "local", "project"];
/// MCP Agent adapter for Claude CLI.
///
/// # CLI Commands
///
/// - **detect**: `claude mcp list`
/// - **install (stdio)**: `claude mcp add-json -s user <name> <json>`
/// - **install (http/sse)**: `claude mcp add -s user --transport <type> <name> <url> [--header ...]`
/// - **remove**: `claude mcp remove -s <scope> <name>` (tries user → local → project)
///
/// Claude's list output uses a custom format:
/// `name: command args - ✓ Connected` or `name: command args - ✗ Failed`
pub struct ClaudeAdapter;
#[async_trait::async_trait]
impl McpAgentAdapter for ClaudeAdapter {
fn source(&self) -> McpSource {
McpSource::Claude
}
async fn is_installed(&self) -> Result<bool, McpError> {
is_cli_installed(CLI_NAME).await
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let (stdout, _stderr) = run_cli(CLI_NAME, &["mcp", "list"], DETECT_TIMEOUT).await?;
Ok(parse_claude_list_output(&stdout))
}
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
match transport {
McpServerTransport::Stdio { command, args, env } => {
let config = build_stdio_json(command, args, env);
let config_str =
serde_json::to_string(&config).map_err(|e| McpError::AgentOperationFailed(e.to_string()))?;
run_cli(
CLI_NAME,
&["mcp", "add-json", "-s", "user", name, &config_str],
MUTATE_TIMEOUT,
)
.await?;
}
McpServerTransport::Sse { url, headers } => {
install_http_like(name, "sse", url, headers).await?;
}
McpServerTransport::Http { url, headers } => {
install_http_like(name, "http", url, headers).await?;
}
}
Ok(())
}
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
// Try each scope; stop on first success or "not found".
for scope in REMOVE_SCOPES {
let (stdout, _stderr) = run_cli(CLI_NAME, &["mcp", "remove", "-s", scope, name], MUTATE_TIMEOUT).await?;
let lower = stdout.to_lowercase();
if lower.contains("removed") || lower.contains("not found") {
return Ok(());
}
}
// If none of the scopes reported "removed" or "not found", treat as
// idempotent success (server may simply not exist).
Ok(())
}
}
/// Install an HTTP-like (sse/http) server via `claude mcp add`.
async fn install_http_like(
name: &str,
transport_type: &str,
url: &str,
headers: &HashMap<String, String>,
) -> Result<(), McpError> {
let mut args = vec![
"mcp".to_owned(),
"add".to_owned(),
"-s".to_owned(),
"user".to_owned(),
"--transport".to_owned(),
transport_type.to_owned(),
name.to_owned(),
url.to_owned(),
];
for (key, value) in headers {
args.push("--header".to_owned());
args.push(format!("{key}: {value}"));
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
run_cli(CLI_NAME, &arg_refs, MUTATE_TIMEOUT).await?;
Ok(())
}
/// Build the JSON config for `claude mcp add-json`.
fn build_stdio_json(command: &str, args: &[String], env: &HashMap<String, String>) -> serde_json::Value {
let mut config = serde_json::json!({
"command": command,
"args": args,
});
if !env.is_empty() {
config["env"] = serde_json::json!(env);
}
config
}
// ---------------------------------------------------------------------------
// Output parsing
// ---------------------------------------------------------------------------
/// Parse Claude CLI `mcp list` output.
///
/// Claude uses a custom format (not the standard Gemini/Qwen pattern):
/// ```text
/// name: command args - ✓ Connected
/// name: command args - ✗ Failed to connect
/// ```
fn parse_claude_list_output(output: &str) -> Vec<DetectedServer> {
let cleaned = strip_ansi(output);
let mut servers = Vec::new();
for line in cleaned.lines() {
let trimmed = line.trim();
if let Some(server) = parse_claude_list_line(trimmed) {
servers.push(server);
}
}
servers
}
/// Parse a single line of Claude list output.
///
/// Pattern: `<name>: <command_or_url> - [✓|✗] <status>`
fn parse_claude_list_line(line: &str) -> Option<DetectedServer> {
// Split on " - " to separate "name: command" from status
let dash_pos = line.rfind(" - ")?;
let status = normalize_detection_status(&line[dash_pos + 3..]);
let name_cmd_part = &line[..dash_pos];
// Claude separates the name from command/URL with ": ". Names
// themselves may contain ":" (for example plugin-scoped MCP entries).
let separator_pos = name_cmd_part.find(": ")?;
let name = name_cmd_part[..separator_pos].trim();
if name.is_empty() {
return None;
}
let command_or_url = name_cmd_part[separator_pos + 2..].trim();
if command_or_url.is_empty() {
return None;
}
let normalized_command_or_url = command_or_url
.strip_suffix(" (HTTP)")
.or_else(|| command_or_url.strip_suffix(" (SSE)"))
.unwrap_or(command_or_url)
.trim();
// Heuristic: if it looks like a URL, treat as HTTP; otherwise stdio.
let transport =
if normalized_command_or_url.starts_with("http://") || normalized_command_or_url.starts_with("https://") {
// SSE heuristic: URL ending with /sse
if normalized_command_or_url.ends_with("/sse") {
McpServerTransport::Sse {
url: normalized_command_or_url.to_owned(),
headers: HashMap::new(),
}
} else {
McpServerTransport::Http {
url: normalized_command_or_url.to_owned(),
headers: HashMap::new(),
}
}
} else {
McpServerTransport::Stdio {
command: normalized_command_or_url.to_owned(),
args: Vec::new(),
env: HashMap::new(),
}
};
Some(DetectedServer {
name: name.to_owned(),
transport,
importable: status.eq_ignore_ascii_case("Connected") && !name.starts_with("plugin:"),
import_skip_reason: if name.starts_with("plugin:") {
Some("Plugin-managed MCP".to_owned())
} else if status.eq_ignore_ascii_case("Connected") {
None
} else {
Some(status)
},
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_claude_stdio_connected() {
let output = "my-server: npx -y @test/server - ✓ Connected";
let servers = parse_claude_list_output(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "my-server");
match &servers[0].transport {
McpServerTransport::Stdio { command, .. } => {
assert_eq!(command, "npx -y @test/server");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_claude_stdio_failed() {
let output = "broken-srv: node index.js - ✗ Failed to connect";
let servers = parse_claude_list_output(output);
assert_eq!(servers.len(), 1);
assert!(!servers[0].importable);
assert_eq!(servers[0].import_skip_reason.as_deref(), Some("Failed to connect"));
}
#[test]
fn parse_claude_http_server() {
let output = "remote: https://example.com/mcp - ✓ Connected";
let servers = parse_claude_list_output(output);
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Http { url, .. } => {
assert_eq!(url, "https://example.com/mcp");
}
_ => panic!("expected Http"),
}
}
#[test]
fn parse_claude_sse_heuristic() {
let output = "sse-srv: https://example.com/sse - ✓ Connected";
let servers = parse_claude_list_output(output);
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Sse { url, .. } => {
assert_eq!(url, "https://example.com/sse");
}
_ => panic!("expected Sse"),
}
}
#[test]
fn parse_claude_plugin_http_server_needing_auth() {
let output = "plugin:slack:slack: https://mcp.slack.com/mcp (HTTP) - ! Needs authentication";
let servers = parse_claude_list_output(output);
assert_eq!(servers.len(), 1);
assert!(!servers[0].importable);
assert_eq!(servers[0].import_skip_reason.as_deref(), Some("Plugin-managed MCP"));
}
#[test]
fn parse_claude_multiple_servers() {
let output = "\
my-mcp: npx -y @test/mcp - ✓ Connected
broken: node bad.js - ✗ Failed to connect
web: https://example.com/api - ✓ Connected";
let servers = parse_claude_list_output(output);
assert_eq!(servers.len(), 3);
assert_eq!(servers[0].name, "my-mcp");
assert_eq!(servers[1].name, "broken");
assert!(!servers[1].importable);
assert_eq!(servers[2].name, "web");
}
#[test]
fn parse_claude_with_ansi() {
let output = "\x1b[32m✓\x1b[0m test: npx srv - \x1b[32mConnected\x1b[0m";
let servers = parse_claude_list_output(output);
// After ANSI strip: "✓ test: npx srv - Connected"
// The ✓ is at the beginning of the line, not in the "name: cmd" pattern
// but it contains "Connected" so it should be parseable
assert_eq!(servers.len(), 1);
}
#[test]
fn parse_claude_no_servers() {
let output = "No MCP servers configured.\nTry `claude mcp add` to get started.";
let servers = parse_claude_list_output(output);
assert!(servers.is_empty());
}
#[test]
fn parse_claude_empty_output() {
let servers = parse_claude_list_output("");
assert!(servers.is_empty());
}
#[test]
fn build_stdio_json_without_env() {
let json = build_stdio_json("npx", &["-y".into(), "srv".into()], &HashMap::new());
assert_eq!(json["command"], "npx");
assert_eq!(json["args"], serde_json::json!(["-y", "srv"]));
assert!(json.get("env").is_none());
}
#[test]
fn build_stdio_json_with_env() {
let mut env = HashMap::new();
env.insert("KEY".into(), "VALUE".into());
let json = build_stdio_json("node", &[], &env);
assert_eq!(json["command"], "node");
assert_eq!(json["env"]["KEY"], "VALUE");
}
}
@@ -0,0 +1,409 @@
use std::collections::HashMap;
use std::time::Duration;
use nomifun_runtime::Builder as CmdBuilder;
use nomifun_runtime::resolve_command_path;
use crate::adapter::DetectedServer;
use crate::error::McpError;
use crate::types::McpServerTransport;
/// Timeout for detect/list operations (30 seconds).
pub const DETECT_TIMEOUT: Duration = Duration::from_secs(30);
/// Timeout for install/remove operations (5 seconds).
pub const MUTATE_TIMEOUT: Duration = Duration::from_secs(5);
/// Check whether a CLI binary is available on `$PATH`.
///
/// Uses `nomifun_runtime::resolve_command_path` so the lookup respects
/// the bundled-bun shim and Windows `PATHEXT` rules. Previously this
/// shelled out to `which`, which does not exist on Windows and made
/// every MCP adapter report "not installed" there.
pub async fn is_cli_installed(name: &str) -> Result<bool, McpError> {
Ok(resolve_command_path(name).is_some())
}
/// Run a CLI command with a timeout and clean environment variables.
///
/// Returns `(stdout, stderr)` on success. Returns an error if the command
/// fails to start, times out, or exits with a non-zero status.
pub async fn run_cli(program: &str, args: &[&str], timeout: Duration) -> Result<(String, String), McpError> {
let mut builder = CmdBuilder::clean_cli(program);
builder.args(args);
let result = tokio::time::timeout(timeout, builder.output()).await;
let output = match result {
Ok(Ok(output)) => output,
Ok(Err(e)) => {
return Err(McpError::AgentOperationFailed(format!(
"`{program}` failed to start: {e}"
)));
}
Err(_) => {
return Err(McpError::AgentOperationFailed(format!(
"`{program} {}` timed out after {}s",
args.join(" "),
timeout.as_secs()
)));
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
// Non-zero exit is not always fatal — callers inspect stdout/stderr.
Ok((stdout, stderr))
}
/// Run a CLI command and require zero exit status.
pub async fn run_cli_strict(program: &str, args: &[&str], timeout: Duration) -> Result<String, McpError> {
let mut builder = CmdBuilder::clean_cli(program);
builder.args(args);
let result = tokio::time::timeout(timeout, builder.output()).await;
let output = match result {
Ok(Ok(output)) => output,
Ok(Err(e)) => {
return Err(McpError::AgentOperationFailed(format!(
"`{program}` failed to start: {e}"
)));
}
Err(_) => {
return Err(McpError::AgentOperationFailed(format!(
"`{program} {}` timed out after {}s",
args.join(" "),
timeout.as_secs()
)));
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(McpError::AgentOperationFailed(format!(
"`{program} {}` exited with {}: {}",
args.join(" "),
output.status,
if stderr.is_empty() { &stdout } else { stderr.as_ref() }
)));
}
Ok(stdout)
}
/// Strip ANSI escape codes from CLI output.
pub fn strip_ansi(input: &str) -> String {
// Matches: ESC[ ... m (SGR sequences) and other CSI sequences.
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
// Consume the '[' and everything until a letter in @ ..~ range.
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
while let Some(&c) = chars.peek() {
chars.next();
if c.is_ascii_alphabetic() || c == '~' || c == '@' {
break;
}
}
}
} else {
result.push(ch);
}
}
result
}
/// Normalize a CLI-reported MCP status string by stripping leading symbols
/// such as `✓`, `✗`, `!`, bullets, and extra whitespace.
pub fn normalize_detection_status(status: &str) -> String {
status
.trim()
.trim_start_matches(|c: char| {
matches!(c, '✓' | '✗' | '!' | '•' | '-' | '*' | '✔' | '✘' | ':' | '[' | ']') || c.is_whitespace()
})
.trim()
.to_owned()
}
/// Parse the "standard" `mcp list` text output shared by Gemini and Qwen.
///
/// Pattern: `[checkmark] name: command (transport_type) - Status`
///
/// Each matching line produces a `DetectedServer`.
pub fn parse_standard_list_output(output: &str) -> Vec<DetectedServer> {
let cleaned = strip_ansi(output);
let mut servers = Vec::new();
for line in cleaned.lines() {
let trimmed = line.trim();
if let Some(server) = parse_standard_list_line(trimmed) {
servers.push(server);
}
}
servers
}
/// Parse a single line of standard list output.
///
/// Expected pattern:
/// `[✓|✗] <name>: <command_or_url> (<transport_type>) - <Status>`
fn parse_standard_list_line(line: &str) -> Option<DetectedServer> {
// Must start with a check/cross mark (Unicode or ASCII fallback)
let rest = if line.starts_with('\u{2713}') || line.starts_with('\u{2717}') {
&line[3..] // UTF-8 multibyte ✓/✗
} else if line.starts_with("") || line.starts_with("") {
// Already handled above via char check
return parse_standard_list_line_inner(line);
} else {
return None;
};
parse_standard_list_line_inner_rest(rest.trim())
}
fn parse_standard_list_line_inner(line: &str) -> Option<DetectedServer> {
// Skip the leading mark character
let rest = line.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');
parse_standard_list_line_inner_rest(rest)
}
fn parse_standard_list_line_inner_rest(rest: &str) -> Option<DetectedServer> {
// Find "name: command_or_url (type) - Status"
let status_sep = rest.rfind(" - ")?;
let status = normalize_detection_status(&rest[status_sep + 3..]);
let rest = &rest[..status_sep];
let colon_pos = rest.find(':')?;
let name = rest[..colon_pos].trim();
if name.is_empty() {
return None;
}
let after_colon = rest[colon_pos + 1..].trim();
// Find the transport type in parentheses
let paren_open = after_colon.rfind('(')?;
let paren_close = after_colon.rfind(')')?;
if paren_close <= paren_open {
return None;
}
let transport_type = after_colon[paren_open + 1..paren_close].trim();
let command_or_url = after_colon[..paren_open].trim();
let transport = match transport_type {
"stdio" => McpServerTransport::Stdio {
command: command_or_url.to_owned(),
args: Vec::new(),
env: HashMap::new(),
},
"sse" => McpServerTransport::Sse {
url: command_or_url.to_owned(),
headers: HashMap::new(),
},
"http" | "streamable_http" => McpServerTransport::Http {
url: command_or_url.to_owned(),
headers: HashMap::new(),
},
_ => return None,
};
Some(DetectedServer {
name: name.to_owned(),
transport,
importable: status.eq_ignore_ascii_case("connected"),
import_skip_reason: if status.eq_ignore_ascii_case("connected") {
None
} else {
Some(status)
},
})
}
/// Build `--env "KEY=VALUE"` argument pairs for CLI commands.
pub fn build_env_args(env: &HashMap<String, String>, flag: &str) -> Vec<String> {
env.iter()
.flat_map(|(k, v)| [flag.to_owned(), format!("{k}={v}")])
.collect()
}
/// Build `--header "Key: Value"` or `-H "Key: Value"` argument pairs.
pub fn build_header_args(headers: &HashMap<String, String>, flag: &str) -> Vec<String> {
headers
.iter()
.flat_map(|(k, v)| [flag.to_owned(), format!("{k}: {v}")])
.collect()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn is_cli_installed_finds_known_binary() {
// Both platforms ship a usable shell on PATH out of the box: `sh`
// on Unix, `cmd` on Windows. resolve_command_path must locate it.
#[cfg(unix)]
let probe = "sh";
#[cfg(windows)]
let probe = "cmd";
assert!(is_cli_installed(probe).await.unwrap(), "expected `{probe}` on PATH");
}
#[tokio::test]
async fn is_cli_installed_returns_false_for_missing_binary() {
let result = is_cli_installed("nomifun-definitely-not-a-real-binary-xyz")
.await
.unwrap();
assert!(!result);
}
#[test]
fn strip_ansi_removes_color_codes() {
let input = "\x1b[32m✓\x1b[0m my-server: npx (stdio) - \x1b[32mConnected\x1b[0m";
let cleaned = strip_ansi(input);
assert_eq!(cleaned, "✓ my-server: npx (stdio) - Connected");
}
#[test]
fn strip_ansi_preserves_plain_text() {
let input = "hello world";
assert_eq!(strip_ansi(input), "hello world");
}
#[test]
fn strip_ansi_handles_complex_sequences() {
let input = "\x1b[1;34mBold Blue\x1b[0m normal \x1b[38;5;196mRed\x1b[0m";
assert_eq!(strip_ansi(input), "Bold Blue normal Red");
}
#[test]
fn normalize_detection_status_strips_prefix_symbols() {
assert_eq!(normalize_detection_status("✓ Connected"), "Connected");
assert_eq!(normalize_detection_status("✗ Failed to connect"), "Failed to connect");
assert_eq!(
normalize_detection_status("! Needs authentication"),
"Needs authentication"
);
}
#[test]
fn parse_standard_list_stdio() {
let output = "✓ my-server: npx -y @test/server (stdio) - Connected";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "my-server");
match &servers[0].transport {
McpServerTransport::Stdio { command, .. } => {
assert_eq!(command, "npx -y @test/server");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_standard_list_http() {
let output = "✗ remote-srv: https://example.com/mcp (http) - Disconnected";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 1);
assert!(!servers[0].importable);
assert_eq!(servers[0].import_skip_reason.as_deref(), Some("Disconnected"));
}
#[test]
fn parse_standard_list_sse() {
let output = "✓ sse-srv: https://example.com/sse (sse) - Connected";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Sse { url, .. } => {
assert_eq!(url, "https://example.com/sse");
}
_ => panic!("expected Sse"),
}
}
#[test]
fn parse_standard_list_multiple_servers() {
let output = "\
Configured MCP servers:
✓ server-a: npx -y @a/srv (stdio) - Connected
✗ server-b: https://b.com/mcp (http) - Disconnected
✓ server-c: https://c.com/sse (sse) - Connected
Some footer text";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 3);
assert_eq!(servers[0].name, "server-a");
assert_eq!(servers[1].name, "server-b");
assert!(!servers[1].importable);
assert_eq!(servers[2].name, "server-c");
}
#[test]
fn parse_standard_list_with_ansi() {
let output = "\x1b[32m✓\x1b[0m my-mcp: npx -y @test/mcp (stdio) - \x1b[32mConnected\x1b[0m";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "my-mcp");
}
#[test]
fn parse_standard_list_empty_output() {
let servers = parse_standard_list_output("");
assert!(servers.is_empty());
}
#[test]
fn parse_standard_list_no_matching_lines() {
let output = "No MCP servers configured.\nTry `mcp add` to get started.";
let servers = parse_standard_list_output(output);
assert!(servers.is_empty());
}
#[test]
fn parse_standard_list_unknown_transport_skipped() {
let output = "✓ srv: cmd (websocket) - Connected";
let servers = parse_standard_list_output(output);
assert!(servers.is_empty());
}
#[test]
fn build_env_args_produces_pairs() {
let mut env = HashMap::new();
env.insert("K1".into(), "V1".into());
let args = build_env_args(&env, "--env");
assert_eq!(args.len(), 2);
assert_eq!(args[0], "--env");
assert_eq!(args[1], "K1=V1");
}
#[test]
fn build_env_args_empty() {
let env = HashMap::new();
let args = build_env_args(&env, "--env");
assert!(args.is_empty());
}
#[test]
fn build_header_args_produces_pairs() {
let mut headers = HashMap::new();
headers.insert("Authorization".into(), "Bearer tok".into());
let args = build_header_args(&headers, "--header");
assert_eq!(args.len(), 2);
assert_eq!(args[0], "--header");
assert_eq!(args[1], "Authorization: Bearer tok");
}
}
@@ -0,0 +1,398 @@
use std::collections::HashMap;
use nomifun_common::McpSource;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::McpServerTransport;
use super::cli_helpers::{MUTATE_TIMEOUT, is_cli_installed, run_cli};
const CLI_NAME: &str = "codebuddy";
/// Scopes to try when removing a server.
const REMOVE_SCOPES: &[&str] = &["user", "local", "project"];
/// MCP Agent adapter for CodeBuddy CLI.
///
/// # Detection
///
/// Detection reads `~/.codebuddy/mcp.json` directly (JSON) rather than
/// parsing CLI text output. The file format:
///
/// ```json
/// { "mcpServers": { "name": { "command": "...", "args": [...], ... } } }
/// ```
///
/// # CLI Commands
///
/// - **install (stdio)**: `codebuddy mcp add -s user <name> <cmd> [-- args...] [-e K=V...]`
/// - **install (http)**: `codebuddy mcp add-json -s user <name> <json>`
/// - **remove**: `codebuddy mcp remove -s <scope> <name>` (tries user → local → project)
pub struct CodeBuddyAdapter;
#[async_trait::async_trait]
impl McpAgentAdapter for CodeBuddyAdapter {
fn source(&self) -> McpSource {
McpSource::CodeBuddy
}
async fn is_installed(&self) -> Result<bool, McpError> {
is_cli_installed(CLI_NAME).await
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let config_path = config_file_path()?;
if !config_path.exists() {
return Ok(Vec::new());
}
let content = tokio::fs::read_to_string(&config_path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("read codebuddy config: {e}")))?;
parse_codebuddy_config(&content)
}
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
match transport {
McpServerTransport::Stdio { command, args, env } => {
let mut cli_args = vec![
"mcp".to_owned(),
"add".to_owned(),
"-s".to_owned(),
"user".to_owned(),
name.to_owned(),
command.clone(),
];
// Separate args from env with --
if !args.is_empty() {
cli_args.push("--".to_owned());
cli_args.extend(args.iter().cloned());
}
// Env vars as -e KEY=VALUE
for (k, v) in env {
cli_args.push("-e".to_owned());
cli_args.push(format!("{k}={v}"));
}
let arg_refs: Vec<&str> = cli_args.iter().map(|s| s.as_str()).collect();
run_cli(CLI_NAME, &arg_refs, MUTATE_TIMEOUT).await?;
}
McpServerTransport::Sse { url, headers } => {
let config = build_http_json("sse", url, headers);
install_via_add_json(name, &config).await?;
}
McpServerTransport::Http { url, headers } => {
let config = build_http_json("streamable-http", url, headers);
install_via_add_json(name, &config).await?;
}
}
Ok(())
}
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
for scope in REMOVE_SCOPES {
let (stdout, _stderr) = run_cli(CLI_NAME, &["mcp", "remove", "-s", scope, name], MUTATE_TIMEOUT).await?;
let lower = stdout.to_lowercase();
if lower.contains("removed") || lower.contains("not found") {
return Ok(());
}
}
Ok(())
}
}
/// Install via `codebuddy mcp add-json -s user <name> <json>`.
async fn install_via_add_json(name: &str, config: &serde_json::Value) -> Result<(), McpError> {
let config_str = serde_json::to_string(config).map_err(|e| McpError::AgentOperationFailed(e.to_string()))?;
run_cli(
CLI_NAME,
&["mcp", "add-json", "-s", "user", name, &config_str],
MUTATE_TIMEOUT,
)
.await?;
Ok(())
}
/// Build JSON config for HTTP-like servers.
fn build_http_json(transport_type: &str, url: &str, headers: &HashMap<String, String>) -> serde_json::Value {
let mut config = serde_json::json!({
"url": url,
"transportType": transport_type,
});
if !headers.is_empty() {
config["headers"] = serde_json::json!(headers);
}
config
}
/// Get the CodeBuddy config file path: `~/.codebuddy/mcp.json`.
fn config_file_path() -> Result<std::path::PathBuf, McpError> {
let home =
dirs::home_dir().ok_or_else(|| McpError::AgentOperationFailed("cannot determine home directory".into()))?;
Ok(home.join(".codebuddy").join("mcp.json"))
}
/// Parse the CodeBuddy `mcp.json` config file.
///
/// Format:
/// ```json
/// {
/// "mcpServers": {
/// "name": {
/// "command": "...",
/// "args": [...],
/// "env": { ... },
/// "disabled": false,
/// "url": "...",
/// "transportType": "streamable-http",
/// "headers": { ... }
/// }
/// }
/// }
/// ```
fn parse_codebuddy_config(content: &str) -> Result<Vec<DetectedServer>, McpError> {
let config: serde_json::Value = serde_json::from_str(content).map_err(McpError::from)?;
let servers_obj = match config.get("mcpServers").and_then(|v| v.as_object()) {
Some(obj) => obj,
None => return Ok(Vec::new()),
};
let mut servers = Vec::new();
for (name, entry) in servers_obj {
if let Some(transport) = parse_codebuddy_entry(entry) {
let disabled = entry.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false);
servers.push(DetectedServer {
name: name.clone(),
transport,
importable: !disabled,
import_skip_reason: if disabled { Some("Disabled".into()) } else { None },
});
}
}
Ok(servers)
}
/// Parse a single CodeBuddy config entry into a transport.
fn parse_codebuddy_entry(entry: &serde_json::Value) -> Option<McpServerTransport> {
let has_command = entry.get("command").and_then(|v| v.as_str()).is_some();
let has_url = entry.get("url").and_then(|v| v.as_str()).is_some();
if has_command {
let command = entry["command"].as_str()?.to_owned();
let args = entry
.get("args")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let env = parse_string_map(entry.get("env"));
Some(McpServerTransport::Stdio { command, args, env })
} else if has_url {
let url = entry["url"].as_str()?.to_owned();
let headers = parse_string_map(entry.get("headers"));
let transport_type = entry
.get("transportType")
.and_then(|v| v.as_str())
.unwrap_or("streamable-http");
// Normalize transport type
match transport_type {
"sse" => Some(McpServerTransport::Sse { url, headers }),
_ => Some(McpServerTransport::Http { url, headers }),
}
} else {
None
}
}
/// Parse a JSON object as `HashMap<String, String>`.
fn parse_string_map(value: Option<&serde_json::Value>) -> HashMap<String, String> {
value
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_is_codebuddy() {
assert_eq!(CodeBuddyAdapter.source(), McpSource::CodeBuddy);
}
#[test]
fn parse_config_stdio_server() {
let config = r#"{
"mcpServers": {
"test-server": {
"command": "npx",
"args": ["-y", "@test/server"],
"env": { "NODE_ENV": "production" }
}
}
}"#;
let servers = parse_codebuddy_config(config).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "test-server");
match &servers[0].transport {
McpServerTransport::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y", "@test/server"]);
assert_eq!(env.get("NODE_ENV").unwrap(), "production");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_config_http_server() {
let config = r#"{
"mcpServers": {
"remote": {
"url": "https://example.com/mcp",
"transportType": "streamable-http",
"headers": { "Authorization": "Bearer tok" }
}
}
}"#;
let servers = parse_codebuddy_config(config).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "remote");
match &servers[0].transport {
McpServerTransport::Http { url, headers } => {
assert_eq!(url, "https://example.com/mcp");
assert_eq!(headers.get("Authorization").unwrap(), "Bearer tok");
}
_ => panic!("expected Http"),
}
}
#[test]
fn parse_config_sse_server() {
let config = r#"{
"mcpServers": {
"sse-srv": {
"url": "https://example.com/sse",
"transportType": "sse"
}
}
}"#;
let servers = parse_codebuddy_config(config).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Sse { url, .. } => {
assert_eq!(url, "https://example.com/sse");
}
_ => panic!("expected Sse"),
}
}
#[test]
fn parse_config_skips_disabled() {
let config = r#"{
"mcpServers": {
"active": { "command": "npx", "args": [] },
"disabled": { "command": "npx", "args": [], "disabled": true }
}
}"#;
let servers = parse_codebuddy_config(config).unwrap();
assert_eq!(servers.len(), 2);
assert_eq!(servers[0].name, "active");
assert!(servers[0].importable);
assert_eq!(servers[1].name, "disabled");
assert!(!servers[1].importable);
assert_eq!(servers[1].import_skip_reason.as_deref(), Some("Disabled"));
}
#[test]
fn parse_config_empty_mcp_servers() {
let config = r#"{ "mcpServers": {} }"#;
let servers = parse_codebuddy_config(config).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_config_no_mcp_servers_key() {
let config = r#"{ "otherKey": 42 }"#;
let servers = parse_codebuddy_config(config).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_config_multiple_servers() {
let config = r#"{
"mcpServers": {
"stdio-srv": { "command": "node", "args": ["index.js"] },
"http-srv": { "url": "https://a.com/mcp" },
"sse-srv": { "url": "https://b.com/sse", "transportType": "sse" }
}
}"#;
let servers = parse_codebuddy_config(config).unwrap();
assert_eq!(servers.len(), 3);
}
#[test]
fn parse_config_url_without_transport_type_defaults_to_http() {
let config = r#"{
"mcpServers": {
"no-type": { "url": "https://example.com/api" }
}
}"#;
let servers = parse_codebuddy_config(config).unwrap();
assert_eq!(servers.len(), 1);
assert!(matches!(servers[0].transport, McpServerTransport::Http { .. }));
}
#[test]
fn build_http_json_without_headers() {
let json = build_http_json("streamable-http", "https://example.com", &HashMap::new());
assert_eq!(json["url"], "https://example.com");
assert_eq!(json["transportType"], "streamable-http");
assert!(json.get("headers").is_none());
}
#[test]
fn build_http_json_with_headers() {
let mut headers = HashMap::new();
headers.insert("Authorization".into(), "Bearer tok".into());
let json = build_http_json("sse", "https://example.com/sse", &headers);
assert_eq!(json["transportType"], "sse");
assert_eq!(json["headers"]["Authorization"], "Bearer tok");
}
#[test]
fn trait_is_object_safe() {
let adapter: Box<dyn McpAgentAdapter> = Box::new(CodeBuddyAdapter);
assert_eq!(adapter.source(), McpSource::CodeBuddy);
}
}
@@ -0,0 +1,486 @@
use std::collections::HashMap;
use nomifun_common::McpSource;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::McpServerTransport;
use super::cli_helpers::{DETECT_TIMEOUT, MUTATE_TIMEOUT, is_cli_installed, run_cli_strict};
const CLI_NAME: &str = "codex";
/// MCP Agent adapter for Codex CLI.
///
/// # CLI Commands
///
/// - **detect**: `codex mcp list --json` (JSON output)
/// - **install (stdio)**: `codex mcp add <name> [--env K=V]... -- <cmd> [args...]`
/// - **install (http)**: `codex mcp add <name> --url <url>`
/// - **remove**: `codex mcp remove <name>` (no scope parameter)
///
/// Codex outputs structured JSON for list, unlike the text-based agents.
pub struct CodexAdapter;
#[async_trait::async_trait]
impl McpAgentAdapter for CodexAdapter {
fn source(&self) -> McpSource {
McpSource::Codex
}
async fn is_installed(&self) -> Result<bool, McpError> {
is_cli_installed(CLI_NAME).await
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let stdout = run_cli_strict(CLI_NAME, &["mcp", "list", "--json"], DETECT_TIMEOUT).await?;
parse_codex_list_json(&stdout)
}
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
match transport {
McpServerTransport::Stdio { command, args, env } => {
let mut cli_args = vec!["mcp".to_owned(), "add".to_owned(), name.to_owned()];
// Env vars come before --
for (k, v) in env {
cli_args.push("--env".to_owned());
cli_args.push(format!("{k}={v}"));
}
// Command and args come after --
cli_args.push("--".to_owned());
cli_args.push(command.clone());
cli_args.extend(args.iter().cloned());
let arg_refs: Vec<&str> = cli_args.iter().map(|s| s.as_str()).collect();
run_cli_strict(CLI_NAME, &arg_refs, MUTATE_TIMEOUT).await?;
}
McpServerTransport::Http { url, .. } | McpServerTransport::Sse { url, .. } => {
// Codex only supports --url for HTTP, no headers via CLI
run_cli_strict(CLI_NAME, &["mcp", "add", name, "--url", url], MUTATE_TIMEOUT).await?;
}
}
Ok(())
}
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
// Codex has no scope parameter; remove is simple.
let (stdout, _stderr) = super::cli_helpers::run_cli(CLI_NAME, &["mcp", "remove", name], MUTATE_TIMEOUT).await?;
// Idempotent: treat "not found" as success.
let lower = stdout.to_lowercase();
if lower.contains("not found") || lower.contains("removed") || lower.is_empty() {
return Ok(());
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// JSON parsing
// ---------------------------------------------------------------------------
/// Parse the JSON output of `codex mcp list --json`.
///
/// Expected format: array of entries with transport details.
///
/// ```json
/// [
/// {
/// "name": "...",
/// "enabled": true,
/// "transport": {
/// "type": "stdio",
/// "command": "...",
/// "args": [...],
/// "env": { ... },
/// "env_vars": [{ "name": "...", "value": "..." }]
/// }
/// }
/// ]
/// ```
fn parse_codex_list_json(json_str: &str) -> Result<Vec<DetectedServer>, McpError> {
let trimmed = json_str.trim();
if trimmed.is_empty() || trimmed == "[]" {
return Ok(Vec::new());
}
let entries: Vec<serde_json::Value> = serde_json::from_str(trimmed).map_err(McpError::from)?;
let mut servers = Vec::new();
for entry in &entries {
if let Some(server) = parse_codex_entry(entry) {
servers.push(server);
}
}
Ok(servers)
}
/// Parse a single Codex list entry.
fn parse_codex_entry(entry: &serde_json::Value) -> Option<DetectedServer> {
let name = entry.get("name")?.as_str()?.to_owned();
let enabled = entry.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let transport_obj = entry.get("transport")?;
let transport_type = transport_obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
let transport = match transport_type {
"stdio" => {
let command = transport_obj
.get("command")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
let args = transport_obj
.get("args")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
// Codex supports both `env` (object) and `env_vars` (array of {name, value})
let env = parse_codex_env(transport_obj);
McpServerTransport::Stdio { command, args, env }
}
"http" | "streamable_http" => {
let url = transport_obj
.get("url")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
McpServerTransport::Http {
url,
headers: HashMap::new(),
}
}
"sse" => {
let url = transport_obj
.get("url")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
McpServerTransport::Sse {
url,
headers: HashMap::new(),
}
}
_ => return None,
};
Some(DetectedServer {
name,
transport,
importable: enabled,
import_skip_reason: if enabled { None } else { Some("Disabled".into()) },
})
}
/// Parse environment variables from Codex transport.
///
/// Handles both formats:
/// - `"env": { "KEY": "VALUE" }` (object)
/// - `"env_vars": [{ "name": "KEY", "value": "VALUE" }]` (array)
fn parse_codex_env(transport: &serde_json::Value) -> HashMap<String, String> {
// Try object format first
if let Some(obj) = transport.get("env").and_then(|v| v.as_object()) {
return obj
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect();
}
// Try array format
if let Some(arr) = transport.get("env_vars").and_then(|v| v.as_array()) {
return arr
.iter()
.filter_map(|entry| {
let name = entry.get("name")?.as_str()?;
let value = entry.get("value")?.as_str()?;
Some((name.to_owned(), value.to_owned()))
})
.collect();
}
HashMap::new()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_is_codex() {
assert_eq!(CodexAdapter.source(), McpSource::Codex);
}
#[test]
fn parse_empty_json() {
let servers = parse_codex_list_json("[]").unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_empty_string() {
let servers = parse_codex_list_json("").unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_stdio_server_with_env_object() {
let json = r#"[
{
"name": "test-mcp",
"enabled": true,
"transport": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@test/server"],
"env": { "NODE_ENV": "production" }
}
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "test-mcp");
match &servers[0].transport {
McpServerTransport::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y", "@test/server"]);
assert_eq!(env.get("NODE_ENV").unwrap(), "production");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_stdio_server_with_env_vars_array() {
let json = r#"[
{
"name": "test-mcp",
"enabled": true,
"transport": {
"type": "stdio",
"command": "node",
"args": ["index.js"],
"env_vars": [
{ "name": "KEY1", "value": "VAL1" },
{ "name": "KEY2", "value": "VAL2" }
]
}
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Stdio { env, .. } => {
assert_eq!(env.get("KEY1").unwrap(), "VAL1");
assert_eq!(env.get("KEY2").unwrap(), "VAL2");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_http_server() {
let json = r#"[
{
"name": "remote",
"enabled": true,
"transport": {
"type": "http",
"url": "https://example.com/mcp"
}
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Http { url, .. } => {
assert_eq!(url, "https://example.com/mcp");
}
_ => panic!("expected Http"),
}
}
#[test]
fn parse_streamable_http_becomes_http() {
let json = r#"[
{
"name": "streamable",
"enabled": true,
"transport": {
"type": "streamable_http",
"url": "https://example.com/api"
}
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
assert!(matches!(servers[0].transport, McpServerTransport::Http { .. }));
}
#[test]
fn parse_sse_server() {
let json = r#"[
{
"name": "sse-srv",
"enabled": true,
"transport": {
"type": "sse",
"url": "https://example.com/sse"
}
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Sse { url, .. } => {
assert_eq!(url, "https://example.com/sse");
}
_ => panic!("expected Sse"),
}
}
#[test]
fn parse_multiple_servers() {
let json = r#"[
{
"name": "stdio-srv",
"enabled": true,
"transport": { "type": "stdio", "command": "node", "args": [] }
},
{
"name": "http-srv",
"enabled": true,
"transport": { "type": "http", "url": "https://a.com/mcp" }
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 2);
assert_eq!(servers[0].name, "stdio-srv");
assert_eq!(servers[1].name, "http-srv");
}
#[test]
fn parse_disabled_server_skipped_from_import_only() {
let json = r#"[
{
"name": "disabled-srv",
"enabled": false,
"transport": { "type": "stdio", "command": "node", "args": [] }
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
assert!(!servers[0].importable);
assert_eq!(servers[0].import_skip_reason.as_deref(), Some("Disabled"));
}
#[test]
fn parse_unknown_transport_skipped() {
let json = r#"[
{
"name": "unknown",
"enabled": true,
"transport": { "type": "websocket", "url": "ws://localhost" }
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_missing_name_skipped() {
let json = r#"[
{
"enabled": true,
"transport": { "type": "stdio", "command": "node" }
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_default_type_is_stdio() {
let json = r#"[
{
"name": "no-type",
"enabled": true,
"transport": { "command": "node", "args": ["srv.js"] }
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
assert!(matches!(servers[0].transport, McpServerTransport::Stdio { .. }));
}
#[test]
fn parse_env_object_takes_precedence() {
let json = r#"[
{
"name": "both-env",
"enabled": true,
"transport": {
"type": "stdio",
"command": "node",
"env": { "FROM_OBJ": "yes" },
"env_vars": [{ "name": "FROM_ARR", "value": "yes" }]
}
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
match &servers[0].transport {
McpServerTransport::Stdio { env, .. } => {
// Object format takes precedence
assert_eq!(env.get("FROM_OBJ").unwrap(), "yes");
assert!(env.get("FROM_ARR").is_none());
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_disabled_server_skipped() {
let json = r#"[
{
"name": "disabled-mcp",
"enabled": false,
"transport": { "type": "stdio", "command": "node", "args": ["srv.js"] }
}
]"#;
let servers = parse_codex_list_json(json).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "disabled-mcp");
assert!(!servers[0].importable);
assert_eq!(servers[0].import_skip_reason.as_deref(), Some("Disabled"));
}
#[test]
fn trait_is_object_safe() {
let adapter: Box<dyn McpAgentAdapter> = Box::new(CodexAdapter);
assert_eq!(adapter.source(), McpSource::Codex);
}
}
@@ -0,0 +1,145 @@
use nomifun_common::McpSource;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::McpServerTransport;
use super::cli_helpers::{DETECT_TIMEOUT, MUTATE_TIMEOUT, is_cli_installed, parse_standard_list_output, run_cli};
const CLI_NAME: &str = "gemini";
/// Scopes tried when removing (user first, then project).
const REMOVE_SCOPES: &[&str] = &["user", "project"];
/// MCP Agent adapter for Gemini CLI.
///
/// # CLI Commands
///
/// - **detect**: `gemini mcp list`
/// - **install (stdio)**: `gemini mcp add <name> <command> [args...] -s user`
/// - **install (http/sse)**: `gemini mcp add <name> <url> --transport <type> -s user`
/// - **remove**: `gemini mcp remove <name> -s user` (falls back to `-s project`)
pub struct GeminiAdapter;
#[async_trait::async_trait]
impl McpAgentAdapter for GeminiAdapter {
fn source(&self) -> McpSource {
McpSource::Gemini
}
async fn is_installed(&self) -> Result<bool, McpError> {
is_cli_installed(CLI_NAME).await
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let (stdout, _stderr) = run_cli(CLI_NAME, &["mcp", "list"], DETECT_TIMEOUT).await?;
Ok(parse_standard_list_output(&stdout))
}
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
match transport {
McpServerTransport::Stdio { command, args, .. } => {
let mut cli_args = vec!["mcp".to_owned(), "add".to_owned(), name.to_owned(), command.clone()];
cli_args.extend(args.iter().cloned());
cli_args.push("-s".to_owned());
cli_args.push("user".to_owned());
let arg_refs: Vec<&str> = cli_args.iter().map(|s| s.as_str()).collect();
run_cli(CLI_NAME, &arg_refs, MUTATE_TIMEOUT).await?;
}
McpServerTransport::Sse { url, .. } => {
run_cli(
CLI_NAME,
&["mcp", "add", name, url, "--transport", "sse", "-s", "user"],
MUTATE_TIMEOUT,
)
.await?;
}
McpServerTransport::Http { url, .. } => {
run_cli(
CLI_NAME,
&["mcp", "add", name, url, "--transport", "http", "-s", "user"],
MUTATE_TIMEOUT,
)
.await?;
}
}
Ok(())
}
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
for scope in REMOVE_SCOPES {
let (stdout, _stderr) = run_cli(CLI_NAME, &["mcp", "remove", name, "-s", scope], MUTATE_TIMEOUT).await?;
let lower = stdout.to_lowercase();
if lower.contains("removed") || lower.contains("not found") {
return Ok(());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_is_gemini() {
assert_eq!(GeminiAdapter.source(), McpSource::Gemini);
}
#[test]
fn parse_gemini_list_output() {
let output = "\
Configured MCP servers:
✓ my-server: npx -y @test/server (stdio) - Connected
✗ broken: node bad.js (stdio) - Disconnected
✓ remote: https://example.com/mcp (http) - Connected";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 3);
assert_eq!(servers[0].name, "my-server");
assert_eq!(servers[1].name, "broken");
assert_eq!(servers[2].name, "remote");
match &servers[0].transport {
McpServerTransport::Stdio { command, .. } => {
assert_eq!(command, "npx -y @test/server");
}
_ => panic!("expected Stdio"),
}
match &servers[2].transport {
McpServerTransport::Http { url, .. } => {
assert_eq!(url, "https://example.com/mcp");
}
_ => panic!("expected Http"),
}
}
#[test]
fn parse_gemini_empty() {
let servers = parse_standard_list_output("No MCP servers configured.");
assert!(servers.is_empty());
}
#[test]
fn trait_is_object_safe() {
let adapter: Box<dyn McpAgentAdapter> = Box::new(GeminiAdapter);
assert_eq!(adapter.source(), McpSource::Gemini);
}
}
@@ -0,0 +1,18 @@
mod claude;
mod cli_helpers;
mod codebuddy;
mod codex;
mod gemini;
mod nomi;
mod nomifun;
mod opencode;
mod qwen;
pub use claude::ClaudeAdapter;
pub use codebuddy::CodeBuddyAdapter;
pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter;
pub use nomi::NomiAdapter;
pub use nomifun::NomifunAdapter;
pub use opencode::OpencodeAdapter;
pub use qwen::QwenAdapter;
@@ -0,0 +1,603 @@
use std::collections::HashMap;
use nomifun_common::McpSource;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::McpServerTransport;
use super::cli_helpers::{DETECT_TIMEOUT, is_cli_installed, run_cli_strict};
const CLI_NAME: &str = "nomi";
/// MCP Agent adapter for Nomi.
///
/// Nomi stores MCP configuration in a TOML config file. The config path
/// is obtained via `nomi --config-path`.
///
/// # Config Format (TOML)
///
/// ```toml
/// [mcp.servers.server-name]
/// transport = "stdio"
/// command = "npx"
/// args = ["-y", "@test/server"]
///
/// [mcp.servers.server-name.env]
/// KEY = "VALUE"
///
/// [mcp.servers.remote-server]
/// transport = "http"
/// url = "https://example.com/mcp"
///
/// [mcp.servers.remote-server.headers]
/// Authorization = "Bearer xxx"
/// ```
pub struct NomiAdapter;
#[async_trait::async_trait]
impl McpAgentAdapter for NomiAdapter {
fn source(&self) -> McpSource {
McpSource::Nomi
}
async fn is_installed(&self) -> Result<bool, McpError> {
is_cli_installed(CLI_NAME).await
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let config_path = get_config_path().await?;
let path = std::path::Path::new(&config_path);
if !path.exists() {
return Ok(Vec::new());
}
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to read {config_path}: {e}")))?;
parse_toml_servers(&content)
}
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let config_path = get_config_path().await?;
let path = std::path::Path::new(&config_path);
let mut doc = if path.exists() {
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to read {config_path}: {e}")))?;
content
.parse::<toml::Value>()
.map_err(|e| McpError::AgentOperationFailed(format!("failed to parse TOML: {e}")))?
} else {
// Ensure parent directory exists
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to create dir: {e}")))?;
}
toml::Value::Table(toml::map::Map::new())
};
// Ensure mcp.servers exists
let root = doc
.as_table_mut()
.ok_or_else(|| McpError::AgentOperationFailed("TOML root is not a table".into()))?;
let mcp = root
.entry("mcp")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
let mcp_table = mcp
.as_table_mut()
.ok_or_else(|| McpError::AgentOperationFailed("mcp is not a table".into()))?;
let servers = mcp_table
.entry("servers")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
let servers_table = servers
.as_table_mut()
.ok_or_else(|| McpError::AgentOperationFailed("mcp.servers is not a table".into()))?;
servers_table.insert(name.to_owned(), transport_to_toml(transport));
let output = toml::to_string_pretty(&doc)
.map_err(|e| McpError::AgentOperationFailed(format!("failed to serialize TOML: {e}")))?;
tokio::fs::write(path, output)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to write {config_path}: {e}")))?;
Ok(())
}
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let config_path = get_config_path().await?;
let path = std::path::Path::new(&config_path);
if !path.exists() {
return Ok(());
}
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to read {config_path}: {e}")))?;
let mut doc: toml::Value = content
.parse()
.map_err(|e| McpError::AgentOperationFailed(format!("failed to parse TOML: {e}")))?;
let removed = doc
.as_table_mut()
.and_then(|root| root.get_mut("mcp"))
.and_then(|mcp| mcp.as_table_mut())
.and_then(|mcp| mcp.get_mut("servers"))
.and_then(|servers| servers.as_table_mut())
.map(|servers| servers.remove(name).is_some())
.unwrap_or(false);
if !removed {
// Idempotent: not found is fine
return Ok(());
}
let output = toml::to_string_pretty(&doc)
.map_err(|e| McpError::AgentOperationFailed(format!("failed to serialize TOML: {e}")))?;
tokio::fs::write(path, output)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to write {config_path}: {e}")))?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Run `nomi --config-path` to get the TOML config file path.
async fn get_config_path() -> Result<String, McpError> {
let stdout = run_cli_strict(CLI_NAME, &["--config-path"], DETECT_TIMEOUT).await?;
let path = stdout.trim().to_owned();
if path.is_empty() {
return Err(McpError::AgentOperationFailed(
"nomi --config-path returned empty output".into(),
));
}
Ok(path)
}
/// Parse MCP servers from TOML config content.
///
/// Expects `[mcp.servers.<name>]` tables.
fn parse_toml_servers(content: &str) -> Result<Vec<DetectedServer>, McpError> {
let doc: toml::Value = content
.parse()
.map_err(|e| McpError::AgentOperationFailed(format!("failed to parse TOML: {e}")))?;
let servers_table = match doc
.get("mcp")
.and_then(|mcp| mcp.get("servers"))
.and_then(|s| s.as_table())
{
Some(t) => t,
None => return Ok(Vec::new()),
};
let mut servers = Vec::new();
for (name, config) in servers_table {
if let Some(server) = parse_toml_server_entry(name, config) {
servers.push(server);
}
}
Ok(servers)
}
/// Parse a single server entry from the TOML `[mcp.servers.*]` table.
fn parse_toml_server_entry(name: &str, config: &toml::Value) -> Option<DetectedServer> {
let table = config.as_table()?;
let transport_type = table
.get("transport")
.or_else(|| table.get("type"))
.and_then(|v| v.as_str())
.unwrap_or("stdio");
let transport = match transport_type {
"stdio" => {
let command = table.get("command")?.as_str()?.to_owned();
let args = table
.get("args")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let env = table
.get("env")
.and_then(|v| v.as_table())
.map(|t| {
t.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default();
McpServerTransport::Stdio { command, args, env }
}
"sse" => {
let url = table.get("url")?.as_str()?.to_owned();
let headers = parse_toml_headers(table);
McpServerTransport::Sse { url, headers }
}
"http" | "streamable_http" => {
let url = table.get("url")?.as_str()?.to_owned();
let headers = parse_toml_headers(table);
McpServerTransport::Http { url, headers }
}
_ => return None,
};
Some(DetectedServer {
name: name.to_owned(),
transport,
importable: true,
import_skip_reason: None,
})
}
/// Extract headers from a TOML table's `headers` field.
fn parse_toml_headers(table: &toml::map::Map<String, toml::Value>) -> HashMap<String, String> {
table
.get("headers")
.and_then(|v| v.as_table())
.map(|t| {
t.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default()
}
/// Convert a `McpServerTransport` to a TOML value for writing to config.
fn transport_to_toml(transport: &McpServerTransport) -> toml::Value {
let mut table = toml::map::Map::new();
match transport {
McpServerTransport::Stdio { command, args, env } => {
table.insert("transport".into(), toml::Value::String("stdio".into()));
table.insert("command".into(), toml::Value::String(command.clone()));
if !args.is_empty() {
table.insert(
"args".into(),
toml::Value::Array(args.iter().map(|a| toml::Value::String(a.clone())).collect()),
);
}
if !env.is_empty() {
let env_table: toml::map::Map<String, toml::Value> = env
.iter()
.map(|(k, v)| (k.clone(), toml::Value::String(v.clone())))
.collect();
table.insert("env".into(), toml::Value::Table(env_table));
}
}
McpServerTransport::Sse { url, headers } => {
table.insert("transport".into(), toml::Value::String("sse".into()));
table.insert("url".into(), toml::Value::String(url.clone()));
insert_toml_headers(&mut table, headers);
}
McpServerTransport::Http { url, headers } => {
table.insert("transport".into(), toml::Value::String("http".into()));
table.insert("url".into(), toml::Value::String(url.clone()));
insert_toml_headers(&mut table, headers);
}
}
toml::Value::Table(table)
}
/// Insert headers into a TOML table if non-empty.
fn insert_toml_headers(table: &mut toml::map::Map<String, toml::Value>, headers: &HashMap<String, String>) {
if !headers.is_empty() {
let headers_table: toml::map::Map<String, toml::Value> = headers
.iter()
.map(|(k, v)| (k.clone(), toml::Value::String(v.clone())))
.collect();
table.insert("headers".into(), toml::Value::Table(headers_table));
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_is_nomi() {
assert_eq!(NomiAdapter.source(), McpSource::Nomi);
}
// -- parse_toml_servers ---------------------------------------------------
#[test]
fn parse_empty_config() {
let servers = parse_toml_servers("").unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_no_mcp_section() {
let toml = r#"
[some_other]
key = "value"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_empty_servers() {
let toml = r#"
[mcp]
[mcp.servers]
"#;
let servers = parse_toml_servers(toml).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_stdio_server() {
let toml = r#"
[mcp.servers.test-mcp]
type = "stdio"
command = "npx"
args = ["-y", "@test/server"]
[mcp.servers.test-mcp.env]
KEY = "VALUE"
NODE_ENV = "production"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "test-mcp");
match &servers[0].transport {
McpServerTransport::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y", "@test/server"]);
assert_eq!(env.get("KEY").unwrap(), "VALUE");
assert_eq!(env.get("NODE_ENV").unwrap(), "production");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_stdio_server_with_transport_key() {
let toml = r#"
[mcp.servers.test-mcp]
transport = "stdio"
command = "npx"
args = ["-y", "@test/server"]
[mcp.servers.test-mcp.env]
KEY = "VALUE"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y", "@test/server"]);
assert_eq!(env.get("KEY").unwrap(), "VALUE");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_http_server() {
let toml = r#"
[mcp.servers.remote]
type = "http"
url = "https://example.com/mcp"
[mcp.servers.remote.headers]
Authorization = "Bearer tok"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "remote");
match &servers[0].transport {
McpServerTransport::Http { url, headers } => {
assert_eq!(url, "https://example.com/mcp");
assert_eq!(headers.get("Authorization").unwrap(), "Bearer tok");
}
_ => panic!("expected Http"),
}
}
#[test]
fn parse_sse_server() {
let toml = r#"
[mcp.servers.sse-srv]
type = "sse"
url = "https://example.com/sse"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Sse { url, .. } => {
assert_eq!(url, "https://example.com/sse");
}
_ => panic!("expected Sse"),
}
}
#[test]
fn parse_streamable_http_becomes_http() {
let toml = r#"
[mcp.servers.sh]
type = "streamable_http"
url = "https://example.com/api"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert_eq!(servers.len(), 1);
assert!(matches!(servers[0].transport, McpServerTransport::Http { .. }));
}
#[test]
fn parse_unknown_transport_skipped() {
let toml = r#"
[mcp.servers.ws]
type = "websocket"
url = "ws://localhost"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_stdio_missing_command_skipped() {
let toml = r#"
[mcp.servers.bad]
type = "stdio"
args = []
"#;
let servers = parse_toml_servers(toml).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_multiple_servers() {
let toml = r#"
[mcp.servers.srv-a]
type = "stdio"
command = "node"
[mcp.servers.srv-b]
type = "http"
url = "https://b.com/mcp"
[mcp.servers.srv-c]
type = "sse"
url = "https://c.com/sse"
"#;
let servers = parse_toml_servers(toml).unwrap();
assert_eq!(servers.len(), 3);
}
#[test]
fn parse_default_type_is_stdio() {
let toml = r#"
[mcp.servers.no-type]
command = "node"
args = ["srv.js"]
"#;
let servers = parse_toml_servers(toml).unwrap();
assert_eq!(servers.len(), 1);
assert!(matches!(servers[0].transport, McpServerTransport::Stdio { .. }));
}
// -- transport_to_toml roundtrip ------------------------------------------
#[test]
fn stdio_to_toml_roundtrip() {
let transport = McpServerTransport::Stdio {
command: "npx".into(),
args: vec!["-y".into(), "@test/srv".into()],
env: HashMap::from([("K".into(), "V".into())]),
};
let toml_val = transport_to_toml(&transport);
let table = toml_val.as_table().unwrap();
assert_eq!(table.get("transport").unwrap().as_str().unwrap(), "stdio");
assert!(table.get("type").is_none());
assert_eq!(
table
.get("env")
.and_then(|v| v.as_table())
.and_then(|env| env.get("K"))
.and_then(|v| v.as_str())
.unwrap(),
"V"
);
let server = parse_toml_server_entry("test", &toml_val).unwrap();
assert_eq!(server.transport, transport);
}
#[test]
fn http_to_toml_roundtrip() {
let transport = McpServerTransport::Http {
url: "https://example.com/mcp".into(),
headers: HashMap::from([("Authorization".into(), "Bearer tok".into())]),
};
let toml_val = transport_to_toml(&transport);
let server = parse_toml_server_entry("test", &toml_val).unwrap();
assert_eq!(server.transport, transport);
}
#[test]
fn sse_to_toml_roundtrip() {
let transport = McpServerTransport::Sse {
url: "https://example.com/sse".into(),
headers: HashMap::new(),
};
let toml_val = transport_to_toml(&transport);
let server = parse_toml_server_entry("test", &toml_val).unwrap();
assert_eq!(server.transport, transport);
}
#[test]
fn stdio_to_toml_omits_empty_args_and_env() {
let transport = McpServerTransport::Stdio {
command: "node".into(),
args: vec![],
env: HashMap::new(),
};
let toml_val = transport_to_toml(&transport);
let table = toml_val.as_table().unwrap();
assert!(table.get("args").is_none());
assert!(table.get("env").is_none());
}
#[test]
fn http_to_toml_omits_empty_headers() {
let transport = McpServerTransport::Http {
url: "https://x.com".into(),
headers: HashMap::new(),
};
let toml_val = transport_to_toml(&transport);
let table = toml_val.as_table().unwrap();
assert!(table.get("headers").is_none());
}
// -- invalid TOML ---------------------------------------------------------
#[test]
fn parse_invalid_toml_fails() {
let result = parse_toml_servers("not valid toml [[[");
assert!(result.is_err());
}
#[test]
fn trait_is_object_safe() {
let adapter: Box<dyn McpAgentAdapter> = Box::new(NomiAdapter);
assert_eq!(adapter.source(), McpSource::Nomi);
}
}
@@ -0,0 +1,240 @@
use std::sync::Arc;
use nomifun_common::McpSource;
use nomifun_db::IMcpServerRepository;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::{McpServer, McpServerTransport};
/// MCP Agent adapter for Nomi itself.
///
/// Unlike CLI-based adapters, this adapter reads/writes directly to the
/// local database. It is always "installed" since Nomi is the host
/// application.
///
/// # Behavior
///
/// - `is_installed()` → always `true`
/// - `detect_existing()` → reads all MCP servers from the DB
/// - `install_server()` → no-op (DB writes are handled by `McpConfigService`)
/// - `remove_server()` → no-op (configuration is managed via the frontend)
pub struct NomifunAdapter {
repo: Arc<dyn IMcpServerRepository>,
}
impl NomifunAdapter {
pub fn new(repo: Arc<dyn IMcpServerRepository>) -> Self {
Self { repo }
}
}
#[async_trait::async_trait]
impl McpAgentAdapter for NomifunAdapter {
fn source(&self) -> McpSource {
McpSource::Nomifun
}
async fn is_installed(&self) -> Result<bool, McpError> {
Ok(true)
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
let rows = self.repo.list().await?;
let mut servers = Vec::new();
for row in rows {
let server = McpServer::from_row(row)?;
servers.push(DetectedServer {
name: server.name,
transport: server.transport,
importable: true,
import_skip_reason: None,
});
}
Ok(servers)
}
async fn install_server(&self, _name: &str, _transport: &McpServerTransport) -> Result<(), McpError> {
// No-op: DB writes are handled by McpConfigService.
// The sync service calls install_server on all adapters, but for
// Nomi the server is already in the DB.
Ok(())
}
async fn remove_server(&self, _name: &str) -> Result<(), McpError> {
// No-op: configuration is managed via the frontend/REST API.
// Removing from the DB is done through McpConfigService.delete_server().
Ok(())
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::types::McpServerTransport;
use nomifun_db::models::McpServerRow;
/// In-memory mock repository for testing.
struct MockRepo {
servers: Vec<McpServerRow>,
}
impl MockRepo {
fn new(servers: Vec<McpServerRow>) -> Self {
Self { servers }
}
}
#[async_trait::async_trait]
impl IMcpServerRepository for MockRepo {
async fn list(&self) -> Result<Vec<McpServerRow>, nomifun_db::DbError> {
Ok(self.servers.clone())
}
async fn find_by_id(&self, id: i64) -> Result<Option<McpServerRow>, nomifun_db::DbError> {
Ok(self.servers.iter().find(|s| s.id == id).cloned())
}
async fn find_by_name(&self, name: &str) -> Result<Option<McpServerRow>, nomifun_db::DbError> {
Ok(self.servers.iter().find(|s| s.name == name).cloned())
}
async fn create(
&self,
_params: nomifun_db::CreateMcpServerParams<'_>,
) -> Result<McpServerRow, nomifun_db::DbError> {
unimplemented!("not needed for adapter tests")
}
async fn update(
&self,
_id: i64,
_params: nomifun_db::UpdateMcpServerParams<'_>,
) -> Result<McpServerRow, nomifun_db::DbError> {
unimplemented!("not needed for adapter tests")
}
async fn delete(&self, _id: i64) -> Result<(), nomifun_db::DbError> {
unimplemented!("not needed for adapter tests")
}
async fn batch_upsert(
&self,
_servers: &[nomifun_db::CreateMcpServerParams<'_>],
) -> Result<Vec<McpServerRow>, nomifun_db::DbError> {
unimplemented!("not needed for adapter tests")
}
async fn update_status(
&self,
_id: i64,
_status: &str,
_last_connected: Option<nomifun_common::TimestampMs>,
) -> Result<(), nomifun_db::DbError> {
unimplemented!("not needed for adapter tests")
}
async fn update_tools(&self, _id: i64, _tools: Option<&str>) -> Result<(), nomifun_db::DbError> {
unimplemented!("not needed for adapter tests")
}
}
fn make_row(name: &str, transport_type: &str, transport_config: &str) -> McpServerRow {
McpServerRow {
// Host-local integer PK; never compared in adapter tests (detection keys on name).
id: name.bytes().map(i64::from).sum::<i64>().max(1),
name: name.to_owned(),
description: None,
enabled: true,
transport_type: transport_type.into(),
transport_config: transport_config.into(),
tools: None,
last_test_status: "disconnected".into(),
last_connected: None,
original_json: None,
builtin: false,
deleted_at: None,
created_at: 1000,
updated_at: 1000,
}
}
#[test]
fn source_is_nomifun() {
let repo = Arc::new(MockRepo::new(vec![]));
let adapter = NomifunAdapter::new(repo);
assert_eq!(adapter.source(), McpSource::Nomifun);
}
#[tokio::test]
async fn is_always_installed() {
let repo = Arc::new(MockRepo::new(vec![]));
let adapter = NomifunAdapter::new(repo);
assert!(adapter.is_installed().await.unwrap());
}
#[tokio::test]
async fn detect_existing_returns_db_servers() {
let rows = vec![
make_row("srv-a", "stdio", r#"{"command":"npx","args":[]}"#),
make_row("srv-b", "http", r#"{"url":"https://b.com/mcp","headers":{}}"#),
];
let repo = Arc::new(MockRepo::new(rows));
let adapter = NomifunAdapter::new(repo);
let servers = adapter.detect_existing().await.unwrap();
assert_eq!(servers.len(), 2);
assert_eq!(servers[0].name, "srv-a");
assert_eq!(servers[1].name, "srv-b");
assert!(matches!(servers[0].transport, McpServerTransport::Stdio { .. }));
assert!(matches!(servers[1].transport, McpServerTransport::Http { .. }));
}
#[tokio::test]
async fn detect_existing_empty_db() {
let repo = Arc::new(MockRepo::new(vec![]));
let adapter = NomifunAdapter::new(repo);
let servers = adapter.detect_existing().await.unwrap();
assert!(servers.is_empty());
}
#[tokio::test]
async fn install_server_is_noop() {
let repo = Arc::new(MockRepo::new(vec![]));
let adapter = NomifunAdapter::new(repo);
let transport = McpServerTransport::Stdio {
command: "npx".into(),
args: vec![],
env: HashMap::new(),
};
// Should succeed without side effects
adapter.install_server("test", &transport).await.unwrap();
}
#[tokio::test]
async fn remove_server_is_noop() {
let repo = Arc::new(MockRepo::new(vec![]));
let adapter = NomifunAdapter::new(repo);
// Should succeed without side effects
adapter.remove_server("test").await.unwrap();
}
#[tokio::test]
async fn trait_is_object_safe() {
let repo = Arc::new(MockRepo::new(vec![]));
let adapter: Arc<dyn McpAgentAdapter> = Arc::new(NomifunAdapter::new(repo));
assert_eq!(adapter.source(), McpSource::Nomifun);
assert!(adapter.is_installed().await.unwrap());
}
}
@@ -0,0 +1,637 @@
use std::collections::HashMap;
use std::path::PathBuf;
use nomifun_common::McpSource;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::McpServerTransport;
/// MCP Agent adapter for Opencode.
///
/// Opencode stores configuration in `~/.config/opencode/opencode.json`.
/// The `mcp` field is a map of server names to transport configs.
///
/// # Config Format (JSONC)
///
/// ```jsonc
/// {
/// // other opencode config...
/// "mcp": {
/// "server-name": {
/// "type": "stdio",
/// "command": "npx",
/// "args": ["-y", "@test/server"],
/// "env": { "KEY": "VALUE" }
/// },
/// "remote-server": {
/// "type": "http",
/// "url": "https://example.com/mcp",
/// "headers": { "Authorization": "Bearer xxx" }
/// }
/// }
/// }
/// ```
///
/// Opencode config files may contain JSON comments (JSONC), so we
/// strip comments before parsing and preserve the original structure
/// when writing back.
pub struct OpencodeAdapter;
#[async_trait::async_trait]
impl McpAgentAdapter for OpencodeAdapter {
fn source(&self) -> McpSource {
McpSource::OpenCode
}
async fn is_installed(&self) -> Result<bool, McpError> {
Ok(config_dir().is_some_and(|d| d.exists()))
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
let path = config_file_path().ok_or_else(|| McpError::AgentNotInstalled("opencode".into()))?;
if !path.exists() {
return Ok(Vec::new());
}
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to read {}: {e}", path.display())))?;
let root = parse_jsonc(&content)?;
parse_mcp_field(&root)
}
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
let path = config_file_path().ok_or_else(|| McpError::AgentNotInstalled("opencode".into()))?;
let mut root = if path.exists() {
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to read {}: {e}", path.display())))?;
parse_jsonc(&content)?
} else {
// Ensure directory exists
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to create dir: {e}")))?;
}
serde_json::json!({})
};
let mcp = root
.as_object_mut()
.ok_or_else(|| McpError::AgentOperationFailed("config root is not an object".into()))?
.entry("mcp")
.or_insert_with(|| serde_json::json!({}));
let mcp_obj = mcp
.as_object_mut()
.ok_or_else(|| McpError::AgentOperationFailed("mcp field is not an object".into()))?;
mcp_obj.insert(name.to_owned(), transport_to_json(transport));
let output = serde_json::to_string_pretty(&root)
.map_err(|e| McpError::AgentOperationFailed(format!("failed to serialize config: {e}")))?;
tokio::fs::write(&path, output)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to write {}: {e}", path.display())))?;
Ok(())
}
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
let path = config_file_path().ok_or_else(|| McpError::AgentNotInstalled("opencode".into()))?;
if !path.exists() {
return Ok(());
}
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to read {}: {e}", path.display())))?;
let mut root = parse_jsonc(&content)?;
let removed = root
.as_object_mut()
.and_then(|obj| obj.get_mut("mcp"))
.and_then(|mcp| mcp.as_object_mut())
.map(|mcp_obj| mcp_obj.remove(name).is_some())
.unwrap_or(false);
if !removed {
// Idempotent: not found is fine
return Ok(());
}
let output = serde_json::to_string_pretty(&root)
.map_err(|e| McpError::AgentOperationFailed(format!("failed to serialize config: {e}")))?;
tokio::fs::write(&path, output)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("failed to write {}: {e}", path.display())))?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Returns `~/.config/opencode/` if HOME is available.
fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("opencode"))
}
/// Returns `~/.config/opencode/opencode.json` if HOME is available.
fn config_file_path() -> Option<PathBuf> {
config_dir().map(|d| d.join("opencode.json"))
}
/// Strip single-line (`//`) and multi-line (`/* ... */`) JSON comments.
///
/// Preserves string contents (comments inside strings are left alone).
fn strip_json_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
// Check for string literal
if bytes[i] == b'"' {
result.push('"');
i += 1;
// Consume until closing quote, respecting escapes
while i < len {
if bytes[i] == b'\\' && i + 1 < len {
result.push(bytes[i] as char);
result.push(bytes[i + 1] as char);
i += 2;
} else if bytes[i] == b'"' {
result.push('"');
i += 1;
break;
} else {
result.push(bytes[i] as char);
i += 1;
}
}
} else if bytes[i] == b'/' && i + 1 < len {
if bytes[i + 1] == b'/' {
// Single-line comment: skip until newline
i += 2;
while i < len && bytes[i] != b'\n' {
i += 1;
}
} else if bytes[i + 1] == b'*' {
// Multi-line comment: skip until */
i += 2;
while i + 1 < len {
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
i += 2;
break;
}
i += 1;
}
// Handle unterminated block comment
if i >= len {
break;
}
} else {
result.push(bytes[i] as char);
i += 1;
}
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
/// Parse JSONC (JSON with comments) into a `serde_json::Value`.
fn parse_jsonc(input: &str) -> Result<serde_json::Value, McpError> {
let stripped = strip_json_comments(input);
serde_json::from_str(&stripped).map_err(McpError::from)
}
/// Extract MCP servers from the parsed config root.
fn parse_mcp_field(root: &serde_json::Value) -> Result<Vec<DetectedServer>, McpError> {
let mcp = match root.get("mcp") {
Some(v) => v,
None => return Ok(Vec::new()),
};
let mcp_obj = mcp
.as_object()
.ok_or_else(|| McpError::AgentOperationFailed("mcp field is not an object".into()))?;
let mut servers = Vec::new();
for (name, config) in mcp_obj {
if let Some(server) = parse_server_entry(name, config) {
servers.push(server);
}
}
Ok(servers)
}
/// Parse a single server entry from the `mcp` object.
fn parse_server_entry(name: &str, config: &serde_json::Value) -> Option<DetectedServer> {
let transport_type = config.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
let transport = match transport_type {
"stdio" => {
let command = config.get("command")?.as_str()?.to_owned();
let args = config
.get("args")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let env = config
.get("env")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default();
McpServerTransport::Stdio { command, args, env }
}
"sse" => {
let url = config.get("url")?.as_str()?.to_owned();
let headers = parse_headers(config);
McpServerTransport::Sse { url, headers }
}
"http" | "streamable_http" => {
let url = config.get("url")?.as_str()?.to_owned();
let headers = parse_headers(config);
McpServerTransport::Http { url, headers }
}
_ => return None,
};
Some(DetectedServer {
name: name.to_owned(),
transport,
importable: true,
import_skip_reason: None,
})
}
/// Extract headers from a config object's `headers` field.
fn parse_headers(config: &serde_json::Value) -> HashMap<String, String> {
config
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
.collect()
})
.unwrap_or_default()
}
/// Convert a `McpServerTransport` to a JSON value for writing to config.
fn transport_to_json(transport: &McpServerTransport) -> serde_json::Value {
match transport {
McpServerTransport::Stdio { command, args, env } => {
let mut obj = serde_json::json!({
"type": "stdio",
"command": command,
"args": args,
});
if !env.is_empty() {
obj["env"] = serde_json::json!(env);
}
obj
}
McpServerTransport::Sse { url, headers } => {
let mut obj = serde_json::json!({
"type": "sse",
"url": url,
});
if !headers.is_empty() {
obj["headers"] = serde_json::json!(headers);
}
obj
}
McpServerTransport::Http { url, headers } => {
let mut obj = serde_json::json!({
"type": "http",
"url": url,
});
if !headers.is_empty() {
obj["headers"] = serde_json::json!(headers);
}
obj
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_is_opencode() {
assert_eq!(OpencodeAdapter.source(), McpSource::OpenCode);
}
// -- strip_json_comments --------------------------------------------------
#[test]
fn strip_single_line_comments() {
let input = r#"{
// This is a comment
"key": "value" // inline comment
}"#;
let stripped = strip_json_comments(input);
let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(parsed["key"], "value");
}
#[test]
fn strip_multi_line_comments() {
let input = r#"{
/* multi-line
comment */
"key": "value"
}"#;
let stripped = strip_json_comments(input);
let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(parsed["key"], "value");
}
#[test]
fn preserve_comments_inside_strings() {
let input = r#"{
"key": "value with // comment inside",
"key2": "value with /* block */ inside"
}"#;
let stripped = strip_json_comments(input);
let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(parsed["key"], "value with // comment inside");
assert_eq!(parsed["key2"], "value with /* block */ inside");
}
#[test]
fn strip_comments_preserves_escaped_quotes() {
let input = r#"{"key": "val\"ue // not a comment"}"#;
let stripped = strip_json_comments(input);
let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
assert_eq!(parsed["key"], "val\"ue // not a comment");
}
#[test]
fn strip_no_comments() {
let input = r#"{"key": "value"}"#;
assert_eq!(strip_json_comments(input), input);
}
// -- parse_mcp_field ------------------------------------------------------
#[test]
fn parse_empty_mcp() {
let root = serde_json::json!({ "mcp": {} });
let servers = parse_mcp_field(&root).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_no_mcp_field() {
let root = serde_json::json!({ "other": "stuff" });
let servers = parse_mcp_field(&root).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_stdio_server() {
let root = serde_json::json!({
"mcp": {
"test-mcp": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@test/server"],
"env": { "KEY": "VALUE" }
}
}
});
let servers = parse_mcp_field(&root).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "test-mcp");
match &servers[0].transport {
McpServerTransport::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y", "@test/server"]);
assert_eq!(env.get("KEY").unwrap(), "VALUE");
}
_ => panic!("expected Stdio"),
}
}
#[test]
fn parse_http_server() {
let root = serde_json::json!({
"mcp": {
"remote": {
"type": "http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer tok" }
}
}
});
let servers = parse_mcp_field(&root).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Http { url, headers } => {
assert_eq!(url, "https://example.com/mcp");
assert_eq!(headers.get("Authorization").unwrap(), "Bearer tok");
}
_ => panic!("expected Http"),
}
}
#[test]
fn parse_sse_server() {
let root = serde_json::json!({
"mcp": {
"sse-srv": {
"type": "sse",
"url": "https://example.com/sse"
}
}
});
let servers = parse_mcp_field(&root).unwrap();
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Sse { url, .. } => {
assert_eq!(url, "https://example.com/sse");
}
_ => panic!("expected Sse"),
}
}
#[test]
fn parse_streamable_http_becomes_http() {
let root = serde_json::json!({
"mcp": {
"sh": {
"type": "streamable_http",
"url": "https://example.com/api"
}
}
});
let servers = parse_mcp_field(&root).unwrap();
assert_eq!(servers.len(), 1);
assert!(matches!(servers[0].transport, McpServerTransport::Http { .. }));
}
#[test]
fn parse_unknown_transport_skipped() {
let root = serde_json::json!({
"mcp": {
"ws": { "type": "websocket", "url": "ws://localhost" }
}
});
let servers = parse_mcp_field(&root).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_stdio_missing_command_skipped() {
let root = serde_json::json!({
"mcp": {
"bad": { "type": "stdio", "args": [] }
}
});
let servers = parse_mcp_field(&root).unwrap();
assert!(servers.is_empty());
}
#[test]
fn parse_multiple_servers() {
let root = serde_json::json!({
"mcp": {
"srv-a": { "type": "stdio", "command": "node" },
"srv-b": { "type": "http", "url": "https://b.com/mcp" }
}
});
let servers = parse_mcp_field(&root).unwrap();
assert_eq!(servers.len(), 2);
}
#[test]
fn parse_default_type_is_stdio() {
let root = serde_json::json!({
"mcp": {
"no-type": { "command": "node", "args": ["srv.js"] }
}
});
let servers = parse_mcp_field(&root).unwrap();
assert_eq!(servers.len(), 1);
assert!(matches!(servers[0].transport, McpServerTransport::Stdio { .. }));
}
// -- transport_to_json ----------------------------------------------------
#[test]
fn stdio_to_json_roundtrip() {
let transport = McpServerTransport::Stdio {
command: "npx".into(),
args: vec!["-y".into(), "@test/srv".into()],
env: HashMap::from([("K".into(), "V".into())]),
};
let json = transport_to_json(&transport);
let server = parse_server_entry("test", &json).unwrap();
assert_eq!(server.transport, transport);
}
#[test]
fn http_to_json_roundtrip() {
let transport = McpServerTransport::Http {
url: "https://example.com/mcp".into(),
headers: HashMap::from([("Authorization".into(), "Bearer tok".into())]),
};
let json = transport_to_json(&transport);
let server = parse_server_entry("test", &json).unwrap();
assert_eq!(server.transport, transport);
}
#[test]
fn sse_to_json_roundtrip() {
let transport = McpServerTransport::Sse {
url: "https://example.com/sse".into(),
headers: HashMap::new(),
};
let json = transport_to_json(&transport);
let server = parse_server_entry("test", &json).unwrap();
assert_eq!(server.transport, transport);
}
#[test]
fn stdio_to_json_omits_empty_env() {
let transport = McpServerTransport::Stdio {
command: "node".into(),
args: vec![],
env: HashMap::new(),
};
let json = transport_to_json(&transport);
assert!(json.get("env").is_none());
}
#[test]
fn http_to_json_omits_empty_headers() {
let transport = McpServerTransport::Http {
url: "https://x.com".into(),
headers: HashMap::new(),
};
let json = transport_to_json(&transport);
assert!(json.get("headers").is_none());
}
// -- parse_jsonc ----------------------------------------------------------
#[test]
fn parse_jsonc_with_comments() {
let input = r#"{
// comment
"mcp": {
/* block comment */
"srv": {
"type": "stdio",
"command": "npx"
}
}
}"#;
let root = parse_jsonc(input).unwrap();
let servers = parse_mcp_field(&root).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "srv");
}
#[test]
fn parse_jsonc_invalid_json_fails() {
let result = parse_jsonc("not json at all");
assert!(result.is_err());
}
#[test]
fn trait_is_object_safe() {
let adapter: Box<dyn McpAgentAdapter> = Box::new(OpencodeAdapter);
assert_eq!(adapter.source(), McpSource::OpenCode);
}
}
@@ -0,0 +1,198 @@
use std::collections::HashMap;
use nomifun_common::McpSource;
use crate::adapter::{DetectedServer, McpAgentAdapter};
use crate::error::McpError;
use crate::types::McpServerTransport;
use super::cli_helpers::{
DETECT_TIMEOUT, MUTATE_TIMEOUT, build_env_args, build_header_args, is_cli_installed, parse_standard_list_output,
run_cli,
};
const CLI_NAME: &str = "qwen";
/// Scopes tried when removing (user first, then project).
const REMOVE_SCOPES: &[&str] = &["user", "project"];
/// MCP Agent adapter for Qwen CLI.
///
/// # CLI Commands
///
/// - **detect**: `qwen mcp list`
/// - **install (stdio)**: `qwen mcp add <name> <command> [args...] [--env K=V]... -s user`
/// - **install (http/sse)**: `qwen mcp add <name> <url> --transport <type> [--header K: V]... -s user`
/// - **remove**: `qwen mcp remove <name> -s user` → `-s project` → file fallback
///
/// If CLI remove fails, falls back to editing `~/.qwen/client_config.json`.
pub struct QwenAdapter;
#[async_trait::async_trait]
impl McpAgentAdapter for QwenAdapter {
fn source(&self) -> McpSource {
McpSource::Qwen
}
async fn is_installed(&self) -> Result<bool, McpError> {
is_cli_installed(CLI_NAME).await
}
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
let (stdout, _stderr) = run_cli(CLI_NAME, &["mcp", "list"], DETECT_TIMEOUT).await?;
Ok(parse_standard_list_output(&stdout))
}
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
match transport {
McpServerTransport::Stdio { command, args, env } => {
let mut cli_args = vec!["mcp".to_owned(), "add".to_owned(), name.to_owned(), command.clone()];
cli_args.extend(args.iter().cloned());
cli_args.extend(build_env_args(env, "--env"));
cli_args.push("-s".to_owned());
cli_args.push("user".to_owned());
let arg_refs: Vec<&str> = cli_args.iter().map(|s| s.as_str()).collect();
run_cli(CLI_NAME, &arg_refs, MUTATE_TIMEOUT).await?;
}
McpServerTransport::Sse { url, headers } => {
install_http_like(name, "sse", url, headers).await?;
}
McpServerTransport::Http { url, headers } => {
install_http_like(name, "http", url, headers).await?;
}
}
Ok(())
}
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
if !self.is_installed().await? {
return Err(McpError::AgentNotInstalled(CLI_NAME.into()));
}
// Try CLI removal with each scope.
for scope in REMOVE_SCOPES {
let (stdout, _stderr) = run_cli(CLI_NAME, &["mcp", "remove", name, "-s", scope], MUTATE_TIMEOUT).await?;
let lower = stdout.to_lowercase();
if lower.contains("removed") {
return Ok(());
}
}
// Fallback: directly edit ~/.qwen/client_config.json
remove_from_config_file(name).await
}
}
/// Install an HTTP-like (sse/http) server via `qwen mcp add`.
async fn install_http_like(
name: &str,
transport_type: &str,
url: &str,
headers: &HashMap<String, String>,
) -> Result<(), McpError> {
let mut cli_args = vec![
"mcp".to_owned(),
"add".to_owned(),
name.to_owned(),
url.to_owned(),
"--transport".to_owned(),
transport_type.to_owned(),
];
cli_args.extend(build_header_args(headers, "--header"));
cli_args.push("-s".to_owned());
cli_args.push("user".to_owned());
let arg_refs: Vec<&str> = cli_args.iter().map(|s| s.as_str()).collect();
run_cli(CLI_NAME, &arg_refs, MUTATE_TIMEOUT).await?;
Ok(())
}
/// Fallback: remove server from `~/.qwen/client_config.json` directly.
///
/// Reads the file, deletes the key from `mcpServers`, writes back.
/// Silently succeeds if the file doesn't exist or the key is absent.
async fn remove_from_config_file(name: &str) -> Result<(), McpError> {
let home = home_dir()?;
let config_path = home.join(".qwen").join("client_config.json");
if !config_path.exists() {
return Ok(());
}
let content = tokio::fs::read_to_string(&config_path)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("read qwen config: {e}")))?;
let mut config: serde_json::Value = serde_json::from_str(&content).map_err(McpError::from)?;
let removed = config
.get_mut("mcpServers")
.and_then(|servers| servers.as_object_mut())
.map(|servers| servers.remove(name).is_some())
.unwrap_or(false);
if removed {
let new_content = serde_json::to_string_pretty(&config).map_err(McpError::from)?;
tokio::fs::write(&config_path, new_content)
.await
.map_err(|e| McpError::AgentOperationFailed(format!("write qwen config: {e}")))?;
}
Ok(())
}
/// Get the user's home directory.
fn home_dir() -> Result<std::path::PathBuf, McpError> {
dirs::home_dir().ok_or_else(|| McpError::AgentOperationFailed("cannot determine home directory".into()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_is_qwen() {
assert_eq!(QwenAdapter.source(), McpSource::Qwen);
}
#[test]
fn parse_qwen_list_output() {
let output = "\
✓ my-server: npx -y @test/server (stdio) - Connected
✗ broken: node bad.js (stdio) - Disconnected";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 2);
assert_eq!(servers[0].name, "my-server");
assert_eq!(servers[1].name, "broken");
}
#[test]
fn parse_qwen_http_server() {
let output = "✓ remote: https://example.com/mcp (http) - Connected";
let servers = parse_standard_list_output(output);
assert_eq!(servers.len(), 1);
match &servers[0].transport {
McpServerTransport::Http { url, .. } => {
assert_eq!(url, "https://example.com/mcp");
}
_ => panic!("expected Http"),
}
}
#[test]
fn trait_is_object_safe() {
let adapter: Box<dyn McpAgentAdapter> = Box::new(QwenAdapter);
assert_eq!(adapter.source(), McpSource::Qwen);
}
}