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+mcp";
55
56pub const MIME_TYPE_HTML: &str = "text/html";
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "camelCase")]
65pub struct UiResource {
66 pub uri: String,
68
69 pub name: String,
71
72 #[serde(default = "default_mime_type")]
74 pub mime_type: String,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub description: Option<String>,
79}
80
81fn default_mime_type() -> String {
82 MIME_TYPE_HTML.to_string()
83}
84
85impl UiResource {
86 #[must_use]
102 pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
103 Self {
104 uri: uri.into(),
105 name: name.into(),
106 mime_type: MIME_TYPE_HTML.to_string(),
107 description: None,
108 }
109 }
110
111 #[must_use]
117 pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
118 self.mime_type = mime_type.into();
119 self
120 }
121
122 #[must_use]
128 pub fn with_description(mut self, description: impl Into<String>) -> Self {
129 self.description = Some(description.into());
130 self
131 }
132
133 #[must_use]
135 pub fn is_mcp_html(&self) -> bool {
136 self.mime_type == MIME_TYPE_HTML_MCP
137 }
138
139 #[must_use]
143 pub fn has_valid_scheme(&self) -> bool {
144 self.uri.starts_with("ui://")
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "camelCase")]
154pub struct ToolUiMeta {
155 #[serde(rename = "ui/resourceUri")]
157 pub resource_uri: String,
158
159 #[serde(rename = "ui/displayHints", skip_serializing_if = "Option::is_none")]
161 pub display_hints: Option<UiDisplayHints>,
162}
163
164impl ToolUiMeta {
165 #[must_use]
179 pub fn new(resource_uri: impl Into<String>) -> Self {
180 Self {
181 resource_uri: resource_uri.into(),
182 display_hints: None,
183 }
184 }
185
186 #[must_use]
192 pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
193 self.display_hints = Some(hints);
194 self
195 }
196
197 #[must_use]
199 pub fn to_meta_value(&self) -> serde_json::Value {
200 serde_json::to_value(self).unwrap_or_default()
201 }
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
208#[serde(rename_all = "camelCase")]
209pub struct UiDisplayHints {
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub width: Option<u32>,
213
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub height: Option<u32>,
217
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub resizable: Option<bool>,
221
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub mode: Option<UiDisplayMode>,
225}
226
227impl UiDisplayHints {
228 #[must_use]
230 pub fn new() -> Self {
231 Self::default()
232 }
233
234 #[must_use]
236 pub fn with_size(mut self, width: u32, height: u32) -> Self {
237 self.width = Some(width);
238 self.height = Some(height);
239 self
240 }
241
242 #[must_use]
244 pub fn with_resizable(mut self, resizable: bool) -> Self {
245 self.resizable = Some(resizable);
246 self
247 }
248
249 #[must_use]
251 pub fn with_mode(mut self, mode: UiDisplayMode) -> Self {
252 self.mode = Some(mode);
253 self
254 }
255}
256
257#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
259#[serde(rename_all = "lowercase")]
260pub enum UiDisplayMode {
261 #[default]
263 Inline,
264
265 Modal,
267
268 Sidebar,
270
271 Fullscreen,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct AppsConfig {
281 #[serde(default = "default_true")]
283 pub ui_resources: bool,
284
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 pub sandbox_permissions: Vec<String>,
288
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub max_content_size: Option<usize>,
292
293 #[serde(
295 default = "default_allowed_mime_types",
296 skip_serializing_if = "Vec::is_empty"
297 )]
298 pub allowed_mime_types: Vec<String>,
299}
300
301fn default_true() -> bool {
302 true
303}
304
305fn default_allowed_mime_types() -> Vec<String> {
306 vec![MIME_TYPE_HTML.to_string(), MIME_TYPE_HTML_MCP.to_string()]
307}
308
309impl AppsConfig {
310 #[must_use]
312 pub fn new() -> Self {
313 Self::default()
314 }
315
316 #[must_use]
334 pub fn with_sandbox_permissions(mut self, permissions: Vec<String>) -> Self {
335 self.sandbox_permissions = permissions;
336 self
337 }
338
339 #[must_use]
345 pub fn with_max_content_size(mut self, size: usize) -> Self {
346 self.max_content_size = Some(size);
347 self
348 }
349
350 #[must_use]
356 pub fn with_allowed_mime_types(mut self, types: Vec<String>) -> Self {
357 self.allowed_mime_types = types;
358 self
359 }
360
361 #[must_use]
363 pub fn into_extension(self) -> Extension {
364 Extension::new(namespaces::MCP_APPS)
365 .with_version(APPS_VERSION)
366 .with_description("MCP Apps Extension for interactive UIs")
367 .with_config(serde_json::to_value(self).unwrap_or_default())
368 }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase")]
376pub struct UiContent {
377 pub html: String,
379
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub styles: Option<String>,
383
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub scripts: Option<String>,
387}
388
389impl UiContent {
390 #[must_use]
396 pub fn new(html: impl Into<String>) -> Self {
397 Self {
398 html: html.into(),
399 styles: None,
400 scripts: None,
401 }
402 }
403
404 #[must_use]
406 pub fn with_styles(mut self, styles: impl Into<String>) -> Self {
407 self.styles = Some(styles.into());
408 self
409 }
410
411 #[must_use]
413 pub fn with_scripts(mut self, scripts: impl Into<String>) -> Self {
414 self.scripts = Some(scripts.into());
415 self
416 }
417
418 #[must_use]
422 pub fn render(&self) -> String {
423 let mut doc = String::new();
424
425 doc.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
426 doc.push_str("<meta charset=\"utf-8\">\n");
427 doc.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
428
429 if let Some(ref styles) = self.styles {
430 doc.push_str("<style>\n");
431 doc.push_str(styles);
432 doc.push_str("\n</style>\n");
433 }
434
435 doc.push_str("</head>\n<body>\n");
436 doc.push_str(&self.html);
437
438 if let Some(ref scripts) = self.scripts {
439 doc.push_str("\n<script>\n");
440 doc.push_str(scripts);
441 doc.push_str("\n</script>\n");
442 }
443
444 doc.push_str("\n</body>\n</html>");
445 doc
446 }
447}
448
449#[derive(Debug, Clone)]
453pub struct UiToolBuilder {
454 name: String,
455 description: Option<String>,
456 ui_resource_uri: String,
457 display_hints: Option<UiDisplayHints>,
458 fallback_text: Option<String>,
459}
460
461impl UiToolBuilder {
462 #[must_use]
469 pub fn new(name: impl Into<String>, ui_resource_uri: impl Into<String>) -> Self {
470 Self {
471 name: name.into(),
472 description: None,
473 ui_resource_uri: ui_resource_uri.into(),
474 display_hints: None,
475 fallback_text: None,
476 }
477 }
478
479 #[must_use]
481 pub fn with_description(mut self, description: impl Into<String>) -> Self {
482 self.description = Some(description.into());
483 self
484 }
485
486 #[must_use]
488 pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
489 self.display_hints = Some(hints);
490 self
491 }
492
493 #[must_use]
495 pub fn with_fallback_text(mut self, text: impl Into<String>) -> Self {
496 self.fallback_text = Some(text.into());
497 self
498 }
499
500 #[must_use]
502 pub fn build_meta(&self) -> ToolUiMeta {
503 let mut meta = ToolUiMeta::new(&self.ui_resource_uri);
504 if let Some(ref hints) = self.display_hints {
505 meta = meta.with_display_hints(hints.clone());
506 }
507 meta
508 }
509
510 #[must_use]
512 pub fn name(&self) -> &str {
513 &self.name
514 }
515
516 #[must_use]
518 pub fn description(&self) -> Option<&str> {
519 self.description.as_deref()
520 }
521
522 #[must_use]
524 pub fn fallback_text(&self) -> Option<&str> {
525 self.fallback_text.as_deref()
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn test_ui_resource() {
535 let ui = UiResource::new("ui://charts/bar", "Bar Chart")
536 .with_description("A bar chart")
537 .with_mime_type(MIME_TYPE_HTML_MCP);
538
539 assert_eq!(ui.uri, "ui://charts/bar");
540 assert_eq!(ui.name, "Bar Chart");
541 assert!(ui.is_mcp_html());
542 assert!(ui.has_valid_scheme());
543 }
544
545 #[test]
546 fn test_tool_ui_meta() {
547 let meta = ToolUiMeta::new("ui://widgets/counter")
548 .with_display_hints(UiDisplayHints::new().with_size(400, 300));
549
550 let value = meta.to_meta_value();
551 assert!(value.get("ui/resourceUri").is_some());
552 assert!(value.get("ui/displayHints").is_some());
553 }
554
555 #[test]
556 fn test_apps_config() {
557 let config = AppsConfig::new()
558 .with_sandbox_permissions(vec!["allow-scripts".to_string()])
559 .with_max_content_size(1024 * 1024);
560
561 let ext = config.into_extension();
562 assert_eq!(ext.name, namespaces::MCP_APPS);
563 assert_eq!(ext.version, Some(APPS_VERSION.to_string()));
564 }
565
566 #[test]
567 fn test_ui_content_render() {
568 let content = UiContent::new("<div>Hello</div>")
569 .with_styles("body { margin: 0; }")
570 .with_scripts("console.log('loaded');");
571
572 let html = content.render();
573 assert!(html.contains("<!DOCTYPE html>"));
574 assert!(html.contains("<div>Hello</div>"));
575 assert!(html.contains("body { margin: 0; }"));
576 assert!(html.contains("console.log('loaded');"));
577 }
578
579 #[test]
580 fn test_ui_tool_builder() {
581 let builder = UiToolBuilder::new("chart", "ui://charts/bar")
582 .with_description("Display a bar chart")
583 .with_display_hints(UiDisplayHints::new().with_mode(UiDisplayMode::Modal))
584 .with_fallback_text("Chart displayed");
585
586 assert_eq!(builder.name(), "chart");
587 assert_eq!(builder.description(), Some("Display a bar chart"));
588
589 let meta = builder.build_meta();
590 assert_eq!(meta.resource_uri, "ui://charts/bar");
591 }
592
593 #[test]
594 fn test_serialization() {
595 let meta = ToolUiMeta::new("ui://test");
596 let json = serde_json::to_string(&meta).unwrap();
597 assert!(json.contains("ui/resourceUri"));
598
599 let parsed: ToolUiMeta = serde_json::from_str(&json).unwrap();
600 assert_eq!(parsed.resource_uri, "ui://test");
601 }
602}