Skip to main content

adk_server/
ui_types.rs

1//! MCP Apps render-option types and validation.
2//!
3//! Inlined from `adk-ui::interop::mcp_apps` so `adk-server` can validate
4//! UI resource registrations without depending on the full UI toolkit.
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9#[serde(rename_all = "camelCase")]
10pub struct McpUiResourceCsp {
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub connect_domains: Option<Vec<String>>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub resource_domains: Option<Vec<String>>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub frame_domains: Option<Vec<String>>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub base_uri_domains: Option<Vec<String>>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
22pub struct PermissionGrant {}
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25#[serde(rename_all = "camelCase")]
26pub struct McpUiPermissions {
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub camera: Option<PermissionGrant>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub microphone: Option<PermissionGrant>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub geolocation: Option<PermissionGrant>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub clipboard_write: Option<PermissionGrant>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct McpAppsRenderOptions {
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub domain: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub prefers_border: Option<bool>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub csp: Option<McpUiResourceCsp>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub permissions: Option<McpUiPermissions>,
47}
48
49fn is_allowed_domain(domain: &str) -> bool {
50    domain.starts_with("https://")
51        || domain.starts_with("http://localhost")
52        || domain.starts_with("http://127.0.0.1")
53}
54
55fn validate_domain_list(domains: Option<&Vec<String>>, field: &str) -> Result<(), String> {
56    let Some(domains) = domains else {
57        return Ok(());
58    };
59    for domain in domains {
60        if !is_allowed_domain(domain) {
61            return Err(format!(
62                "Invalid mcp_apps option '{}': unsupported domain '{}'",
63                field, domain
64            ));
65        }
66    }
67    Ok(())
68}
69
70pub fn validate_mcp_apps_render_options(options: &McpAppsRenderOptions) -> Result<(), String> {
71    if let Some(domain) = options.domain.as_deref() {
72        if !is_allowed_domain(domain) {
73            return Err(format!(
74                "Invalid mcp_apps option 'domain': unsupported domain '{}'",
75                domain
76            ));
77        }
78    }
79    if let Some(csp) = &options.csp {
80        validate_domain_list(csp.connect_domains.as_ref(), "csp.connect_domains")?;
81        validate_domain_list(csp.resource_domains.as_ref(), "csp.resource_domains")?;
82        validate_domain_list(csp.frame_domains.as_ref(), "csp.frame_domains")?;
83        validate_domain_list(csp.base_uri_domains.as_ref(), "csp.base_uri_domains")?;
84    }
85    Ok(())
86}