use anyhow::{Result, anyhow};
use async_trait::async_trait;
use std::sync::Arc;
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlElicitation {
pub server_name: String,
pub tool_name: String,
pub key: String,
pub message: String,
pub url: String,
pub host: String,
pub punycode: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElicitationAction {
Accept,
Decline,
Cancel,
}
impl ElicitationAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Accept => "accept",
Self::Decline => "decline",
Self::Cancel => "cancel",
}
}
}
#[async_trait]
pub trait UrlElicitationHandler: Send + Sync {
async fn request_url_consent(&self, elicitation: &UrlElicitation) -> Result<ElicitationAction>;
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"MCP server '{server_name}' needs you to complete an interaction at {host} before tool \
'{tool_name}' can run"
)]
pub struct UrlElicitationPending {
pub server_name: String,
pub tool_name: String,
pub message: String,
pub url: String,
pub host: String,
pub punycode: bool,
pub action: ElicitationAction,
}
pub struct RelayUrlElicitations;
#[async_trait]
impl UrlElicitationHandler for RelayUrlElicitations {
async fn request_url_consent(
&self,
_elicitation: &UrlElicitation,
) -> Result<ElicitationAction> {
Ok(ElicitationAction::Cancel)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantedConsent {
pub host: String,
}
#[async_trait]
pub trait ElicitationConsentStore: Send + Sync {
async fn take_consent(&self, server: &str, tool: &str) -> Result<Option<GrantedConsent>>;
}
pub struct ConsentingUrlElicitations {
store: Arc<dyn ElicitationConsentStore>,
}
impl ConsentingUrlElicitations {
pub fn new(store: Arc<dyn ElicitationConsentStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl UrlElicitationHandler for ConsentingUrlElicitations {
async fn request_url_consent(&self, elicitation: &UrlElicitation) -> Result<ElicitationAction> {
let consent = match self
.store
.take_consent(&elicitation.server_name, &elicitation.tool_name)
.await
{
Ok(consent) => consent,
Err(error) => {
tracing::warn!(
server = %elicitation.server_name,
tool = %elicitation.tool_name,
%error,
"Could not read recorded elicitation consent; asking the user again"
);
None
}
};
let Some(consent) = consent else {
return Ok(ElicitationAction::Cancel);
};
if consent.host != elicitation.host {
tracing::warn!(
server = %elicitation.server_name,
tool = %elicitation.tool_name,
consented_host = %consent.host,
requested_host = %elicitation.host,
"MCP server elicited a different domain than the user consented to; \
asking again"
);
return Ok(ElicitationAction::Cancel);
}
Ok(ElicitationAction::Accept)
}
}
pub const CONSENT_TTL: chrono::Duration = chrono::Duration::minutes(30);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredConsent {
pub server: String,
pub tool: String,
pub host: String,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
impl StoredConsent {
pub fn new(server: &str, tool: &str, host: &str, now: chrono::DateTime<chrono::Utc>) -> Self {
Self {
server: server.to_string(),
tool: tool.to_string(),
host: host.to_string(),
expires_at: now + CONSENT_TTL,
}
}
pub fn grant_for(
&self,
server: &str,
tool: &str,
now: chrono::DateTime<chrono::Utc>,
) -> Option<GrantedConsent> {
if self.server != server || self.tool != tool || self.expires_at <= now {
return None;
}
Some(GrantedConsent {
host: self.host.clone(),
})
}
}
pub fn consent_storage_key(server: &str, tool: &str) -> String {
fn fold(part: &str) -> String {
part.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
format!(
"{}{}/{}",
crate::capabilities::MCP_ELICITATION_CONSENT_KV_PREFIX,
fold(server),
fold(tool)
)
}
pub struct DeclineUrlElicitations;
#[async_trait]
impl UrlElicitationHandler for DeclineUrlElicitations {
async fn request_url_consent(
&self,
_elicitation: &UrlElicitation,
) -> Result<ElicitationAction> {
Ok(ElicitationAction::Decline)
}
}
pub fn validate_elicitation_url(url: &str) -> Result<(String, bool)> {
let parsed = Url::parse(url).map_err(|e| anyhow!("elicitation URL is not a valid URL: {e}"))?;
let host = parsed
.host_str()
.ok_or_else(|| anyhow!("elicitation URL has no host"))?
.to_string();
let loopback = host == "localhost" || host == "127.0.0.1" || host == "[::1]" || host == "::1";
match parsed.scheme() {
"https" => {}
"http" if loopback => {}
scheme => {
return Err(anyhow!(
"elicitation URL must use https (got '{scheme}'); \
http is accepted only on loopback for local development"
));
}
}
let punycode = host.split('.').any(|label| label.starts_with("xn--"));
Ok((host, punycode))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_https_and_reports_host() {
let (host, punycode) = validate_elicitation_url("https://auth.example.com/connect?x=1")
.expect("https accepted");
assert_eq!(host, "auth.example.com");
assert!(!punycode);
}
#[test]
fn accepts_loopback_http_for_local_development() {
assert!(validate_elicitation_url("http://localhost:3000/connect").is_ok());
assert!(validate_elicitation_url("http://127.0.0.1:3000/connect").is_ok());
}
#[test]
fn rejects_non_https_and_dangerous_schemes() {
for url in [
"http://auth.example.com/connect",
"javascript:alert(1)",
"file:///etc/passwd",
"data:text/html,<script>",
"not-a-url",
] {
assert!(
validate_elicitation_url(url).is_err(),
"expected {url} to be rejected"
);
}
}
#[test]
fn flags_punycode_hosts_without_rejecting_them() {
let (host, punycode) =
validate_elicitation_url("https://xn--80ak6aa92e.com/connect").expect("accepted");
assert_eq!(host, "xn--80ak6aa92e.com");
assert!(punycode);
}
struct OneShotConsents {
record: std::sync::Mutex<Option<StoredConsent>>,
}
impl OneShotConsents {
fn holding(record: StoredConsent) -> Self {
Self {
record: std::sync::Mutex::new(Some(record)),
}
}
fn empty() -> Self {
Self {
record: std::sync::Mutex::new(None),
}
}
}
#[async_trait]
impl ElicitationConsentStore for OneShotConsents {
async fn take_consent(&self, server: &str, tool: &str) -> Result<Option<GrantedConsent>> {
let taken = self.record.lock().expect("lock").take();
Ok(taken.and_then(|record| record.grant_for(server, tool, chrono::Utc::now())))
}
}
fn elicitation(host: &str) -> UrlElicitation {
UrlElicitation {
server_name: "billing".to_string(),
tool_name: "charge".to_string(),
key: "pay".to_string(),
message: "Complete the payment".to_string(),
url: format!("https://{host}/pay/1"),
host: host.to_string(),
punycode: false,
}
}
#[tokio::test]
async fn accepts_only_once_a_human_has_consented() {
let handler = ConsentingUrlElicitations::new(Arc::new(OneShotConsents::empty()));
assert_eq!(
handler
.request_url_consent(&elicitation("pay.example.com"))
.await
.expect("handled"),
ElicitationAction::Cancel,
"no recorded consent must never become an implicit accept"
);
let handler = ConsentingUrlElicitations::new(Arc::new(OneShotConsents::holding(
StoredConsent::new("billing", "charge", "pay.example.com", chrono::Utc::now()),
)));
assert_eq!(
handler
.request_url_consent(&elicitation("pay.example.com"))
.await
.expect("handled"),
ElicitationAction::Accept
);
}
#[tokio::test]
async fn refuses_to_reuse_a_consent_for_another_domain() {
let handler = ConsentingUrlElicitations::new(Arc::new(OneShotConsents::holding(
StoredConsent::new("billing", "charge", "pay.example.com", chrono::Utc::now()),
)));
assert_eq!(
handler
.request_url_consent(&elicitation("evil.example"))
.await
.expect("handled"),
ElicitationAction::Cancel
);
}
#[tokio::test]
async fn consent_is_single_use() {
let store = Arc::new(OneShotConsents::holding(StoredConsent::new(
"billing",
"charge",
"pay.example.com",
chrono::Utc::now(),
)));
let handler = ConsentingUrlElicitations::new(store);
let first = handler
.request_url_consent(&elicitation("pay.example.com"))
.await
.expect("handled");
let second = handler
.request_url_consent(&elicitation("pay.example.com"))
.await
.expect("handled");
assert_eq!(first, ElicitationAction::Accept);
assert_eq!(
second,
ElicitationAction::Cancel,
"one consent authorises exactly one accept"
);
}
#[test]
fn expired_or_mismatched_records_grant_nothing() {
let now = chrono::Utc::now();
let record = StoredConsent::new("billing", "charge", "pay.example.com", now);
assert!(record.grant_for("billing", "charge", now).is_some());
assert!(record.grant_for("other", "charge", now).is_none());
assert!(record.grant_for("billing", "refund", now).is_none());
assert!(
record
.grant_for("billing", "charge", now + CONSENT_TTL)
.is_none()
);
}
#[test]
fn consent_keys_stay_readable_and_scoped() {
assert_eq!(
consent_storage_key("billing", "charge"),
"mcp/elicitation-consent/billing/charge"
);
assert_eq!(
consent_storage_key("acme/billing", "charge"),
"mcp/elicitation-consent/acme_billing/charge"
);
}
}