use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use serde_yaml::Value as YamlValue;
use tokio::sync::Mutex;
use nexo_tool_meta::admin::mcp::{
McpServerDetail, McpServerSummary, McpServersDeleteParams, McpServersDeleteResponse,
McpServersGetParams, McpServersGetResponse, McpServersListResponse, McpServersUpsertInput,
McpServersUpsertResponse,
};
use crate::agent::admin_rpc::dispatcher::{AdminRpcError, AdminRpcResult};
const VALID_TRANSPORTS: &[&str] = &["stdio", "streamable_http", "sse", "auto"];
#[async_trait]
pub trait McpServerStore: Send + Sync + std::fmt::Debug {
async fn list(&self) -> anyhow::Result<Vec<McpServerSummary>>;
async fn get(&self, name: &str) -> anyhow::Result<Option<McpServerDetail>>;
async fn upsert(&self, input: McpServerDetail) -> anyhow::Result<(McpServerDetail, bool)>;
async fn delete(&self, name: &str) -> anyhow::Result<bool>;
}
pub async fn list(store: &dyn McpServerStore) -> AdminRpcResult {
match store.list().await {
Ok(servers) => {
let resp = McpServersListResponse { servers };
AdminRpcResult::ok(serde_json::to_value(resp).unwrap_or(Value::Null))
}
Err(e) => AdminRpcResult::err(AdminRpcError::Internal(format!("mcp_store.list: {e}"))),
}
}
pub async fn get(store: &dyn McpServerStore, params: Value) -> AdminRpcResult {
let p: McpServersGetParams = match serde_json::from_value(params) {
Ok(v) => v,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
match store.get(&p.name).await {
Ok(server) => {
let resp = McpServersGetResponse { server };
AdminRpcResult::ok(serde_json::to_value(resp).unwrap_or(Value::Null))
}
Err(e) => AdminRpcResult::err(AdminRpcError::Internal(format!("mcp_store.get: {e}"))),
}
}
pub async fn upsert(store: &dyn McpServerStore, params: Value) -> AdminRpcResult {
let input: McpServersUpsertInput = match serde_json::from_value(params) {
Ok(v) => v,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if let Err(msg) = validate_upsert(&input) {
return AdminRpcResult::err(AdminRpcError::InvalidParams(msg));
}
match store.upsert(input).await {
Ok((server, created)) => {
let resp = McpServersUpsertResponse { server, created };
AdminRpcResult::ok(serde_json::to_value(resp).unwrap_or(Value::Null))
}
Err(e) => AdminRpcResult::err(AdminRpcError::Internal(format!("mcp_store.upsert: {e}"))),
}
}
pub async fn delete(store: &dyn McpServerStore, params: Value) -> AdminRpcResult {
let p: McpServersDeleteParams = match serde_json::from_value(params) {
Ok(v) => v,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
match store.delete(&p.name).await {
Ok(removed) => {
let resp = McpServersDeleteResponse { removed };
AdminRpcResult::ok(serde_json::to_value(resp).unwrap_or(Value::Null))
}
Err(e) => AdminRpcResult::err(AdminRpcError::Internal(format!("mcp_store.delete: {e}"))),
}
}
fn validate_upsert(input: &McpServerDetail) -> Result<(), String> {
let trimmed = input.name.trim();
if trimmed.is_empty() {
return Err("name is empty".into());
}
if trimmed.len() > 64 {
return Err("name exceeds 64 chars".into());
}
if !VALID_TRANSPORTS.contains(&input.transport.as_str()) {
return Err(format!(
"transport `{}` not one of: stdio | streamable_http | sse | auto",
input.transport
));
}
match input.transport.as_str() {
"stdio" => {
if input.command.as_deref().unwrap_or("").trim().is_empty() {
return Err("stdio transport requires `command`".into());
}
}
"streamable_http" | "sse" | "auto" => {
if input.url.as_deref().unwrap_or("").trim().is_empty() {
return Err(format!("{} transport requires `url`", input.transport));
}
}
_ => unreachable!(),
}
Ok(())
}
#[derive(Debug)]
pub struct McpYamlStore {
path: PathBuf,
write_lock: Mutex<()>,
}
impl McpYamlStore {
pub fn new(config_dir: PathBuf) -> Arc<Self> {
Arc::new(Self {
path: config_dir.join("mcp.yaml"),
write_lock: Mutex::new(()),
})
}
fn read_doc(&self) -> anyhow::Result<YamlValue> {
if !self.path.exists() {
return Ok(empty_doc());
}
let raw = std::fs::read_to_string(&self.path)?;
let doc: YamlValue = serde_yaml::from_str(&raw)?;
Ok(doc)
}
fn write_doc(&self, doc: &YamlValue) -> anyhow::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = self.path.with_extension("yaml.tmp");
let serialised = serde_yaml::to_string(doc)?;
std::fs::write(&tmp, serialised)?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
#[async_trait]
impl McpServerStore for McpYamlStore {
async fn list(&self) -> anyhow::Result<Vec<McpServerSummary>> {
let doc = self.read_doc()?;
let servers = servers_map(&doc).cloned().unwrap_or_default();
let mut out = Vec::with_capacity(servers.len());
for (name, val) in servers {
let transport = transport_of(&val).unwrap_or_else(|| "unknown".into());
let log_level = string_field(&val, "log_level");
out.push(McpServerSummary {
name: yaml_key_to_string(&name),
transport,
log_level,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
async fn get(&self, name: &str) -> anyhow::Result<Option<McpServerDetail>> {
let doc = self.read_doc()?;
let Some(servers) = servers_map(&doc) else {
return Ok(None);
};
let key = YamlValue::String(name.to_string());
let Some(val) = servers.get(&key) else {
return Ok(None);
};
Ok(Some(yaml_to_detail(name, val)))
}
async fn upsert(&self, input: McpServerDetail) -> anyhow::Result<(McpServerDetail, bool)> {
let _guard = self.write_lock.lock().await;
let mut doc = self.read_doc()?;
let mut created = true;
{
let servers = servers_map_mut(&mut doc)?;
let key = YamlValue::String(input.name.clone());
if servers.contains_key(&key) {
created = false;
}
servers.insert(key, detail_to_yaml(&input));
}
self.write_doc(&doc)?;
Ok((input, created))
}
async fn delete(&self, name: &str) -> anyhow::Result<bool> {
let _guard = self.write_lock.lock().await;
let mut doc = self.read_doc()?;
let removed = {
let servers = servers_map_mut(&mut doc)?;
let key = YamlValue::String(name.to_string());
servers.remove(&key).is_some()
};
if removed {
self.write_doc(&doc)?;
}
Ok(removed)
}
}
fn empty_doc() -> YamlValue {
let mut mcp = serde_yaml::Mapping::new();
mcp.insert(
YamlValue::String("servers".into()),
YamlValue::Mapping(serde_yaml::Mapping::new()),
);
let mut root = serde_yaml::Mapping::new();
root.insert(YamlValue::String("mcp".into()), YamlValue::Mapping(mcp));
YamlValue::Mapping(root)
}
fn servers_map(doc: &YamlValue) -> Option<&serde_yaml::Mapping> {
let YamlValue::Mapping(root) = doc else {
return None;
};
let mcp = root.get(YamlValue::String("mcp".into()))?;
let YamlValue::Mapping(mcp_map) = mcp else {
return None;
};
let servers = mcp_map.get(YamlValue::String("servers".into()))?;
let YamlValue::Mapping(map) = servers else {
return None;
};
Some(map)
}
fn servers_map_mut(doc: &mut YamlValue) -> anyhow::Result<&mut serde_yaml::Mapping> {
let root = match doc {
YamlValue::Mapping(m) => m,
_ => anyhow::bail!("mcp.yaml root is not a mapping"),
};
let mcp_entry = root
.entry(YamlValue::String("mcp".into()))
.or_insert_with(|| YamlValue::Mapping(serde_yaml::Mapping::new()));
let mcp_map = match mcp_entry {
YamlValue::Mapping(m) => m,
_ => anyhow::bail!("mcp section is not a mapping"),
};
let servers_entry = mcp_map
.entry(YamlValue::String("servers".into()))
.or_insert_with(|| YamlValue::Mapping(serde_yaml::Mapping::new()));
match servers_entry {
YamlValue::Mapping(m) => Ok(m),
_ => anyhow::bail!("mcp.servers section is not a mapping"),
}
}
fn transport_of(val: &YamlValue) -> Option<String> {
let YamlValue::Mapping(m) = val else {
return None;
};
let v = m.get(YamlValue::String("transport".into()))?;
let YamlValue::String(s) = v else {
return None;
};
Some(s.clone())
}
fn string_field(val: &YamlValue, field: &str) -> Option<String> {
let YamlValue::Mapping(m) = val else {
return None;
};
let v = m.get(YamlValue::String(field.into()))?;
let YamlValue::String(s) = v else {
return None;
};
Some(s.clone())
}
fn yaml_key_to_string(k: &YamlValue) -> String {
match k {
YamlValue::String(s) => s.clone(),
other => serde_yaml::to_string(other).unwrap_or_default().trim().into(),
}
}
fn yaml_to_detail(name: &str, val: &YamlValue) -> McpServerDetail {
let YamlValue::Mapping(m) = val else {
return McpServerDetail {
name: name.into(),
transport: "unknown".into(),
..Default::default()
};
};
let transport = m
.get(YamlValue::String("transport".into()))
.and_then(|v| match v {
YamlValue::String(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_else(|| "unknown".into());
McpServerDetail {
name: name.into(),
transport,
command: m
.get(YamlValue::String("command".into()))
.and_then(|v| match v {
YamlValue::String(s) => Some(s.clone()),
_ => None,
}),
args: m
.get(YamlValue::String("args".into()))
.and_then(|v| match v {
YamlValue::Sequence(s) => Some(
s.iter()
.filter_map(|e| match e {
YamlValue::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
),
_ => None,
})
.unwrap_or_default(),
env: m
.get(YamlValue::String("env".into()))
.and_then(yaml_to_string_map)
.unwrap_or_default(),
cwd: m
.get(YamlValue::String("cwd".into()))
.and_then(|v| match v {
YamlValue::String(s) => Some(s.clone()),
_ => None,
}),
url: m
.get(YamlValue::String("url".into()))
.and_then(|v| match v {
YamlValue::String(s) => Some(s.clone()),
_ => None,
}),
headers: m
.get(YamlValue::String("headers".into()))
.and_then(yaml_to_string_map)
.unwrap_or_default(),
log_level: m
.get(YamlValue::String("log_level".into()))
.and_then(|v| match v {
YamlValue::String(s) => Some(s.clone()),
_ => None,
}),
context_passthrough: m
.get(YamlValue::String("context_passthrough".into()))
.and_then(|v| match v {
YamlValue::Bool(b) => Some(*b),
_ => None,
}),
}
}
fn yaml_to_string_map(val: &YamlValue) -> Option<BTreeMap<String, String>> {
let YamlValue::Mapping(m) = val else {
return None;
};
let mut out = BTreeMap::new();
for (k, v) in m {
if let (YamlValue::String(ks), YamlValue::String(vs)) = (k, v) {
out.insert(ks.clone(), vs.clone());
}
}
Some(out)
}
fn detail_to_yaml(d: &McpServerDetail) -> YamlValue {
let mut m = serde_yaml::Mapping::new();
m.insert(
YamlValue::String("transport".into()),
YamlValue::String(d.transport.clone()),
);
if let Some(cmd) = &d.command {
m.insert(
YamlValue::String("command".into()),
YamlValue::String(cmd.clone()),
);
}
if !d.args.is_empty() {
m.insert(
YamlValue::String("args".into()),
YamlValue::Sequence(d.args.iter().map(|s| YamlValue::String(s.clone())).collect()),
);
}
if !d.env.is_empty() {
let mut env_map = serde_yaml::Mapping::new();
for (k, v) in &d.env {
env_map.insert(YamlValue::String(k.clone()), YamlValue::String(v.clone()));
}
m.insert(
YamlValue::String("env".into()),
YamlValue::Mapping(env_map),
);
}
if let Some(cwd) = &d.cwd {
m.insert(
YamlValue::String("cwd".into()),
YamlValue::String(cwd.clone()),
);
}
if let Some(url) = &d.url {
m.insert(
YamlValue::String("url".into()),
YamlValue::String(url.clone()),
);
}
if !d.headers.is_empty() {
let mut h_map = serde_yaml::Mapping::new();
for (k, v) in &d.headers {
h_map.insert(YamlValue::String(k.clone()), YamlValue::String(v.clone()));
}
m.insert(
YamlValue::String("headers".into()),
YamlValue::Mapping(h_map),
);
}
if let Some(level) = &d.log_level {
m.insert(
YamlValue::String("log_level".into()),
YamlValue::String(level.clone()),
);
}
if let Some(b) = d.context_passthrough {
m.insert(
YamlValue::String("context_passthrough".into()),
YamlValue::Bool(b),
);
}
YamlValue::Mapping(m)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn make_store() -> (Arc<McpYamlStore>, tempfile::TempDir) {
let dir = tempdir().unwrap();
let store = McpYamlStore::new(dir.path().to_path_buf());
(store, dir)
}
#[tokio::test]
async fn empty_store_list_returns_empty() {
let (store, _dir) = make_store();
let servers = store.list().await.unwrap();
assert!(servers.is_empty());
}
#[tokio::test]
async fn upsert_then_list_roundtrips() {
let (store, _dir) = make_store();
let detail = McpServerDetail {
name: "echo".into(),
transport: "stdio".into(),
command: Some("/bin/cat".into()),
args: vec!["--keep-going".into()],
..Default::default()
};
let (_, created) = store.upsert(detail.clone()).await.unwrap();
assert!(created);
let servers = store.list().await.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "echo");
assert_eq!(servers[0].transport, "stdio");
}
#[tokio::test]
async fn upsert_idempotent_returns_created_false_on_second_call() {
let (store, _dir) = make_store();
let detail = McpServerDetail {
name: "tag".into(),
transport: "stdio".into(),
command: Some("/bin/echo".into()),
..Default::default()
};
let (_, c1) = store.upsert(detail.clone()).await.unwrap();
let (_, c2) = store.upsert(detail).await.unwrap();
assert!(c1);
assert!(!c2);
}
#[tokio::test]
async fn get_returns_full_detail() {
let (store, _dir) = make_store();
let detail = McpServerDetail {
name: "remote".into(),
transport: "streamable_http".into(),
url: Some("https://api.example.com".into()),
headers: BTreeMap::from([("X-Auth".into(), "${TOKEN}".into())]),
..Default::default()
};
store.upsert(detail.clone()).await.unwrap();
let got = store.get("remote").await.unwrap();
assert_eq!(got.as_ref().map(|d| d.transport.as_str()), Some("streamable_http"));
assert_eq!(got.as_ref().and_then(|d| d.url.as_deref()), Some("https://api.example.com"));
}
#[tokio::test]
async fn get_unknown_returns_none() {
let (store, _dir) = make_store();
let got = store.get("nope").await.unwrap();
assert!(got.is_none());
}
#[tokio::test]
async fn delete_removes_entry() {
let (store, _dir) = make_store();
store
.upsert(McpServerDetail {
name: "drop".into(),
transport: "stdio".into(),
command: Some("/bin/true".into()),
..Default::default()
})
.await
.unwrap();
let removed = store.delete("drop").await.unwrap();
assert!(removed);
let removed_again = store.delete("drop").await.unwrap();
assert!(!removed_again);
}
#[test]
fn validate_upsert_rejects_invalid_transport() {
let bad = McpServerDetail {
name: "x".into(),
transport: "ws".into(),
..Default::default()
};
assert!(validate_upsert(&bad).is_err());
}
#[test]
fn validate_upsert_rejects_stdio_without_command() {
let bad = McpServerDetail {
name: "x".into(),
transport: "stdio".into(),
..Default::default()
};
assert!(validate_upsert(&bad).is_err());
}
#[test]
fn validate_upsert_rejects_http_without_url() {
let bad = McpServerDetail {
name: "x".into(),
transport: "streamable_http".into(),
..Default::default()
};
assert!(validate_upsert(&bad).is_err());
}
#[tokio::test]
async fn yaml_round_trip_preserves_unknown_fields_at_top_level() {
let (store, dir) = make_store();
let path = dir.path().join("mcp.yaml");
std::fs::write(
&path,
"schema_version: 11\nmcp:\n enabled: true\n servers: {}\n",
)
.unwrap();
store
.upsert(McpServerDetail {
name: "new-one".into(),
transport: "stdio".into(),
command: Some("/bin/echo".into()),
..Default::default()
})
.await
.unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("schema_version"), "got: {raw}");
assert!(raw.contains("enabled: true"), "got: {raw}");
assert!(raw.contains("new-one"), "got: {raw}");
}
}