1mod auth;
2
3pub use auth::AuthplaneFastMcpTokenVerifier;
4
5use authplane_sdk::{AuthplaneError, build_url_elicitation_payload};
6use fastmcp_rust::{McpError, McpErrorCode};
7
8const URL_ELICITATION_REQUIRED: i32 = -32042;
14
15pub fn to_url_elicitation_required_error(
16 error: AuthplaneError,
17) -> Result<AuthplaneError, McpError> {
18 let AuthplaneError::ConsentRequired(consent) = &error else {
19 return Ok(error);
20 };
21
22 match build_url_elicitation_payload(consent) {
23 Some(payload) => Err(McpError::with_data(
24 McpErrorCode::Custom(URL_ELICITATION_REQUIRED),
25 payload.message,
26 payload.data,
27 )),
28 None => Ok(error),
29 }
30}
31
32pub async fn wrap_tool_with_url_elicitation<F, Fut, T>(handler: F) -> Result<T, McpError>
33where
34 F: FnOnce() -> Fut,
35 Fut: std::future::Future<Output = Result<T, AuthplaneError>>,
36{
37 match handler().await {
38 Ok(value) => Ok(value),
39 Err(error) => match to_url_elicitation_required_error(error) {
40 Ok(unmapped) => Err(McpError::tool_error(unmapped.to_string())),
41 Err(mapped) => Err(mapped),
42 },
43 }
44}
45
46#[cfg(test)]
47mod tests {
48 use authplane_sdk::{AuthplaneError, ConsentRequiredError, parse_token_exchange_error};
49
50 use crate::{to_url_elicitation_required_error, wrap_tool_with_url_elicitation};
51
52 #[test]
53 fn maps_consent_error_to_url_elicitation() {
54 let input = AuthplaneError::from(ConsentRequiredError {
55 message: "Consent required".to_string(),
56 code: "consent_required".to_string(),
57 status_code: Some(400),
58 service_id: "calendar".to_string(),
59 cause_detail: "approval_pending".to_string(),
60 consent_url: Some("https://example.com/consent".to_string()),
61 });
62
63 let mapped = to_url_elicitation_required_error(input);
64 let err = mapped.expect_err("must map to MCP url elicitation");
65
66 assert_eq!(i32::from(err.code), -32042);
67 let data = err.data.expect("data should exist");
68 assert!(data["elicitations"][0]["mode"] == "url");
69 }
70
71 #[tokio::test]
72 async fn wrapper_maps_non_consent_errors_to_tool_error() {
73 let result = wrap_tool_with_url_elicitation(|| async {
74 Err::<u32, _>(AuthplaneError::Auth(authplane_sdk::AuthError {
75 message: "nope".to_string(),
76 code: "invalid_request".to_string(),
77 status_code: Some(400),
78 }))
79 })
80 .await;
81
82 let err = result.expect_err("must map to mcp error");
83 assert_eq!(i32::from(err.code), -32000);
84 }
85
86 #[tokio::test]
87 async fn end_to_end_token_exchange_to_url_elicitation() {
88 let payload = r#"{
89 "error":"interaction_required",
90 "error_description":"User action required",
91 "service":"drive",
92 "consent_url":"https://example.com/consent"
93 }"#;
94
95 let result = wrap_tool_with_url_elicitation(|| async {
96 Err::<(), _>(parse_token_exchange_error(Some(400), payload))
97 })
98 .await;
99
100 let err = result.expect_err("must map to url elicitation");
101 assert_eq!(i32::from(err.code), -32042);
102 assert_eq!(
103 err.data.expect("must include data")["elicitations"][0]["mode"],
104 "url"
105 );
106 }
107
108 #[test]
109 fn non_consent_error_is_passthrough() {
110 let input = AuthplaneError::Auth(authplane_sdk::AuthError {
111 message: "scope denied".to_string(),
112 code: "invalid_scope".to_string(),
113 status_code: Some(400),
114 });
115 let passthrough =
116 to_url_elicitation_required_error(input).expect("non-consent errors must pass through");
117 let AuthplaneError::Auth(auth_error) = passthrough else {
118 panic!("expected auth error passthrough")
119 };
120 assert_eq!(auth_error.code, "invalid_scope");
121 }
122
123 #[test]
124 fn consent_without_url_is_passthrough() {
125 let input = AuthplaneError::from(ConsentRequiredError {
126 message: "Consent required".to_string(),
127 code: "consent_required".to_string(),
128 status_code: Some(400),
129 service_id: "drive".to_string(),
130 cause_detail: "approval_pending".to_string(),
131 consent_url: None,
132 });
133 assert!(to_url_elicitation_required_error(input).is_ok());
134 }
135
136 #[test]
137 fn missing_fields_use_defaults() {
138 let input = AuthplaneError::from(ConsentRequiredError {
139 message: String::new(),
140 code: "consent_required".to_string(),
141 status_code: Some(400),
142 service_id: String::new(),
143 cause_detail: String::new(),
144 consent_url: Some("https://example.com/consent".to_string()),
145 });
146 let mapped = to_url_elicitation_required_error(input).expect_err("must map");
147 let data = mapped.data.expect("must include data");
148 let message = data["elicitations"][0]["message"]
149 .as_str()
150 .expect("message string");
151 assert!(message.contains("unknown_service"));
152 assert!(message.contains("Consent is required to proceed"));
153 }
154}