1use serde::{Deserialize, Serialize};
47
48use super::{Extension, namespaces};
49
50pub const APPS_VERSION: &str = "0.1.0";
52
53pub const MIME_TYPE_HTML_MCP: &str = "text/html;profile=mcp-app";
58
59pub const MIME_TYPE_HTML: &str = "text/html";
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "camelCase")]
68pub struct UiResource {
69 pub uri: String,
71
72 pub name: String,
74
75 #[serde(default = "default_mime_type")]
77 pub mime_type: String,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub description: Option<String>,
82}
83
84fn default_mime_type() -> String {
85 MIME_TYPE_HTML.to_string()
86}
87
88impl UiResource {
89 #[must_use]
105 pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
106 Self {
107 uri: uri.into(),
108 name: name.into(),
109 mime_type: MIME_TYPE_HTML.to_string(),
110 description: None,
111 }
112 }
113
114 #[must_use]
120 pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
121 self.mime_type = mime_type.into();
122 self
123 }
124
125 #[must_use]
131 pub fn with_description(mut self, description: impl Into<String>) -> Self {
132 self.description = Some(description.into());
133 self
134 }
135
136 #[must_use]
138 pub fn is_mcp_html(&self) -> bool {
139 self.mime_type == MIME_TYPE_HTML_MCP
140 }
141
142 #[must_use]
146 pub fn has_valid_scheme(&self) -> bool {
147 self.uri.starts_with("ui://")
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "camelCase")]
172pub struct ToolUiMeta {
173 #[serde(rename = "ui/resourceUri")]
175 pub resource_uri: String,
176
177 #[serde(rename = "ui/displayHints", skip_serializing_if = "Option::is_none")]
179 pub display_hints: Option<UiDisplayHints>,
180}
181
182impl ToolUiMeta {
183 #[must_use]
197 pub fn new(resource_uri: impl Into<String>) -> Self {
198 Self {
199 resource_uri: resource_uri.into(),
200 display_hints: None,
201 }
202 }
203
204 #[must_use]
210 pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
211 self.display_hints = Some(hints);
212 self
213 }
214
215 #[must_use]
217 pub fn to_meta_value(&self) -> serde_json::Value {
218 serde_json::to_value(self).unwrap_or_default()
219 }
220}
221
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(rename_all = "camelCase")]
227pub struct UiDisplayHints {
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub width: Option<u32>,
231
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub height: Option<u32>,
235
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub resizable: Option<bool>,
239
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub mode: Option<UiDisplayMode>,
243}
244
245impl UiDisplayHints {
246 #[must_use]
248 pub fn new() -> Self {
249 Self::default()
250 }
251
252 #[must_use]
254 pub fn with_size(mut self, width: u32, height: u32) -> Self {
255 self.width = Some(width);
256 self.height = Some(height);
257 self
258 }
259
260 #[must_use]
262 pub fn with_resizable(mut self, resizable: bool) -> Self {
263 self.resizable = Some(resizable);
264 self
265 }
266
267 #[must_use]
269 pub fn with_mode(mut self, mode: UiDisplayMode) -> Self {
270 self.mode = Some(mode);
271 self
272 }
273}
274
275#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
277#[serde(rename_all = "lowercase")]
278pub enum UiDisplayMode {
279 #[default]
281 Inline,
282
283 Modal,
285
286 Sidebar,
288
289 Fullscreen,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298#[non_exhaustive]
299pub struct AppsConfig {
300 #[serde(default = "default_true")]
302 pub ui_resources: bool,
303
304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub sandbox_permissions: Vec<String>,
307
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub max_content_size: Option<usize>,
311
312 #[serde(
314 default = "default_allowed_mime_types",
315 skip_serializing_if = "Vec::is_empty"
316 )]
317 pub allowed_mime_types: Vec<String>,
318}
319
320fn default_true() -> bool {
321 true
322}
323
324fn default_allowed_mime_types() -> Vec<String> {
325 vec![MIME_TYPE_HTML.to_string(), MIME_TYPE_HTML_MCP.to_string()]
326}
327
328impl AppsConfig {
329 #[must_use]
331 pub fn new() -> Self {
332 Self::default()
333 }
334
335 #[must_use]
337 pub const fn with_ui_resources(mut self, supported: bool) -> Self {
338 self.ui_resources = supported;
339 self
340 }
341
342 #[must_use]
360 pub fn with_sandbox_permissions(mut self, permissions: Vec<String>) -> Self {
361 self.sandbox_permissions = permissions;
362 self
363 }
364
365 #[must_use]
371 pub fn with_max_content_size(mut self, size: usize) -> Self {
372 self.max_content_size = Some(size);
373 self
374 }
375
376 #[must_use]
382 pub fn with_allowed_mime_types(mut self, types: Vec<String>) -> Self {
383 self.allowed_mime_types = types;
384 self
385 }
386
387 #[must_use]
389 pub fn into_extension(self) -> Extension {
390 Extension::new(namespaces::MCP_APPS)
391 .with_version(APPS_VERSION)
392 .with_description("MCP Apps Extension for interactive UIs")
393 .with_config(serde_json::to_value(self).unwrap_or_default())
394 }
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct UiContent {
403 pub html: String,
405
406 #[serde(skip_serializing_if = "Option::is_none")]
408 pub styles: Option<String>,
409
410 #[serde(skip_serializing_if = "Option::is_none")]
412 pub scripts: Option<String>,
413}
414
415impl UiContent {
416 #[must_use]
422 pub fn new(html: impl Into<String>) -> Self {
423 Self {
424 html: html.into(),
425 styles: None,
426 scripts: None,
427 }
428 }
429
430 #[must_use]
432 pub fn with_styles(mut self, styles: impl Into<String>) -> Self {
433 self.styles = Some(styles.into());
434 self
435 }
436
437 #[must_use]
439 pub fn with_scripts(mut self, scripts: impl Into<String>) -> Self {
440 self.scripts = Some(scripts.into());
441 self
442 }
443
444 #[must_use]
448 pub fn render(&self) -> String {
449 let mut doc = String::new();
450
451 doc.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
452 doc.push_str("<meta charset=\"utf-8\">\n");
453 doc.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
454
455 if let Some(ref styles) = self.styles {
456 doc.push_str("<style>\n");
457 doc.push_str(styles);
458 doc.push_str("\n</style>\n");
459 }
460
461 doc.push_str("</head>\n<body>\n");
462 doc.push_str(&self.html);
463
464 if let Some(ref scripts) = self.scripts {
465 doc.push_str("\n<script>\n");
466 doc.push_str(scripts);
467 doc.push_str("\n</script>\n");
468 }
469
470 doc.push_str("\n</body>\n</html>");
471 doc
472 }
473}
474
475#[derive(Debug, Clone)]
479pub struct UiToolBuilder {
480 name: String,
481 description: Option<String>,
482 ui_resource_uri: String,
483 display_hints: Option<UiDisplayHints>,
484 fallback_text: Option<String>,
485}
486
487impl UiToolBuilder {
488 #[must_use]
495 pub fn new(name: impl Into<String>, ui_resource_uri: impl Into<String>) -> Self {
496 Self {
497 name: name.into(),
498 description: None,
499 ui_resource_uri: ui_resource_uri.into(),
500 display_hints: None,
501 fallback_text: None,
502 }
503 }
504
505 #[must_use]
507 pub fn with_description(mut self, description: impl Into<String>) -> Self {
508 self.description = Some(description.into());
509 self
510 }
511
512 #[must_use]
514 pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
515 self.display_hints = Some(hints);
516 self
517 }
518
519 #[must_use]
521 pub fn with_fallback_text(mut self, text: impl Into<String>) -> Self {
522 self.fallback_text = Some(text.into());
523 self
524 }
525
526 #[must_use]
528 pub fn build_meta(&self) -> ToolUiMeta {
529 let mut meta = ToolUiMeta::new(&self.ui_resource_uri);
530 if let Some(ref hints) = self.display_hints {
531 meta = meta.with_display_hints(hints.clone());
532 }
533 meta
534 }
535
536 #[must_use]
538 pub fn name(&self) -> &str {
539 &self.name
540 }
541
542 #[must_use]
544 pub fn description(&self) -> Option<&str> {
545 self.description.as_deref()
546 }
547
548 #[must_use]
550 pub fn fallback_text(&self) -> Option<&str> {
551 self.fallback_text.as_deref()
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn test_ui_resource() {
561 let ui = UiResource::new("ui://charts/bar", "Bar Chart")
562 .with_description("A bar chart")
563 .with_mime_type(MIME_TYPE_HTML_MCP);
564
565 assert_eq!(ui.uri, "ui://charts/bar");
566 assert_eq!(ui.name, "Bar Chart");
567 assert!(ui.is_mcp_html());
568 assert!(ui.has_valid_scheme());
569 }
570
571 #[test]
572 fn test_tool_ui_meta() {
573 let meta = ToolUiMeta::new("ui://widgets/counter")
574 .with_display_hints(UiDisplayHints::new().with_size(400, 300));
575
576 let value = meta.to_meta_value();
577 assert!(value.get("ui/resourceUri").is_some());
578 assert!(value.get("ui/displayHints").is_some());
579 }
580
581 #[test]
582 fn test_apps_config() {
583 let config = AppsConfig::new()
584 .with_sandbox_permissions(vec!["allow-scripts".to_string()])
585 .with_max_content_size(1024 * 1024);
586
587 let ext = config.into_extension();
588 assert_eq!(ext.name, namespaces::MCP_APPS);
589 assert_eq!(ext.version, Some(APPS_VERSION.to_string()));
590 }
591
592 #[test]
593 fn test_ui_content_render() {
594 let content = UiContent::new("<div>Hello</div>")
595 .with_styles("body { margin: 0; }")
596 .with_scripts("console.log('loaded');");
597
598 let html = content.render();
599 assert!(html.contains("<!DOCTYPE html>"));
600 assert!(html.contains("<div>Hello</div>"));
601 assert!(html.contains("body { margin: 0; }"));
602 assert!(html.contains("console.log('loaded');"));
603 }
604
605 #[test]
606 fn test_ui_tool_builder() {
607 let builder = UiToolBuilder::new("chart", "ui://charts/bar")
608 .with_description("Display a bar chart")
609 .with_display_hints(UiDisplayHints::new().with_mode(UiDisplayMode::Modal))
610 .with_fallback_text("Chart displayed");
611
612 assert_eq!(builder.name(), "chart");
613 assert_eq!(builder.description(), Some("Display a bar chart"));
614
615 let meta = builder.build_meta();
616 assert_eq!(meta.resource_uri, "ui://charts/bar");
617 }
618
619 #[test]
620 fn test_serialization() {
621 let meta = ToolUiMeta::new("ui://test");
622 let json = serde_json::to_string(&meta).unwrap();
623 assert!(json.contains("ui/resourceUri"));
624
625 let parsed: ToolUiMeta = serde_json::from_str(&json).unwrap();
626 assert_eq!(parsed.resource_uri, "ui://test");
627 }
628}