Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-mcp/src/adapters/opencode.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

638 lines
20 KiB
Rust

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);
}
}