1use 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}