Skip to main content

authplane_fastmcp/
lib.rs

1mod auth;
2
3pub use auth::AuthplaneFastMcpTokenVerifier;
4
5use authplane_sdk::{AuthplaneError, build_url_elicitation_payload};
6use fastmcp_rust::{McpError, McpErrorCode};
7
8/// MCP wire constant for the "URL elicitation required" error code.
9/// Defined here because `fastmcp_rust` exposes the generic
10/// `McpErrorCode::Custom(i32)` constructor but does not name this
11/// specific code. The value (-32042) matches the MCP authorization
12/// spec and `rmcp::model::ErrorCode::URL_ELICITATION_REQUIRED`.
13const 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}