Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled
- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db - Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp* - Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md - Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
163 lines
4.7 KiB
Rust
163 lines
4.7 KiB
Rust
use anyhow::{Context, Result};
|
|
use serde_json::{Map, Value};
|
|
use std::{
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
pub trait ClientAdapter {
|
|
fn client_name(&self) -> &'static str;
|
|
fn config_path(&self) -> PathBuf;
|
|
}
|
|
|
|
pub struct CursorAdapter;
|
|
|
|
impl ClientAdapter for CursorAdapter {
|
|
fn client_name(&self) -> &'static str {
|
|
"cursor"
|
|
}
|
|
|
|
fn config_path(&self) -> PathBuf {
|
|
default_home().join(".cursor").join("mcp.json")
|
|
}
|
|
}
|
|
|
|
pub struct ClaudeCodeAdapter;
|
|
|
|
impl ClientAdapter for ClaudeCodeAdapter {
|
|
fn client_name(&self) -> &'static str {
|
|
"claude-code"
|
|
}
|
|
|
|
fn config_path(&self) -> PathBuf {
|
|
default_home().join(".claude").join("mcp.json")
|
|
}
|
|
}
|
|
|
|
fn default_home() -> PathBuf {
|
|
std::env::var_os("HOME")
|
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
}
|
|
|
|
pub fn has_managed_server(adapter: &dyn ClientAdapter, server_name: &str) -> Result<bool> {
|
|
let path = adapter.config_path();
|
|
let root = read_config_or_default(&path)?;
|
|
Ok(root
|
|
.get("mcpServers")
|
|
.and_then(Value::as_object)
|
|
.is_some_and(|servers| servers.contains_key(server_name)))
|
|
}
|
|
|
|
pub fn upsert_managed_server(
|
|
adapter: &dyn ClientAdapter,
|
|
server_name: &str,
|
|
server_config: Value,
|
|
) -> Result<()> {
|
|
let path = adapter.config_path();
|
|
let mut root = read_config_or_default(&path)?;
|
|
let root_object = ensure_object(&mut root);
|
|
let mcp_servers = root_object
|
|
.entry("mcpServers".to_string())
|
|
.or_insert_with(|| Value::Object(Map::new()));
|
|
let servers_object = ensure_object(mcp_servers);
|
|
servers_object.insert(server_name.to_string(), server_config);
|
|
write_config_atomically(&path, &root)
|
|
}
|
|
|
|
fn read_config_or_default(path: &Path) -> Result<Value> {
|
|
if !path.exists() {
|
|
return Ok(Value::Object(Map::new()));
|
|
}
|
|
let raw =
|
|
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
|
serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
|
|
}
|
|
|
|
fn write_config_atomically(path: &Path, value: &Value) -> Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
}
|
|
let tmp_path = path.with_extension("json.tmp");
|
|
let body = serde_json::to_string_pretty(value).context("failed to serialize mcp config")?;
|
|
fs::write(&tmp_path, body)
|
|
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
|
|
fs::rename(&tmp_path, path).with_context(|| format!("failed to replace {}", path.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_object(value: &mut Value) -> &mut Map<String, Value> {
|
|
if !value.is_object() {
|
|
*value = Value::Object(Map::new());
|
|
}
|
|
value.as_object_mut().expect("object just ensured")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
struct TestAdapter {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl ClientAdapter for TestAdapter {
|
|
fn client_name(&self) -> &'static str {
|
|
"test"
|
|
}
|
|
|
|
fn config_path(&self) -> PathBuf {
|
|
self.path.clone()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_preserves_other_servers() {
|
|
let unique = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("clock")
|
|
.as_nanos();
|
|
let base = std::env::temp_dir().join(format!("secrets-client-integrations-{unique}"));
|
|
let adapter = TestAdapter {
|
|
path: base.join("mcp.json"),
|
|
};
|
|
fs::create_dir_all(adapter.path.parent().expect("parent")).expect("mkdir");
|
|
fs::write(
|
|
&adapter.path,
|
|
r#"{"mcpServers":{"postgres":{"command":"npx"},"secrets":{"url":"http://old"}}}"#,
|
|
)
|
|
.expect("seed config");
|
|
|
|
upsert_managed_server(
|
|
&adapter,
|
|
"secrets",
|
|
serde_json::json!({
|
|
"url": "http://127.0.0.1:9515/mcp"
|
|
}),
|
|
)
|
|
.expect("upsert config");
|
|
|
|
let root: Value =
|
|
serde_json::from_str(&fs::read_to_string(&adapter.path).expect("read back"))
|
|
.expect("parse back");
|
|
let servers = root
|
|
.get("mcpServers")
|
|
.and_then(Value::as_object)
|
|
.expect("mcpServers object");
|
|
assert!(servers.contains_key("postgres"));
|
|
assert_eq!(
|
|
servers
|
|
.get("secrets")
|
|
.and_then(Value::as_object)
|
|
.and_then(|value| value.get("url"))
|
|
.and_then(Value::as_str),
|
|
Some("http://127.0.0.1:9515/mcp")
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(base);
|
|
}
|
|
}
|