use std::fmt::Write as _;
use rmcp::schemars::{self, JsonSchema};
use rmcp::service::{ElicitationError, Peer, RoleServer};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::{debug, warn};
pub(super) const APPROVAL_RESPOND_TOPIC: &str = "astrid.v1.request.mcp.approval.respond";
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct ApprovalForm {
pub(super) choice: ApprovalChoice,
}
rmcp::elicit_safe!(ApprovalForm);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[schemars(inline)]
pub(super) enum ApprovalChoice {
ApproveOnce,
ApproveSession,
ApproveAlways,
Deny,
}
impl ApprovalChoice {
fn verb(self) -> &'static str {
match self {
Self::ApproveOnce => "approve",
Self::ApproveSession => "approve_session",
Self::ApproveAlways => "approve_always",
Self::Deny => "deny",
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ApprovalRequest {
request_id: String,
#[serde(default)]
action: String,
#[serde(default)]
resource: String,
#[serde(default)]
reason: String,
tool_name: String,
call_id: String,
}
impl ApprovalRequest {
pub(super) fn from_reply(reply: &Value) -> Option<Self> {
let flag = reply.get("approval_required")?;
match serde_json::from_value::<Self>(flag.clone()) {
Ok(req) if !req.request_id.is_empty() => Some(req),
Ok(_) => {
warn!("MCP shim: approval_required flag missing request_id; ignoring");
None
},
Err(e) => {
warn!(error = %e, "MCP shim: malformed approval_required flag; ignoring");
None
},
}
}
fn respond_body(&self, req_id: &str, decision: &str) -> Value {
json!({
"req_id": req_id,
"request_id": self.request_id,
"decision": decision,
"tool_name": self.tool_name,
"call_id": self.call_id,
})
}
fn prompt(&self) -> String {
let mut p = String::from("A capsule tool is requesting capability approval.");
if !self.action.is_empty() {
let _ = write!(p, "\n\nAction: {}", self.action);
}
if !self.resource.is_empty() {
let _ = write!(p, "\nResource: {}", self.resource);
}
if !self.reason.is_empty() {
let _ = write!(p, "\nReason: {}", self.reason);
}
p.push_str("\n\nApprove this request?");
p
}
}
pub(super) async fn resolve_decision(
peer: &Peer<RoleServer>,
request: &ApprovalRequest,
req_id: &str,
) -> (&'static str, Value) {
let choice = elicit_choice(peer, request).await;
let verb = choice.verb();
debug!(
decision = verb,
tool = %request.tool_name,
"MCP shim: approval decision resolved"
);
(verb, request.respond_body(req_id, verb))
}
async fn elicit_choice(peer: &Peer<RoleServer>, request: &ApprovalRequest) -> ApprovalChoice {
if peer.supported_elicitation_modes().is_empty() {
debug!("MCP shim: client did not advertise elicitation; defaulting approval to deny");
return ApprovalChoice::Deny;
}
match peer.elicit::<ApprovalForm>(request.prompt()).await {
Ok(Some(form)) => form.choice,
Ok(None) => {
warn!("MCP shim: elicitation returned no content; defaulting approval to deny");
ApprovalChoice::Deny
},
Err(ElicitationError::UserDeclined | ElicitationError::UserCancelled) => {
debug!("MCP shim: user declined/cancelled approval; denying");
ApprovalChoice::Deny
},
Err(ElicitationError::CapabilityNotSupported) => {
debug!("MCP shim: client lacks elicitation capability; denying");
ApprovalChoice::Deny
},
Err(e) => {
warn!(error = %e, "MCP shim: elicitation failed; defaulting approval to deny");
ApprovalChoice::Deny
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn choice_maps_to_broker_verbs() {
assert_eq!(ApprovalChoice::ApproveOnce.verb(), "approve");
assert_eq!(ApprovalChoice::ApproveSession.verb(), "approve_session");
assert_eq!(ApprovalChoice::ApproveAlways.verb(), "approve_always");
assert_eq!(ApprovalChoice::Deny.verb(), "deny");
}
#[test]
fn form_schema_is_elicitation_safe() {
let schema = rmcp::model::ElicitationSchema::from_type::<ApprovalForm>();
assert!(
schema.is_ok(),
"ApprovalForm must produce a valid elicitation schema: {schema:?}"
);
}
#[test]
fn from_reply_parses_flag() {
let reply = json!({
"kind": "tool.call",
"content": [],
"isError": false,
"approval_required": {
"request_id": "req-123",
"action": "git push",
"resource": "origin main",
"reason": "needs network",
"tool_name": "shell_exec",
"call_id": "call-abc"
}
});
let req = ApprovalRequest::from_reply(&reply).expect("flag should parse");
assert_eq!(req.request_id, "req-123");
assert_eq!(req.tool_name, "shell_exec");
assert_eq!(req.call_id, "call-abc");
}
#[test]
fn from_reply_none_when_absent() {
let reply = json!({ "kind": "tool.call", "content": [], "isError": false });
assert!(ApprovalRequest::from_reply(&reply).is_none());
}
#[test]
fn from_reply_none_when_request_id_blank() {
let reply = json!({
"approval_required": {
"request_id": "",
"tool_name": "t",
"call_id": "c"
}
});
assert!(ApprovalRequest::from_reply(&reply).is_none());
}
#[test]
fn respond_body_echoes_routing_tokens() {
let req = ApprovalRequest {
request_id: "req-1".into(),
action: String::new(),
resource: String::new(),
reason: String::new(),
tool_name: "tool-x".into(),
call_id: "call-y".into(),
};
let body = req.respond_body("proxy-req", "approve_session");
assert_eq!(body["req_id"], "proxy-req");
assert_eq!(body["request_id"], "req-1");
assert_eq!(body["decision"], "approve_session");
assert_eq!(body["tool_name"], "tool-x");
assert_eq!(body["call_id"], "call-y");
assert!(body.get("reason").is_none());
}
#[test]
fn prompt_renders_only_display_fields() {
let req = ApprovalRequest {
request_id: "secret-routing-id".into(),
action: "git push".into(),
resource: "origin main".into(),
reason: "deploy".into(),
tool_name: "tool-routing".into(),
call_id: "call-routing".into(),
};
let prompt = req.prompt();
assert!(prompt.contains("git push"));
assert!(prompt.contains("origin main"));
assert!(prompt.contains("deploy"));
assert!(!prompt.contains("secret-routing-id"));
assert!(!prompt.contains("tool-routing"));
assert!(!prompt.contains("call-routing"));
}
}