use std::borrow::Cow;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use astrid_core::PrincipalId;
use astrid_uplink::socket_client::ReadError;
use rmcp::ErrorData as McpError;
use rmcp::ServerHandler;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ContentBlock, Implementation, ListToolsResult,
PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::{RequestContext, RoleServer};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use tracing::{debug, warn};
use uuid::Uuid;
use crate::socket_client::SocketClient;
use super::elicit;
use super::grant;
use super::ingress;
pub(super) const TOOLS_LIST_TOPIC: &str = "astrid.v1.request.mcp.tools.list";
const TOOL_CALL_TOPIC: &str = "astrid.v1.request.mcp.tool.call";
const REQUEST_DEADLINE: Duration = Duration::from_secs(55);
const MAX_GRANT_RESOLUTIONS: usize = 8;
const MALFORMED_GRANT_MESSAGE: &str =
"Astrid returned a malformed capsule-grant signal; the tool call was dropped.";
const GRANT_DENIED_MESSAGE: &str = "Capsule access was not granted for this tool.";
const GRANT_BOUND_EXCEEDED_MESSAGE: &str = "Astrid needed to resolve more capsule grants than a single tool call allows; the tool call \
was dropped. Re-run the tool to continue granting the remaining capsules.";
pub(crate) struct AstridMcpServer {
client: Arc<Mutex<SocketClient>>,
principal: PrincipalId,
needs_reconnect: AtomicBool,
}
impl AstridMcpServer {
pub(crate) fn new(client: Arc<Mutex<SocketClient>>, principal: PrincipalId) -> Self {
Self {
client,
principal,
needs_reconnect: AtomicBool::new(false),
}
}
async fn reconnect(&self, client: &mut SocketClient) -> Result<(), McpError> {
self.needs_reconnect.store(true, Ordering::Relaxed);
let principal = self.principal.clone();
let result = crate::socket_client::reconnect_for_workspace(
client,
principal.clone(),
None,
|replacement| {
super::require_authenticated_unless_anonymous(
&principal,
replacement.is_authenticated(),
)
},
)
.await;
record_reconnect_outcome(&self.needs_reconnect, &result);
result.map_err(|error| {
McpError::internal_error(format!("reconnect to daemon failed: {error}"), None)
})
}
async fn round_trip(
&self,
request_topic: &str,
req_id: &str,
body: Value,
) -> Result<Value, McpError> {
let reply_topic = astrid_types::Topic::kernel_response(req_id);
let msg = astrid_types::ipc::IpcMessage::new(
astrid_types::Topic::from_raw(request_topic),
astrid_types::ipc::IpcPayload::RawJson(body),
Uuid::nil(),
)
.with_principal(self.principal.to_string());
let mut client = self.client.lock().await;
if self.needs_reconnect.load(Ordering::Relaxed) {
warn!(
topic = request_topic,
"MCP shim: pre-healing a connection flagged dead by a prior round trip"
);
if let Err(e) = self.reconnect(&mut client).await {
warn!(error = %e, "MCP shim: pre-heal reconnect failed");
return Err(e);
}
}
if let Err(first) = client.send_message(msg.clone()).await {
warn!(topic = request_topic, error = %first, "MCP shim: broker publish failed; reconnecting to daemon and retrying once");
self.reconnect(&mut client).await.map_err(|e| {
warn!(error = %e, "MCP shim: reconnect to daemon failed");
e
})?;
client.send_message(msg.clone()).await.map_err(|e| {
self.needs_reconnect.store(true, Ordering::Relaxed);
warn!(topic = request_topic, error = %e, "MCP shim: failed to publish broker request after reconnect");
McpError::internal_error(format!("failed to publish broker request after reconnect: {e}"), None)
})?;
}
match client
.read_until_topic_typed(&reply_topic, REQUEST_DEADLINE)
.await
{
Ok(raw) => Ok(unwrap_reply_payload(&raw)),
Err(ReadError::ConnectionLost(e)) => {
self.needs_reconnect.store(true, Ordering::Relaxed);
if is_request_retriable(request_topic) {
warn!(topic = request_topic, error = %e, "MCP shim: connection lost awaiting reply; reconnecting and retrying idempotent request once");
self.reconnect(&mut client).await.map_err(|re| {
warn!(error = %re, "MCP shim: reconnect for idempotent retry failed");
re
})?;
client.send_message(msg).await.map_err(|se| {
self.needs_reconnect.store(true, Ordering::Relaxed);
warn!(topic = request_topic, error = %se, "MCP shim: re-publish after reconnect failed");
McpError::internal_error(
format!("failed to re-publish broker request after reconnect: {se}"),
None,
)
})?;
let raw = client
.read_until_topic_typed(&reply_topic, REQUEST_DEADLINE)
.await
.map_err(|re| {
if matches!(re, ReadError::ConnectionLost(_)) {
self.needs_reconnect.store(true, Ordering::Relaxed);
}
warn!(topic = %reply_topic, error = %re, "MCP shim: broker reply not received after idempotent retry");
McpError::internal_error(
format!("broker reply not received after retry: {re}"),
None,
)
})?;
Ok(unwrap_reply_payload(&raw))
} else {
warn!(topic = request_topic, error = %e, "MCP shim: connection lost awaiting reply for a non-idempotent request; not auto-retrying (a mutating call may have executed)");
Err(McpError::internal_error(
format!("connection to daemon lost while awaiting reply: {e}"),
None,
))
}
},
Err(ReadError::Timeout) => {
warn!(topic = %reply_topic, "MCP shim: broker reply timed out (connection still live); not reconnecting");
Err(McpError::internal_error(
"broker reply not received before deadline".to_string(),
None,
))
},
}
}
}
fn record_reconnect_outcome<T, E>(needs_reconnect: &AtomicBool, result: &Result<T, E>) {
needs_reconnect.store(result.is_err(), Ordering::Relaxed);
}
fn is_request_retriable(request_topic: &str) -> bool {
#[allow(clippy::match_same_arms)]
match request_topic {
TOOLS_LIST_TOPIC => true,
TOOL_CALL_TOPIC => false,
elicit::APPROVAL_RESPOND_TOPIC
| ingress::INGRESS_RESPOND_TOPIC
| grant::GRANT_RESPOND_TOPIC => false,
_ => false,
}
}
enum GrantStep {
Terminal,
Resolve(grant::GrantRequest),
Fail(&'static str),
}
fn next_grant_step(reply: &Value, grants_resolved: usize) -> GrantStep {
match grant::GrantRequest::classify(reply) {
grant::GrantSignal::Absent => GrantStep::Terminal,
grant::GrantSignal::Malformed => GrantStep::Fail(MALFORMED_GRANT_MESSAGE),
grant::GrantSignal::Present(grant) => {
if grants_resolved >= MAX_GRANT_RESOLUTIONS {
GrantStep::Fail(GRANT_BOUND_EXCEEDED_MESSAGE)
} else {
GrantStep::Resolve(grant)
}
},
}
}
impl ServerHandler for AstridMcpServer {
fn get_info(&self) -> ServerInfo {
let mut capabilities = ServerCapabilities::default();
let mut tools = rmcp::model::ToolsCapability::default();
tools.list_changed = Some(true);
capabilities.tools = Some(tools);
ServerInfo::new(capabilities)
.with_server_info(Implementation::new("astrid", env!("CARGO_PKG_VERSION")))
.with_protocol_version(ProtocolVersion::V_2025_11_25)
.with_instructions("Astrid secure agent runtime — capsule tools bridged over MCP.")
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let req_id = new_req_id();
let body = json!({ "req_id": req_id });
let reply = self.round_trip(TOOLS_LIST_TOPIC, &req_id, body).await?;
let tools = reply
.get("tools")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(tool_from_descriptor).collect())
.unwrap_or_default();
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let arguments = request.arguments.map_or(Value::Null, Value::Object);
let call_body = |req_id: &str| {
json!({
"req_id": req_id,
"name": request.name,
"arguments": arguments,
})
};
let req_id = new_req_id();
let reply = self
.round_trip(TOOL_CALL_TOPIC, &req_id, call_body(&req_id))
.await?;
let reply = if let Some(ingress) = ingress::IngressRequest::from_reply(&reply) {
let granted = self.resolve_ingress(&context.peer, &ingress).await?;
if !granted {
return Ok(CallToolResult::error(vec![ContentBlock::text(
"Astrid tool calls were not authorized for this session.",
)]));
}
let retry_id = new_req_id();
self.round_trip(TOOL_CALL_TOPIC, &retry_id, call_body(&retry_id))
.await?
} else {
reply
};
let mut reply = reply;
let mut grants_resolved = 0usize;
let reply = loop {
match next_grant_step(&reply, grants_resolved) {
GrantStep::Terminal => break reply,
GrantStep::Fail(message) => {
return Ok(CallToolResult::error(vec![ContentBlock::text(message)]));
},
GrantStep::Resolve(grant) => {
let granted = self.resolve_grant(&context.peer, &grant).await?;
if !granted {
return Ok(CallToolResult::error(vec![ContentBlock::text(
GRANT_DENIED_MESSAGE,
)]));
}
grants_resolved = grants_resolved.saturating_add(1);
let retry_id = new_req_id();
reply = self
.round_trip(TOOL_CALL_TOPIC, &retry_id, call_body(&retry_id))
.await?;
},
}
};
let reply = if let Some(approval) = elicit::ApprovalRequest::from_reply(&reply) {
self.resolve_approval(&context.peer, &approval).await?
} else {
reply
};
Ok(call_tool_result_from_reply(&reply))
}
}
impl AstridMcpServer {
async fn resolve_approval(
&self,
peer: &rmcp::service::Peer<RoleServer>,
approval: &elicit::ApprovalRequest,
) -> Result<Value, McpError> {
let respond_req_id = new_req_id();
let (_decision, respond_body) =
elicit::resolve_decision(peer, approval, &respond_req_id).await;
self.round_trip(
elicit::APPROVAL_RESPOND_TOPIC,
&respond_req_id,
respond_body,
)
.await
}
async fn resolve_ingress(
&self,
peer: &rmcp::service::Peer<RoleServer>,
request: &ingress::IngressRequest,
) -> Result<bool, McpError> {
if !ingress::elicit_consent(peer, request).await {
return Ok(false);
}
let respond_req_id = new_req_id();
let respond_body = json!({ "req_id": respond_req_id, "accept": true });
let ack = self
.round_trip(
ingress::INGRESS_RESPOND_TOPIC,
&respond_req_id,
respond_body,
)
.await?;
let granted = ack.get("granted").and_then(Value::as_bool).unwrap_or(false);
if !granted {
warn!("MCP shim: broker did not confirm ingress trust grant; not retrying call");
}
Ok(granted)
}
async fn resolve_grant(
&self,
peer: &rmcp::service::Peer<RoleServer>,
request: &grant::GrantRequest,
) -> Result<bool, McpError> {
let approved = grant::elicit_grant(peer, request).await;
let decision = grant::grant_decision(approved);
let respond_req_id = new_req_id();
let respond_body = request.respond_body(&respond_req_id, decision);
let ack = self
.round_trip(grant::GRANT_RESPOND_TOPIC, &respond_req_id, respond_body)
.await?;
if !approved {
return Ok(false);
}
let granted = ack.get("granted").and_then(Value::as_bool).unwrap_or(false);
if !granted {
warn!("MCP shim: broker did not confirm capsule grant; not retrying call");
}
Ok(granted)
}
}
fn call_tool_result_from_reply(reply: &Value) -> CallToolResult {
let content = reply
.get("content")
.and_then(Value::as_array)
.map(|arr| arr.iter().map(content_from_block).collect())
.unwrap_or_default();
let is_error = reply
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
if is_error {
CallToolResult::error(content)
} else {
CallToolResult::success(content)
}
}
pub(super) fn new_req_id() -> String {
Uuid::new_v4().simple().to_string()
}
pub(super) fn unwrap_reply_payload(raw: &Value) -> Value {
unwrap_reply_payload_ref(raw).clone()
}
pub(super) fn unwrap_reply_payload_ref(raw: &Value) -> &Value {
static NULL_PAYLOAD: Value = Value::Null;
let Some(payload) = raw.get("payload") else {
return &NULL_PAYLOAD;
};
if payload
.as_object()
.is_some_and(|m| m.contains_key("type") && m.contains_key("value"))
{
return payload.get("value").unwrap_or(payload);
}
payload
}
fn tool_from_descriptor(desc: &Value) -> Option<Tool> {
let name = desc.get("name").and_then(Value::as_str)?.to_string();
let input_schema = desc
.get("inputSchema")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let description = desc
.get("description")
.and_then(Value::as_str)
.map(|s| Cow::Owned(s.to_string()));
let mut tool = Tool::new_with_raw(name, description, Arc::new(input_schema));
if let Some(title) = desc.get("title").and_then(Value::as_str) {
tool = tool.with_title(title);
}
Some(tool)
}
fn content_from_block(block: &Value) -> ContentBlock {
if block.get("type").and_then(Value::as_str) == Some("text")
&& let Some(text) = block.get("text").and_then(Value::as_str)
{
return ContentBlock::text(text.to_string());
}
debug!("MCP shim: non-text broker content block, serializing to text");
ContentBlock::text(block.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_reconnect_remains_armed_until_a_checked_replacement_succeeds() {
let needs_reconnect = AtomicBool::new(false);
record_reconnect_outcome(&needs_reconnect, &Err::<(), _>("mismatch"));
assert!(needs_reconnect.load(Ordering::Relaxed));
record_reconnect_outcome(&needs_reconnect, &Ok::<_, &str>(()));
assert!(!needs_reconnect.load(Ordering::Relaxed));
}
#[test]
fn tools_list_is_retriable() {
assert!(is_request_retriable(TOOLS_LIST_TOPIC));
}
#[test]
fn tool_call_is_not_retriable() {
assert!(!is_request_retriable(TOOL_CALL_TOPIC));
}
#[test]
fn respond_front_doors_are_not_retriable() {
assert!(!is_request_retriable(elicit::APPROVAL_RESPOND_TOPIC));
assert!(!is_request_retriable(ingress::INGRESS_RESPOND_TOPIC));
assert!(!is_request_retriable(grant::GRANT_RESPOND_TOPIC));
}
#[test]
fn unknown_topic_is_not_retriable() {
assert!(!is_request_retriable("astrid.v1.request.mcp.something.new"));
assert!(!is_request_retriable(""));
}
fn grant_reply(capsule: &str) -> Value {
json!({
"kind": "tool.call",
"content": [],
"isError": false,
"grant_required": {
"request_id": format!("grant-{capsule}"),
"capsule_id": capsule,
"principal": "claude-code",
"tool_name": "some.tool",
"call_id": "call-1"
}
})
}
fn terminal_reply() -> Value {
json!({ "kind": "tool.call", "content": [], "isError": false })
}
#[test]
fn grant_step_terminal_when_no_signal() {
assert!(matches!(
next_grant_step(&terminal_reply(), 0),
GrantStep::Terminal
));
}
#[test]
fn grant_step_resolves_present_signal_under_bound() {
assert!(matches!(
next_grant_step(&grant_reply("shell"), 0),
GrantStep::Resolve(_)
));
assert!(matches!(
next_grant_step(&grant_reply("shell"), MAX_GRANT_RESOLUTIONS - 1),
GrantStep::Resolve(_)
));
}
#[test]
fn grant_step_fails_when_bound_exceeded() {
let GrantStep::Fail(message) =
next_grant_step(&grant_reply("shell"), MAX_GRANT_RESOLUTIONS)
else {
panic!("a present grant signal at the bound must Fail, not Resolve/Terminal");
};
assert_eq!(message, GRANT_BOUND_EXCEEDED_MESSAGE);
assert!(matches!(
next_grant_step(&grant_reply("shell"), MAX_GRANT_RESOLUTIONS + 5),
GrantStep::Fail(_)
));
}
#[test]
fn grant_step_fails_on_malformed_signal() {
let malformed = json!({ "grant_required": { "request_id": "" } });
let GrantStep::Fail(message) = next_grant_step(&malformed, 0) else {
panic!("a malformed grant signal must Fail");
};
assert_eq!(message, MALFORMED_GRANT_MESSAGE);
}
#[test]
fn grant_step_never_terminal_while_grant_present() {
let reply = grant_reply("shell");
for resolved in 0..=(MAX_GRANT_RESOLUTIONS + 2) {
assert!(
!matches!(next_grant_step(&reply, resolved), GrantStep::Terminal),
"a present grant signal must never classify as Terminal (resolved={resolved})"
);
}
}
}