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};
7use serde_json::Value;
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10#[serde(rename_all = "camelCase")]
11pub struct McpUiResourceCsp {
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub connect_domains: Option<Vec<String>>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub resource_domains: Option<Vec<String>>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub frame_domains: Option<Vec<String>>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub base_uri_domains: Option<Vec<String>>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
23pub struct PermissionGrant {}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
26pub struct CapabilityGrant {}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29#[serde(rename_all = "camelCase")]
30pub struct McpUiPermissions {
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub camera: Option<PermissionGrant>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub microphone: Option<PermissionGrant>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub geolocation: Option<PermissionGrant>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub clipboard_write: Option<PermissionGrant>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42#[serde(rename_all = "camelCase")]
43pub struct McpUiContentCapabilities {
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub text: Option<CapabilityGrant>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub structured_content: Option<CapabilityGrant>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub resource: Option<CapabilityGrant>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub resource_link: Option<CapabilityGrant>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub image: Option<CapabilityGrant>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub audio: Option<CapabilityGrant>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59#[serde(rename_all = "camelCase")]
60pub struct McpUiListChangedCapability {
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub list_changed: Option<bool>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66#[serde(rename_all = "camelCase")]
67pub struct McpUiSandboxCapabilities {
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub csp: Option<McpUiResourceCsp>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub permissions: Option<McpUiPermissions>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75#[serde(rename_all = "camelCase")]
76pub struct McpUiHostCapabilities {
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub message: Option<McpUiContentCapabilities>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub update_model_context: Option<McpUiContentCapabilities>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub open_links: Option<CapabilityGrant>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub download_file: Option<CapabilityGrant>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub logging: Option<CapabilityGrant>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub server_resources: Option<McpUiListChangedCapability>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub server_tools: Option<McpUiListChangedCapability>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub sandbox: Option<McpUiSandboxCapabilities>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub experimental: Option<Value>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, Default)]
98#[serde(rename_all = "camelCase")]
99pub struct McpUiHostInfo {
100    pub name: String,
101    pub version: String,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub title: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub description: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub website_url: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct McpUiBridgeSnapshot {
113    pub protocol_version: String,
114    pub initialized: bool,
115    pub host_info: McpUiHostInfo,
116    pub host_capabilities: McpUiHostCapabilities,
117    pub host_context: Value,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub app_capabilities: Option<Value>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub app_info: Option<Value>,
122}
123
124impl McpUiBridgeSnapshot {
125    pub fn new(
126        protocol_version: impl Into<String>,
127        initialized: bool,
128        host_info: McpUiHostInfo,
129        host_capabilities: McpUiHostCapabilities,
130        host_context: Value,
131    ) -> Self {
132        Self {
133            protocol_version: protocol_version.into(),
134            initialized,
135            host_info,
136            host_capabilities,
137            host_context,
138            app_capabilities: None,
139            app_info: None,
140        }
141    }
142
143    pub fn with_app_metadata(mut self, app_info: Value, app_capabilities: Value) -> Self {
144        self.app_info = Some(app_info);
145        self.app_capabilities = Some(app_capabilities);
146        self
147    }
148
149    pub fn with_optional_app_metadata(
150        mut self,
151        app_info: Option<Value>,
152        app_capabilities: Option<Value>,
153    ) -> Self {
154        self.app_info = app_info;
155        self.app_capabilities = app_capabilities;
156        self
157    }
158
159    pub fn into_tool_result_bridge(self) -> McpUiToolResultBridge {
160        McpUiToolResultBridge {
161            protocol_version: Some(self.protocol_version),
162            structured_content: None,
163            host_info: Some(self.host_info),
164            host_capabilities: Some(self.host_capabilities),
165            host_context: Some(self.host_context),
166            app_capabilities: self.app_capabilities,
167            app_info: self.app_info,
168            initialized: Some(self.initialized),
169        }
170    }
171
172    pub fn build_tool_result(
173        self,
174        structured_content: Option<Value>,
175        resource_uri: Option<String>,
176        html: Option<String>,
177    ) -> McpUiToolResult {
178        let mut bridge = self.into_tool_result_bridge();
179        if let Some(structured_content) = structured_content {
180            bridge = bridge.with_structured_content(structured_content);
181        }
182
183        let mut result = McpUiToolResult::default().with_bridge(bridge);
184        if let Some(resource_uri) = resource_uri {
185            result = result.with_resource_uri(resource_uri);
186        }
187        if let Some(html) = html {
188            result = result.with_html(html);
189        }
190        result
191    }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, Default)]
195#[serde(rename_all = "camelCase")]
196pub struct McpUiToolResultBridge {
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub protocol_version: Option<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub structured_content: Option<Value>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub host_info: Option<McpUiHostInfo>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub host_capabilities: Option<McpUiHostCapabilities>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub host_context: Option<Value>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub app_capabilities: Option<Value>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub app_info: Option<Value>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub initialized: Option<bool>,
213}
214
215impl McpUiToolResultBridge {
216    pub fn from_host_bridge(
217        protocol_version: impl Into<String>,
218        initialized: bool,
219        host_info: McpUiHostInfo,
220        host_capabilities: McpUiHostCapabilities,
221        host_context: Value,
222    ) -> Self {
223        McpUiBridgeSnapshot::new(
224            protocol_version,
225            initialized,
226            host_info,
227            host_capabilities,
228            host_context,
229        )
230        .into_tool_result_bridge()
231    }
232
233    pub fn with_structured_content(mut self, structured_content: Value) -> Self {
234        self.structured_content = Some(structured_content);
235        self
236    }
237
238    pub fn with_app_metadata(mut self, app_info: Value, app_capabilities: Value) -> Self {
239        self.app_info = Some(app_info);
240        self.app_capabilities = Some(app_capabilities);
241        self
242    }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, Default)]
246#[serde(rename_all = "camelCase")]
247pub struct McpUiToolResult {
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub resource_uri: Option<String>,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub html: Option<String>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub bridge: Option<McpUiToolResultBridge>,
254}
255
256impl McpUiToolResult {
257    pub fn with_resource_uri(mut self, resource_uri: impl Into<String>) -> Self {
258        self.resource_uri = Some(resource_uri.into());
259        self
260    }
261
262    pub fn with_html(mut self, html: impl Into<String>) -> Self {
263        self.html = Some(html.into());
264        self
265    }
266
267    pub fn with_bridge(mut self, bridge: McpUiToolResultBridge) -> Self {
268        self.bridge = Some(bridge);
269        self
270    }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, Default)]
274pub struct McpAppsRenderOptions {
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub domain: Option<String>,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub prefers_border: Option<bool>,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub csp: Option<McpUiResourceCsp>,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub permissions: Option<McpUiPermissions>,
283}
284
285fn is_allowed_domain(domain: &str) -> bool {
286    domain.starts_with("https://")
287        || domain.starts_with("http://localhost")
288        || domain.starts_with("http://127.0.0.1")
289}
290
291fn validate_domain_list(domains: Option<&Vec<String>>, field: &str) -> Result<(), String> {
292    let Some(domains) = domains else {
293        return Ok(());
294    };
295    for domain in domains {
296        if !is_allowed_domain(domain) {
297            return Err(format!(
298                "Invalid mcp_apps option '{}': unsupported domain '{}'",
299                field, domain
300            ));
301        }
302    }
303    Ok(())
304}
305
306pub fn validate_mcp_apps_render_options(options: &McpAppsRenderOptions) -> Result<(), String> {
307    if let Some(domain) = options.domain.as_deref() {
308        if !is_allowed_domain(domain) {
309            return Err(format!(
310                "Invalid mcp_apps option 'domain': unsupported domain '{}'",
311                domain
312            ));
313        }
314    }
315    if let Some(csp) = &options.csp {
316        validate_domain_list(csp.connect_domains.as_ref(), "csp.connect_domains")?;
317        validate_domain_list(csp.resource_domains.as_ref(), "csp.resource_domains")?;
318        validate_domain_list(csp.frame_domains.as_ref(), "csp.frame_domains")?;
319        validate_domain_list(csp.base_uri_domains.as_ref(), "csp.base_uri_domains")?;
320    }
321    Ok(())
322}
323
324pub fn default_mcp_ui_host_info() -> McpUiHostInfo {
325    McpUiHostInfo {
326        name: "adk-server".to_string(),
327        version: env!("CARGO_PKG_VERSION").to_string(),
328        title: Some("ADK Server".to_string()),
329        description: Some(
330            "Additive HTTP bridge for MCP Apps initialize/message/model-context flows plus lifecycle notifications.".to_string(),
331        ),
332        website_url: None,
333    }
334}
335
336pub fn default_mcp_ui_host_capabilities() -> McpUiHostCapabilities {
337    let supported_content = McpUiContentCapabilities {
338        text: Some(CapabilityGrant::default()),
339        structured_content: Some(CapabilityGrant::default()),
340        resource: Some(CapabilityGrant::default()),
341        resource_link: Some(CapabilityGrant::default()),
342        image: None,
343        audio: None,
344    };
345
346    McpUiHostCapabilities {
347        message: Some(supported_content.clone()),
348        update_model_context: Some(supported_content),
349        open_links: None,
350        download_file: None,
351        logging: None,
352        server_resources: Some(McpUiListChangedCapability { list_changed: Some(true) }),
353        server_tools: Some(McpUiListChangedCapability { list_changed: Some(true) }),
354        sandbox: Some(McpUiSandboxCapabilities::default()),
355        experimental: None,
356    }
357}