use std::collections::BTreeSet;
use std::time::Duration;
use rmcp::service::{Peer, RoleServer};
use serde_json::{Value, json};
use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::socket_client::SocketClient;
use super::server::{TOOLS_LIST_TOPIC, new_req_id, unwrap_reply_payload, unwrap_reply_payload_ref};
const CAPSULES_LOADED_TOPIC: &str = "astrid.v1.capsules_loaded";
const ENUMERATE_DEADLINE: Duration = Duration::from_secs(55);
pub(super) async fn run(peer: Peer<RoleServer>, principal: String) {
let caller = match astrid_core::PrincipalId::new(&principal) {
Ok(c) => c,
Err(e) => {
warn!(error = %e, %principal, "MCP hot-reload watcher: invalid principal; live tool-reload pushes disabled");
return;
},
};
let session = astrid_core::SessionId::from_uuid(Uuid::new_v4());
let mut watch_client = match crate::socket_client::connect_for_workspace(session, caller, None)
.await
{
Ok(c) => c,
Err(e) => {
warn!(error = %e, "MCP hot-reload watcher: failed to open watch uplink; live tool-reload pushes disabled");
return;
},
};
info!("MCP hot-reload watcher: watching for {CAPSULES_LOADED_TOPIC} broadcasts");
let mut last_known: BTreeSet<String> = match enumerate_on(&mut watch_client, &principal).await {
Ok(names) => names,
Err(e) => {
warn!(error = %e, "MCP hot-reload watcher: baseline seed failed; starting from empty set");
BTreeSet::new()
},
};
loop {
let frame = match watch_client.read_raw_frame().await {
Ok(Some(bytes)) => bytes,
Ok(None) => {
debug!("MCP hot-reload watcher: watch uplink closed; stopping");
return;
},
Err(e) => {
warn!(error = %e, "MCP hot-reload watcher: watch uplink read failed; stopping");
return;
},
};
let Ok(raw) = serde_json::from_slice::<Value>(&frame) else {
continue;
};
if raw.get("topic").and_then(Value::as_str) != Some(CAPSULES_LOADED_TOPIC) {
continue;
}
debug!(
"MCP hot-reload watcher: capsules_loaded received; reading tool surface from payload"
);
let names = tool_names_from_capsules_loaded(unwrap_reply_payload_ref(&raw), &principal);
if names == last_known {
debug!("MCP hot-reload watcher: tool set unchanged; suppressing notification");
continue;
}
if let Err(e) = peer.notify_tool_list_changed().await {
warn!(error = %e, "MCP hot-reload watcher: notify failed (peer closed); stopping");
return;
}
info!(
tools = names.len(),
"MCP hot-reload watcher: tool set changed; pushed tools/list_changed"
);
last_known = names;
}
}
async fn enumerate_on(
client: &mut SocketClient,
principal: &str,
) -> anyhow::Result<BTreeSet<String>> {
let req_id = new_req_id();
let reply_topic = astrid_types::Topic::kernel_response(&req_id);
let body = json!({ "req_id": req_id });
let msg = astrid_types::ipc::IpcMessage::new(
astrid_types::Topic::from_raw(TOOLS_LIST_TOPIC),
astrid_types::ipc::IpcPayload::RawJson(body),
Uuid::nil(),
)
.with_principal(principal.to_string());
client.send_message(msg).await?;
let raw = client
.read_until_topic(&reply_topic, ENUMERATE_DEADLINE)
.await?;
Ok(tool_names_from_reply(&unwrap_reply_payload(&raw)))
}
fn tool_names_from_reply(reply: &Value) -> BTreeSet<String> {
reply
.get("tools")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn tool_names_from_capsules_loaded(payload: &Value, principal: &str) -> BTreeSet<String> {
payload
.get("capsules")
.and_then(Value::as_array)
.map(|caps| {
caps.iter()
.filter(|c| c.get("principal").and_then(Value::as_str) == Some(principal))
.filter_map(|c| {
c.get("meta")
.and_then(|m| m.get("tools"))
.and_then(Value::as_array)
})
.flatten()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_names_are_extracted_as_a_set() {
let reply = json!({ "tools": [
{ "name": "read_file" },
{ "name": "write_file" },
{ "name": "grep_search" },
] });
assert_eq!(
tool_names_from_reply(&reply),
BTreeSet::from([
"grep_search".to_string(),
"read_file".to_string(),
"write_file".to_string(),
])
);
}
#[test]
fn malformed_replies_degrade_to_a_clean_set() {
let mixed = json!({ "tools": [{ "name": "a" }, { "description": "no name" }, {}] });
assert_eq!(
tool_names_from_reply(&mixed),
BTreeSet::from(["a".to_string()])
);
assert!(tool_names_from_reply(&json!({})).is_empty());
assert!(tool_names_from_reply(&json!({ "tools": "nope" })).is_empty());
}
#[test]
fn set_diff_detects_membership_not_order() {
let before = tool_names_from_reply(&json!({ "tools": [{ "name": "a" }, { "name": "b" }] }));
let added = tool_names_from_reply(
&json!({ "tools": [{ "name": "a" }, { "name": "b" }, { "name": "c" }] }),
);
let removed = tool_names_from_reply(&json!({ "tools": [{ "name": "a" }] }));
let reordered =
tool_names_from_reply(&json!({ "tools": [{ "name": "b" }, { "name": "a" }] }));
assert_ne!(before, added, "an added tool must be a detected change");
assert_ne!(before, removed, "a removed tool must be a detected change");
assert_eq!(
before, reordered,
"reordering identical names must stay quiet"
);
}
#[test]
fn capsules_loaded_collects_tools_across_capsules() {
let payload = json!({
"status": "ready",
"capsules": [
{ "principal": "default", "name": "astrid-capsule-system",
"meta": { "tools": [{ "name": "system_status" }] } },
{ "principal": "default", "name": "astrid-capsule-fs",
"meta": { "tools": [{ "name": "read_file" }, { "name": "write_file" }] } },
]
});
assert_eq!(
tool_names_from_capsules_loaded(&payload, "default"),
BTreeSet::from([
"read_file".to_string(),
"system_status".to_string(),
"write_file".to_string(),
])
);
}
#[test]
fn capsules_loaded_filters_by_principal() {
let payload = json!({
"capsules": [
{ "principal": "default", "name": "a", "meta": { "tools": [{ "name": "mine" }] } },
{ "principal": "bob", "name": "b", "meta": { "tools": [{ "name": "theirs" }] } },
]
});
assert_eq!(
tool_names_from_capsules_loaded(&payload, "default"),
BTreeSet::from(["mine".to_string()])
);
}
#[test]
fn capsules_loaded_degrades_on_missing_fields() {
let ragged = json!({
"capsules": [
{ "principal": "default", "name": "a", "meta": null },
{ "principal": "default", "name": "b", "meta": { "version": "1.0.0" } },
{ "principal": "default", "name": "c", "meta": { "tools": [{ "no": "name" }] } },
]
});
assert!(tool_names_from_capsules_loaded(&ragged, "default").is_empty());
assert!(tool_names_from_capsules_loaded(&json!({}), "default").is_empty());
assert!(
tool_names_from_capsules_loaded(&json!({ "capsules": "nope" }), "default").is_empty()
);
}
#[test]
fn capsules_loaded_diff_tracks_install_and_unload() {
let base = tool_names_from_capsules_loaded(
&json!({ "capsules": [
{ "principal": "default", "name": "sys", "meta": { "tools": [{ "name": "system_status" }] } }
] }),
"default",
);
let after_install = tool_names_from_capsules_loaded(
&json!({ "capsules": [
{ "principal": "default", "name": "sys", "meta": { "tools": [{ "name": "system_status" }] } },
{ "principal": "default", "name": "fs", "meta": { "tools": [{ "name": "read_file" }] } }
] }),
"default",
);
assert_ne!(
base, after_install,
"a newly installed capsule's tools must register as a change"
);
assert_ne!(
after_install, base,
"an unloaded capsule's tools vanishing must register as a change"
);
}
}