use std::fmt::Write as _;
use rmcp::schemars::{self, JsonSchema};
use rmcp::service::{ElicitationError, Peer, RoleServer};
use serde::Deserialize;
use serde_json::Value;
use tracing::{debug, warn};
pub(super) const INGRESS_RESPOND_TOPIC: &str = "astrid.v1.request.mcp.ingress.respond";
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct IngressForm {
pub(super) allow: bool,
}
rmcp::elicit_safe!(IngressForm);
#[derive(Debug, Deserialize)]
pub(super) struct IngressRequest {
#[serde(default)]
source_id: String,
#[serde(default)]
tool_name: String,
}
impl IngressRequest {
pub(super) fn from_reply(reply: &Value) -> Option<Self> {
if reply
.get("ingress_approval_required")
.and_then(Value::as_bool)
!= Some(true)
{
return None;
}
match serde_json::from_value::<Self>(reply.clone()) {
Ok(req) => Some(req),
Err(e) => {
warn!(error = %e, "MCP shim: malformed ingress_approval_required signal; treating as present with no display fields");
Some(IngressRequest {
source_id: String::new(),
tool_name: String::new(),
})
},
}
}
fn prompt(&self) -> String {
let mut p = String::from(
"An MCP client is asking Astrid to run tool calls through this session for the first time.",
);
if !self.tool_name.is_empty() {
let _ = write!(p, "\n\nFirst tool requested: {}", self.tool_name);
}
if !self.source_id.is_empty() {
let _ = write!(p, "\nSession ingress: {}", self.source_id);
}
p.push_str("\n\nAllow Astrid tool calls from this session?");
p
}
}
pub(super) async fn elicit_consent(peer: &Peer<RoleServer>, request: &IngressRequest) -> bool {
if peer.supported_elicitation_modes().is_empty() {
debug!("MCP shim: client did not advertise elicitation; denying ingress consent");
return false;
}
match peer.elicit::<IngressForm>(request.prompt()).await {
Ok(Some(form)) => {
debug!(allow = form.allow, "MCP shim: ingress consent resolved");
form.allow
},
Ok(None) => {
warn!("MCP shim: ingress elicitation returned no content; denying");
false
},
Err(ElicitationError::UserDeclined | ElicitationError::UserCancelled) => {
debug!("MCP shim: user declined/cancelled ingress consent; denying");
false
},
Err(ElicitationError::CapabilityNotSupported) => {
debug!("MCP shim: client lacks elicitation capability; denying ingress consent");
false
},
Err(e) => {
warn!(error = %e, "MCP shim: ingress elicitation failed; denying");
false
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn from_reply_parses_signal() {
let reply = json!({
"kind": "tool.call",
"req_id": "r1",
"ingress_approval_required": true,
"source_id": "abc-123",
"tool_name": "fs.read",
"isError": false,
});
let req = IngressRequest::from_reply(&reply).expect("signal should parse");
assert_eq!(req.source_id, "abc-123");
assert_eq!(req.tool_name, "fs.read");
}
#[test]
fn from_reply_none_when_absent_or_false() {
let none = json!({ "kind": "tool.call", "content": [], "isError": false });
assert!(IngressRequest::from_reply(&none).is_none());
let falsey = json!({ "ingress_approval_required": false });
assert!(IngressRequest::from_reply(&falsey).is_none());
let nonbool = json!({ "ingress_approval_required": "yes" });
assert!(IngressRequest::from_reply(&nonbool).is_none());
}
#[test]
fn from_reply_present_with_missing_display_fields() {
let reply = json!({ "ingress_approval_required": true });
let req = IngressRequest::from_reply(&reply).expect("flag alone is a valid signal");
assert_eq!(req.source_id, "");
assert_eq!(req.tool_name, "");
}
#[test]
fn form_schema_is_elicitation_safe() {
let schema = rmcp::model::ElicitationSchema::from_type::<IngressForm>();
assert!(
schema.is_ok(),
"IngressForm must produce a valid elicitation schema: {schema:?}"
);
}
#[test]
fn prompt_includes_tool_name_when_present() {
let req = IngressRequest {
source_id: "src-1".into(),
tool_name: "shell.exec".into(),
};
let p = req.prompt();
assert!(p.contains("shell.exec"));
assert!(p.contains("Allow Astrid tool calls"));
}
}