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