use serde_json::Value;
use nexo_tool_meta::admin::channels::{
ChannelDoctorReport, ChannelDoctorVerdict, ChannelEntry, ChannelsApproveInput,
ChannelsDoctorParams, ChannelsListFilter, ChannelsListResponse, ChannelsRevokeParams,
ChannelsRevokeResponse,
};
use super::agents::YamlPatcher;
use crate::agent::admin_rpc::dispatcher::{AdminRpcError, AdminRpcResult};
pub fn list(yaml: &dyn YamlPatcher, params: Value) -> AdminRpcResult {
let filter: ChannelsListFilter = serde_json::from_value(params).unwrap_or_default();
let agent_ids = match yaml.list_agent_ids() {
Ok(ids) => ids,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!("agents.yaml read: {e}")));
}
};
let mut entries: Vec<ChannelEntry> = Vec::new();
for aid in &agent_ids {
if let Some(filter_aid) = &filter.agent_id {
if aid != filter_aid {
continue;
}
}
if let Some(Value::Array(approved)) = yaml
.read_agent_field(aid, "channels.approved")
.ok()
.flatten()
{
for ch in approved {
if let Some(entry) = parse_channel_entry(aid, &ch) {
entries.push(entry);
}
}
}
}
entries.sort_by(|a, b| {
a.agent_id
.cmp(&b.agent_id)
.then_with(|| a.server_name.cmp(&b.server_name))
});
AdminRpcResult::ok(
serde_json::to_value(ChannelsListResponse { entries }).unwrap_or(Value::Null),
)
}
pub fn approve(yaml: &dyn YamlPatcher, params: Value, reload_signal: &dyn Fn()) -> AdminRpcResult {
let input: ChannelsApproveInput = match serde_json::from_value(params) {
Ok(i) => i,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if input.agent_id.is_empty() || input.server_name.is_empty() {
return AdminRpcResult::err(AdminRpcError::InvalidParams(
"agent_id and server_name are required".into(),
));
}
let existing = match yaml.read_agent_field(&input.agent_id, "channels.approved") {
Ok(Some(Value::Array(arr))) => arr,
Ok(_) => Vec::new(),
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!("yaml read: {e}")));
}
};
let already = existing.iter().any(|e| {
e.get("server_name")
.and_then(Value::as_str)
.is_some_and(|n| n == input.server_name)
});
if !already {
let mut updated = existing;
let mut entry_obj = serde_json::Map::new();
entry_obj.insert(
"server_name".into(),
Value::String(input.server_name.clone()),
);
if let Some(allow) = &input.allowlist {
entry_obj.insert(
"allowlist".into(),
Value::Array(allow.iter().map(|i| Value::Number((*i).into())).collect()),
);
}
updated.push(Value::Object(entry_obj));
if let Err(e) =
yaml.upsert_agent_field(&input.agent_id, "channels.approved", Value::Array(updated))
{
return AdminRpcResult::err(AdminRpcError::Internal(format!("yaml write: {e}")));
}
reload_signal();
}
AdminRpcResult::ok(
serde_json::to_value(ChannelEntry {
agent_id: input.agent_id,
server_name: input.server_name,
allowlist: input.allowlist,
})
.unwrap_or(Value::Null),
)
}
pub fn revoke(yaml: &dyn YamlPatcher, params: Value, reload_signal: &dyn Fn()) -> AdminRpcResult {
let p: ChannelsRevokeParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
let existing = match yaml.read_agent_field(&p.agent_id, "channels.approved") {
Ok(Some(Value::Array(arr))) => arr,
Ok(_) => Vec::new(),
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!("yaml read: {e}")));
}
};
let before = existing.len();
let filtered: Vec<Value> = existing
.into_iter()
.filter(|e| {
e.get("server_name")
.and_then(Value::as_str)
.is_none_or(|n| n != p.server_name)
})
.collect();
let removed = filtered.len() < before;
if removed {
if let Err(e) =
yaml.upsert_agent_field(&p.agent_id, "channels.approved", Value::Array(filtered))
{
return AdminRpcResult::err(AdminRpcError::Internal(format!("yaml write: {e}")));
}
reload_signal();
}
AdminRpcResult::ok(
serde_json::to_value(ChannelsRevokeResponse { removed }).unwrap_or(Value::Null),
)
}
pub fn doctor(yaml: &dyn YamlPatcher, params: Value) -> AdminRpcResult {
let p: ChannelsDoctorParams = serde_json::from_value(params).unwrap_or_default();
let agent_ids = match yaml.list_agent_ids() {
Ok(ids) => ids,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!("agents.yaml read: {e}")));
}
};
let mut verdicts: Vec<ChannelDoctorVerdict> = Vec::new();
for aid in &agent_ids {
if let Some(filter_aid) = &p.agent_id {
if aid != filter_aid {
continue;
}
}
if let Some(Value::Array(approved)) = yaml
.read_agent_field(aid, "channels.approved")
.ok()
.flatten()
{
for ch in approved {
let Some(server_name) = ch.get("server_name").and_then(Value::as_str) else {
continue;
};
verdicts.push(ChannelDoctorVerdict {
agent_id: aid.clone(),
server_name: server_name.to_string(),
status: "ok".into(),
message: format!(
"Static check passed for `{server_name}`. Run `nexo channel \
doctor --runtime` for live MCP probe."
),
});
}
}
}
AdminRpcResult::ok(
serde_json::to_value(ChannelDoctorReport { verdicts }).unwrap_or(Value::Null),
)
}
fn parse_channel_entry(agent_id: &str, value: &Value) -> Option<ChannelEntry> {
let server_name = value.get("server_name")?.as_str()?.to_string();
let allowlist = value.get("allowlist").and_then(Value::as_array).map(|arr| {
arr.iter()
.filter_map(|v| v.as_u64().map(|n| n as usize))
.collect::<Vec<_>>()
});
Some(ChannelEntry {
agent_id: agent_id.to_string(),
server_name,
allowlist,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
struct MockYaml {
agents: Mutex<HashMap<String, HashMap<String, Value>>>,
}
impl MockYaml {
fn empty() -> Self {
Self::default()
}
fn with_agent(id: &str) -> Self {
let me = Self::default();
me.agents
.lock()
.unwrap()
.entry(id.to_string())
.or_default()
.insert("model.provider".into(), Value::String("minimax".into()));
me
}
}
impl YamlPatcher for MockYaml {
fn list_agent_ids(&self) -> anyhow::Result<Vec<String>> {
let mut v: Vec<String> = self.agents.lock().unwrap().keys().cloned().collect();
v.sort();
Ok(v)
}
fn read_agent_field(&self, id: &str, dotted: &str) -> anyhow::Result<Option<Value>> {
Ok(self
.agents
.lock()
.unwrap()
.get(id)
.and_then(|m| m.get(dotted).cloned()))
}
fn upsert_agent_field(&self, id: &str, dotted: &str, value: Value) -> anyhow::Result<()> {
self.agents
.lock()
.unwrap()
.entry(id.to_string())
.or_default()
.insert(dotted.to_string(), value);
Ok(())
}
fn remove_agent(&self, id: &str) -> anyhow::Result<()> {
self.agents.lock().unwrap().remove(id);
Ok(())
}
}
#[test]
fn channels_approve_writes_yaml() {
let yaml = MockYaml::with_agent("ana");
let result = approve(
&yaml,
serde_json::json!({
"agent_id": "ana",
"server_name": "plugin:telegram:tg"
}),
&|| {},
);
let entry: ChannelEntry = serde_json::from_value(result.result.unwrap()).unwrap();
assert_eq!(entry.server_name, "plugin:telegram:tg");
let approved = yaml
.read_agent_field("ana", "channels.approved")
.unwrap()
.unwrap();
assert_eq!(approved.as_array().unwrap().len(), 1);
}
#[test]
fn channels_approve_idempotent_skip_duplicate() {
let yaml = MockYaml::with_agent("ana");
let params = serde_json::json!({
"agent_id": "ana",
"server_name": "slack"
});
approve(&yaml, params.clone(), &|| {});
approve(&yaml, params, &|| {});
let approved = yaml
.read_agent_field("ana", "channels.approved")
.unwrap()
.unwrap();
assert_eq!(approved.as_array().unwrap().len(), 1);
}
#[test]
fn channels_revoke_removes_entry() {
let yaml = MockYaml::with_agent("ana");
approve(
&yaml,
serde_json::json!({"agent_id": "ana", "server_name": "slack"}),
&|| {},
);
let result = revoke(
&yaml,
serde_json::json!({"agent_id": "ana", "server_name": "slack"}),
&|| {},
);
let response: ChannelsRevokeResponse =
serde_json::from_value(result.result.unwrap()).unwrap();
assert!(response.removed);
}
#[test]
fn channels_revoke_unknown_idempotent() {
let yaml = MockYaml::with_agent("ana");
let result = revoke(
&yaml,
serde_json::json!({"agent_id": "ana", "server_name": "ghost"}),
&|| {},
);
let response: ChannelsRevokeResponse =
serde_json::from_value(result.result.unwrap()).unwrap();
assert!(!response.removed);
}
#[test]
fn channels_list_filters_by_agent() {
let yaml = MockYaml::default();
yaml.agents
.lock()
.unwrap()
.entry("ana".into())
.or_default()
.insert("model.provider".into(), Value::String("x".into()));
yaml.agents
.lock()
.unwrap()
.entry("bob".into())
.or_default()
.insert("model.provider".into(), Value::String("x".into()));
approve(
&yaml,
serde_json::json!({"agent_id": "ana", "server_name": "slack"}),
&|| {},
);
approve(
&yaml,
serde_json::json!({"agent_id": "bob", "server_name": "telegram"}),
&|| {},
);
let result = list(&yaml, serde_json::json!({ "agent_id": "ana" }));
let response: ChannelsListResponse =
serde_json::from_value(result.result.unwrap()).unwrap();
assert_eq!(response.entries.len(), 1);
assert_eq!(response.entries[0].agent_id, "ana");
}
#[test]
fn channels_doctor_returns_static_verdicts() {
let yaml = MockYaml::with_agent("ana");
approve(
&yaml,
serde_json::json!({"agent_id": "ana", "server_name": "slack"}),
&|| {},
);
let result = doctor(&yaml, Value::Null);
let report: ChannelDoctorReport = serde_json::from_value(result.result.unwrap()).unwrap();
assert_eq!(report.verdicts.len(), 1);
assert_eq!(report.verdicts[0].status, "ok");
assert_eq!(report.verdicts[0].agent_id, "ana");
assert_eq!(report.verdicts[0].server_name, "slack");
}
#[test]
fn channels_approve_with_allowlist() {
let yaml = MockYaml::with_agent("ana");
let result = approve(
&yaml,
serde_json::json!({
"agent_id": "ana",
"server_name": "slack",
"allowlist": [0, 2]
}),
&|| {},
);
let entry: ChannelEntry = serde_json::from_value(result.result.unwrap()).unwrap();
assert_eq!(entry.allowlist, Some(vec![0, 2]));
}
#[test]
fn channels_approve_rejects_empty_fields() {
let yaml = MockYaml::empty();
let result = approve(
&yaml,
serde_json::json!({"agent_id": "", "server_name": "slack"}),
&|| {},
);
assert!(matches!(
result.error.unwrap(),
AdminRpcError::InvalidParams(_)
));
}
}