use serde::{Deserialize, Serialize};
use super::{Extension, namespaces};
pub const APPS_VERSION: &str = "0.1.0";
pub const MIME_TYPE_HTML_MCP: &str = "text/html+mcp";
pub const MIME_TYPE_HTML: &str = "text/html";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UiResource {
pub uri: String,
pub name: String,
#[serde(default = "default_mime_type")]
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
fn default_mime_type() -> String {
MIME_TYPE_HTML.to_string()
}
impl UiResource {
#[must_use]
pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri: uri.into(),
name: name.into(),
mime_type: MIME_TYPE_HTML.to_string(),
description: None,
}
}
#[must_use]
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = mime_type.into();
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn is_mcp_html(&self) -> bool {
self.mime_type == MIME_TYPE_HTML_MCP
}
#[must_use]
pub fn has_valid_scheme(&self) -> bool {
self.uri.starts_with("ui://")
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ToolUiMeta {
#[serde(rename = "ui/resourceUri")]
pub resource_uri: String,
#[serde(rename = "ui/displayHints", skip_serializing_if = "Option::is_none")]
pub display_hints: Option<UiDisplayHints>,
}
impl ToolUiMeta {
#[must_use]
pub fn new(resource_uri: impl Into<String>) -> Self {
Self {
resource_uri: resource_uri.into(),
display_hints: None,
}
}
#[must_use]
pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
self.display_hints = Some(hints);
self
}
#[must_use]
pub fn to_meta_value(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_default()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UiDisplayHints {
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resizable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<UiDisplayMode>,
}
impl UiDisplayHints {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_size(mut self, width: u32, height: u32) -> Self {
self.width = Some(width);
self.height = Some(height);
self
}
#[must_use]
pub fn with_resizable(mut self, resizable: bool) -> Self {
self.resizable = Some(resizable);
self
}
#[must_use]
pub fn with_mode(mut self, mode: UiDisplayMode) -> Self {
self.mode = Some(mode);
self
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UiDisplayMode {
#[default]
Inline,
Modal,
Sidebar,
Fullscreen,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppsConfig {
#[serde(default = "default_true")]
pub ui_resources: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sandbox_permissions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_content_size: Option<usize>,
#[serde(
default = "default_allowed_mime_types",
skip_serializing_if = "Vec::is_empty"
)]
pub allowed_mime_types: Vec<String>,
}
fn default_true() -> bool {
true
}
fn default_allowed_mime_types() -> Vec<String> {
vec![MIME_TYPE_HTML.to_string(), MIME_TYPE_HTML_MCP.to_string()]
}
impl AppsConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_sandbox_permissions(mut self, permissions: Vec<String>) -> Self {
self.sandbox_permissions = permissions;
self
}
#[must_use]
pub fn with_max_content_size(mut self, size: usize) -> Self {
self.max_content_size = Some(size);
self
}
#[must_use]
pub fn with_allowed_mime_types(mut self, types: Vec<String>) -> Self {
self.allowed_mime_types = types;
self
}
#[must_use]
pub fn into_extension(self) -> Extension {
Extension::new(namespaces::MCP_APPS)
.with_version(APPS_VERSION)
.with_description("MCP Apps Extension for interactive UIs")
.with_config(serde_json::to_value(self).unwrap_or_default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiContent {
pub html: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub styles: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripts: Option<String>,
}
impl UiContent {
#[must_use]
pub fn new(html: impl Into<String>) -> Self {
Self {
html: html.into(),
styles: None,
scripts: None,
}
}
#[must_use]
pub fn with_styles(mut self, styles: impl Into<String>) -> Self {
self.styles = Some(styles.into());
self
}
#[must_use]
pub fn with_scripts(mut self, scripts: impl Into<String>) -> Self {
self.scripts = Some(scripts.into());
self
}
#[must_use]
pub fn render(&self) -> String {
let mut doc = String::new();
doc.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
doc.push_str("<meta charset=\"utf-8\">\n");
doc.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
if let Some(ref styles) = self.styles {
doc.push_str("<style>\n");
doc.push_str(styles);
doc.push_str("\n</style>\n");
}
doc.push_str("</head>\n<body>\n");
doc.push_str(&self.html);
if let Some(ref scripts) = self.scripts {
doc.push_str("\n<script>\n");
doc.push_str(scripts);
doc.push_str("\n</script>\n");
}
doc.push_str("\n</body>\n</html>");
doc
}
}
#[derive(Debug, Clone)]
pub struct UiToolBuilder {
name: String,
description: Option<String>,
ui_resource_uri: String,
display_hints: Option<UiDisplayHints>,
fallback_text: Option<String>,
}
impl UiToolBuilder {
#[must_use]
pub fn new(name: impl Into<String>, ui_resource_uri: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
ui_resource_uri: ui_resource_uri.into(),
display_hints: None,
fallback_text: None,
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
self.display_hints = Some(hints);
self
}
#[must_use]
pub fn with_fallback_text(mut self, text: impl Into<String>) -> Self {
self.fallback_text = Some(text.into());
self
}
#[must_use]
pub fn build_meta(&self) -> ToolUiMeta {
let mut meta = ToolUiMeta::new(&self.ui_resource_uri);
if let Some(ref hints) = self.display_hints {
meta = meta.with_display_hints(hints.clone());
}
meta
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
#[must_use]
pub fn fallback_text(&self) -> Option<&str> {
self.fallback_text.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ui_resource() {
let ui = UiResource::new("ui://charts/bar", "Bar Chart")
.with_description("A bar chart")
.with_mime_type(MIME_TYPE_HTML_MCP);
assert_eq!(ui.uri, "ui://charts/bar");
assert_eq!(ui.name, "Bar Chart");
assert!(ui.is_mcp_html());
assert!(ui.has_valid_scheme());
}
#[test]
fn test_tool_ui_meta() {
let meta = ToolUiMeta::new("ui://widgets/counter")
.with_display_hints(UiDisplayHints::new().with_size(400, 300));
let value = meta.to_meta_value();
assert!(value.get("ui/resourceUri").is_some());
assert!(value.get("ui/displayHints").is_some());
}
#[test]
fn test_apps_config() {
let config = AppsConfig::new()
.with_sandbox_permissions(vec!["allow-scripts".to_string()])
.with_max_content_size(1024 * 1024);
let ext = config.into_extension();
assert_eq!(ext.name, namespaces::MCP_APPS);
assert_eq!(ext.version, Some(APPS_VERSION.to_string()));
}
#[test]
fn test_ui_content_render() {
let content = UiContent::new("<div>Hello</div>")
.with_styles("body { margin: 0; }")
.with_scripts("console.log('loaded');");
let html = content.render();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<div>Hello</div>"));
assert!(html.contains("body { margin: 0; }"));
assert!(html.contains("console.log('loaded');"));
}
#[test]
fn test_ui_tool_builder() {
let builder = UiToolBuilder::new("chart", "ui://charts/bar")
.with_description("Display a bar chart")
.with_display_hints(UiDisplayHints::new().with_mode(UiDisplayMode::Modal))
.with_fallback_text("Chart displayed");
assert_eq!(builder.name(), "chart");
assert_eq!(builder.description(), Some("Display a bar chart"));
let meta = builder.build_meta();
assert_eq!(meta.resource_uri, "ui://charts/bar");
}
#[test]
fn test_serialization() {
let meta = ToolUiMeta::new("ui://test");
let json = serde_json::to_string(&meta).unwrap();
assert!(json.contains("ui/resourceUri"));
let parsed: ToolUiMeta = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.resource_uri, "ui://test");
}
}