Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user