use std::fmt::Write as _;
use rmcp::schemars::{self, JsonSchema};
use rmcp::service::{ElicitationError, Peer, RoleServer};
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::{debug, warn};
pub(super) const GRANT_RESPOND_TOPIC: &str = "astrid.v1.request.mcp.grant.respond";
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct GrantForm {
pub(super) grant: bool,
}
rmcp::elicit_safe!(GrantForm);
#[derive(Debug, Deserialize)]
pub(super) struct GrantRequest {
request_id: String,
#[serde(default)]
capsule_id: String,
#[serde(default)]
principal: String,
#[serde(default)]
tool_name: String,
}
pub(super) enum GrantSignal {
Absent,
Malformed,
Present(GrantRequest),
}
impl GrantRequest {
pub(super) fn classify(reply: &Value) -> GrantSignal {
let Some(flag) = reply.get("grant_required").filter(|v| !v.is_null()) else {
return GrantSignal::Absent;
};
match serde_json::from_value::<Self>(flag.clone()) {
Ok(req) if req.request_id.is_empty() => {
warn!("MCP shim: grant_required signal missing request_id; treating as malformed");
GrantSignal::Malformed
},
Ok(req) if req.capsule_id.is_empty() => {
warn!("MCP shim: grant_required signal missing capsule_id; treating as malformed");
GrantSignal::Malformed
},
Ok(req) => GrantSignal::Present(req),
Err(e) => {
warn!(error = %e, "MCP shim: malformed grant_required signal");
GrantSignal::Malformed
},
}
}
pub(super) fn respond_body(&self, req_id: &str, decision: &str) -> Value {
json!({
"req_id": req_id,
"request_id": self.request_id,
"decision": decision,
"capsule_id": self.capsule_id,
})
}
fn prompt(&self) -> String {
let mut p = String::from(
"An MCP client invoked a tool from a capsule this identity is not yet allowed to use.",
);
if !self.tool_name.is_empty() {
let _ = write!(p, "\n\nTool requested: {}", self.tool_name);
}
if !self.capsule_id.is_empty() {
let _ = write!(p, "\nCapsule: {}", self.capsule_id);
}
if !self.principal.is_empty() {
let _ = write!(p, "\nIdentity: {}", self.principal);
}
p.push_str("\n\nGrant this capsule to the identity?");
p
}
}
pub(super) fn grant_decision(approved: bool) -> &'static str {
if approved { "approve" } else { "deny" }
}
pub(super) async fn elicit_grant(peer: &Peer<RoleServer>, request: &GrantRequest) -> bool {
if peer.supported_elicitation_modes().is_empty() {
debug!("MCP shim: client did not advertise elicitation; denying grant-on-use");
return false;
}
match peer.elicit::<GrantForm>(request.prompt()).await {
Ok(Some(form)) => {
debug!(
grant = form.grant,
"MCP shim: grant-on-use decision resolved"
);
form.grant
},
Ok(None) => {
warn!("MCP shim: grant elicitation returned no content; denying");
false
},
Err(ElicitationError::UserDeclined | ElicitationError::UserCancelled) => {
debug!("MCP shim: user declined/cancelled grant-on-use; denying");
false
},
Err(ElicitationError::CapabilityNotSupported) => {
debug!("MCP shim: client lacks elicitation capability; denying grant-on-use");
false
},
Err(e) => {
warn!(error = %e, "MCP shim: grant elicitation failed; denying");
false
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_parses_present_signal() {
let reply = json!({
"kind": "tool.call",
"content": [],
"isError": false,
"grant_required": {
"request_id": "grant-123",
"capsule_id": "shell",
"principal": "claude-code",
"tool_name": "shell.exec",
"call_id": "call-abc"
}
});
let GrantSignal::Present(req) = GrantRequest::classify(&reply) else {
panic!("a complete signal should classify as Present");
};
assert_eq!(req.request_id, "grant-123");
assert_eq!(req.capsule_id, "shell");
assert_eq!(req.principal, "claude-code");
assert_eq!(req.tool_name, "shell.exec");
}
#[test]
fn classify_absent_when_field_missing() {
let reply = json!({ "kind": "tool.call", "content": [], "isError": false });
assert!(matches!(
GrantRequest::classify(&reply),
GrantSignal::Absent
));
}
#[test]
fn classify_absent_when_field_is_null() {
let reply = json!({ "grant_required": Value::Null });
assert!(matches!(
GrantRequest::classify(&reply),
GrantSignal::Absent
));
}
#[test]
fn classify_malformed_when_request_id_blank() {
let reply = json!({
"grant_required": {
"request_id": "",
"capsule_id": "shell",
"tool_name": "t"
}
});
assert!(matches!(
GrantRequest::classify(&reply),
GrantSignal::Malformed
));
}
#[test]
fn classify_malformed_when_capsule_id_missing() {
let reply = json!({ "grant_required": { "request_id": "g1" } });
assert!(matches!(
GrantRequest::classify(&reply),
GrantSignal::Malformed
));
}
#[test]
fn respond_body_echoes_grant_id_and_decision() {
let req = GrantRequest {
request_id: "grant-1".into(),
capsule_id: "shell".into(),
principal: "claude-code".into(),
tool_name: "shell.exec".into(),
};
let approve = req.respond_body("proxy-req", "approve");
assert_eq!(approve["req_id"], "proxy-req");
assert_eq!(approve["request_id"], "grant-1");
assert_eq!(approve["decision"], "approve");
let deny = req.respond_body("proxy-req-2", "deny");
assert_eq!(deny["decision"], "deny");
assert_eq!(deny["capsule_id"], "shell");
assert!(deny.get("principal").is_none());
}
#[test]
fn decision_is_binary_approve_or_deny() {
assert_eq!(grant_decision(true), "approve");
assert_eq!(grant_decision(false), "deny");
}
#[test]
fn respond_body_carries_approve_on_accept_deny_otherwise() {
let req = GrantRequest {
request_id: "grant-1".into(),
capsule_id: "shell".into(),
principal: "claude-code".into(),
tool_name: "shell.exec".into(),
};
let on_accept = req.respond_body("p1", grant_decision(true));
assert_eq!(on_accept["decision"], "approve");
assert_eq!(on_accept["request_id"], "grant-1");
let on_decline = req.respond_body("p2", grant_decision(false));
assert_eq!(on_decline["decision"], "deny");
assert_eq!(on_decline["request_id"], "grant-1");
}
#[test]
fn form_schema_is_elicitation_safe() {
let schema = rmcp::model::ElicitationSchema::from_type::<GrantForm>();
assert!(
schema.is_ok(),
"GrantForm must produce a valid elicitation schema: {schema:?}"
);
}
#[test]
fn prompt_includes_display_fields_not_routing_id() {
let req = GrantRequest {
request_id: "secret-grant-id".into(),
capsule_id: "shell".into(),
principal: "claude-code".into(),
tool_name: "shell.exec".into(),
};
let p = req.prompt();
assert!(p.contains("shell.exec"));
assert!(p.contains("shell"));
assert!(p.contains("claude-code"));
assert!(p.contains("Grant this capsule"));
assert!(!p.contains("secret-grant-id"));
}
}