use std::borrow::Cow;
use std::sync::Arc;
use std::time::Duration;
use rmcp::ErrorData as McpError;
use rmcp::ServerHandler;
use rmcp::model::{
CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult,
PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::{RequestContext, RoleServer};
use serde_json::{Map, Value, json};
use tokio::sync::Mutex;
use tracing::{debug, warn};
use uuid::Uuid;
use crate::socket_client::SocketClient;
use super::elicit;
pub(super) const TOOLS_LIST_TOPIC: &str = "astrid.v1.request.mcp.tools.list";
const TOOL_CALL_TOPIC: &str = "astrid.v1.request.mcp.tool.call";
pub(super) const RESPONSE_PREFIX: &str = "astrid.v1.response.";
const REQUEST_DEADLINE: Duration = Duration::from_secs(55);
pub(crate) struct AstridMcpServer {
client: Arc<Mutex<SocketClient>>,
principal: String,
}
impl AstridMcpServer {
pub(crate) fn new(client: Arc<Mutex<SocketClient>>, principal: String) -> Self {
Self { client, principal }
}
async fn round_trip(
&self,
request_topic: &str,
req_id: &str,
body: Value,
) -> Result<Value, McpError> {
let reply_topic = format!("{RESPONSE_PREFIX}{req_id}");
let msg = astrid_types::ipc::IpcMessage::new(
request_topic,
astrid_types::ipc::IpcPayload::RawJson(body),
Uuid::nil(),
)
.with_principal(self.principal.clone());
let mut client = self.client.lock().await;
client.send_message(msg).await.map_err(|e| {
warn!(topic = request_topic, error = %e, "MCP shim: failed to publish broker request");
McpError::internal_error(format!("failed to publish broker request: {e}"), None)
})?;
let raw = client
.read_until_topic(&reply_topic, REQUEST_DEADLINE)
.await
.map_err(|e| {
warn!(topic = %reply_topic, error = %e, "MCP shim: broker reply not received");
McpError::internal_error(format!("broker reply not received: {e}"), None)
})?;
Ok(unwrap_reply_payload(&raw))
}
}
impl ServerHandler for AstridMcpServer {
fn get_info(&self) -> ServerInfo {
let mut capabilities = ServerCapabilities::default();
capabilities.tools = Some(rmcp::model::ToolsCapability {
list_changed: Some(true),
});
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 req_id = new_req_id();
let arguments = request.arguments.map_or(Value::Null, Value::Object);
let body = json!({
"req_id": req_id,
"name": request.name,
"arguments": arguments,
});
let reply = self.round_trip(TOOL_CALL_TOPIC, &req_id, body).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
}
}
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 {
let Some(payload) = raw.get("payload") else {
return Value::Object(Map::new());
};
if payload
.as_object()
.is_some_and(|m| m.contains_key("type") && m.contains_key("value"))
{
return payload
.get("value")
.cloned()
.unwrap_or_else(|| payload.clone());
}
payload.clone()
}
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) -> Content {
if block.get("type").and_then(Value::as_str) == Some("text")
&& let Some(text) = block.get("text").and_then(Value::as_str)
{
return Content::text(text.to_string());
}
debug!("MCP shim: non-text broker content block, serializing to text");
Content::text(block.to_string())
}