mcpkit_core/extension/
apps.rs

1//! MCP Apps Extension (SEP-1865).
2//!
3//! This module implements support for the MCP Apps extension, which enables
4//! MCP servers to deliver interactive user interfaces to hosts.
5//!
6//! # Overview
7//!
8//! MCP Apps extends the protocol with:
9//! - UI resources using the `ui://` URI scheme
10//! - Tool metadata linking tools to UI templates
11//! - Bidirectional communication between UIs and hosts
12//!
13//! # Example
14//!
15//! ```rust
16//! use mcpkit_core::extension::apps::{UiResource, ToolUiMeta, AppsConfig};
17//! use mcpkit_core::extension::{Extension, ExtensionRegistry};
18//!
19//! // Define a UI resource
20//! let chart_ui = UiResource::new("ui://charts/bar-chart", "Bar Chart Viewer")
21//!     .with_description("Interactive bar chart visualization");
22//!
23//! // Link a tool to the UI
24//! let meta = ToolUiMeta::new("ui://charts/bar-chart");
25//!
26//! // Configure the apps extension
27//! let apps = AppsConfig::new()
28//!     .with_sandbox_permissions(vec!["allow-scripts".to_string()]);
29//!
30//! // Register the extension
31//! let registry = ExtensionRegistry::new()
32//!     .register(apps.into_extension());
33//! ```
34//!
35//! # Security
36//!
37//! All UI content runs in sandboxed iframes with restricted permissions.
38//! The extension supports configurable sandbox permissions for different
39//! security requirements.
40//!
41//! # References
42//!
43//! - [SEP-1865: MCP Apps](https://github.com/modelcontextprotocol/ext-apps)
44//! - [MCP Apps Blog Post](https://blog.modelcontextprotocol.io/posts/2025-11-21-mcp-apps/)
45
46use serde::{Deserialize, Serialize};
47
48use super::{Extension, namespaces};
49
50/// The MCP Apps extension version.
51pub const APPS_VERSION: &str = "0.1.0";
52
53/// MIME type for MCP HTML content.
54pub const MIME_TYPE_HTML_MCP: &str = "text/html+mcp";
55
56/// Standard MIME type for HTML content.
57pub const MIME_TYPE_HTML: &str = "text/html";
58
59/// A UI resource declaration.
60///
61/// UI resources use the `ui://` URI scheme and contain HTML content
62/// that can be rendered in sandboxed iframes.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "camelCase")]
65pub struct UiResource {
66    /// The UI resource URI (e.g., `ui://charts/bar-chart`).
67    pub uri: String,
68
69    /// Human-readable name for the UI.
70    pub name: String,
71
72    /// MIME type (typically "text/html" or "text/html+mcp").
73    #[serde(default = "default_mime_type")]
74    pub mime_type: String,
75
76    /// Optional description of the UI.
77    #[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    /// Create a new UI resource.
87    ///
88    /// # Arguments
89    ///
90    /// * `uri` - The UI resource URI (should use `ui://` scheme)
91    /// * `name` - Human-readable name
92    ///
93    /// # Example
94    ///
95    /// ```rust
96    /// use mcpkit_core::extension::apps::UiResource;
97    ///
98    /// let ui = UiResource::new("ui://widgets/counter", "Counter Widget");
99    /// assert!(ui.uri.starts_with("ui://"));
100    /// ```
101    #[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    /// Set the MIME type.
112    ///
113    /// # Arguments
114    ///
115    /// * `mime_type` - The MIME type (e.g., "text/html+mcp")
116    #[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    /// Set the description.
123    ///
124    /// # Arguments
125    ///
126    /// * `description` - Human-readable description
127    #[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    /// Check if this resource uses the MCP-enhanced HTML MIME type.
134    #[must_use]
135    pub fn is_mcp_html(&self) -> bool {
136        self.mime_type == MIME_TYPE_HTML_MCP
137    }
138
139    /// Validate the URI scheme.
140    ///
141    /// Returns `true` if the URI uses the `ui://` scheme.
142    #[must_use]
143    pub fn has_valid_scheme(&self) -> bool {
144        self.uri.starts_with("ui://")
145    }
146}
147
148/// Tool metadata for UI linking.
149///
150/// This metadata is included in the `_meta` field of tool definitions
151/// to link tools to UI resources.
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "camelCase")]
154pub struct ToolUiMeta {
155    /// The UI resource URI to render for this tool.
156    #[serde(rename = "ui/resourceUri")]
157    pub resource_uri: String,
158
159    /// Optional display hints for the host.
160    #[serde(rename = "ui/displayHints", skip_serializing_if = "Option::is_none")]
161    pub display_hints: Option<UiDisplayHints>,
162}
163
164impl ToolUiMeta {
165    /// Create new tool UI metadata.
166    ///
167    /// # Arguments
168    ///
169    /// * `resource_uri` - The UI resource URI to link
170    ///
171    /// # Example
172    ///
173    /// ```rust
174    /// use mcpkit_core::extension::apps::ToolUiMeta;
175    ///
176    /// let meta = ToolUiMeta::new("ui://charts/bar-chart");
177    /// ```
178    #[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    /// Set display hints.
187    ///
188    /// # Arguments
189    ///
190    /// * `hints` - Display hints for the host
191    #[must_use]
192    pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
193        self.display_hints = Some(hints);
194        self
195    }
196
197    /// Convert to a JSON value for inclusion in tool `_meta`.
198    #[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/// Display hints for UI rendering.
205///
206/// Hosts may use these hints to determine how to display the UI.
207#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
208#[serde(rename_all = "camelCase")]
209pub struct UiDisplayHints {
210    /// Suggested width in pixels.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub width: Option<u32>,
213
214    /// Suggested height in pixels.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub height: Option<u32>,
217
218    /// Whether the UI should be resizable.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub resizable: Option<bool>,
221
222    /// Display mode preference.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub mode: Option<UiDisplayMode>,
225}
226
227impl UiDisplayHints {
228    /// Create new display hints.
229    #[must_use]
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// Set the suggested size.
235    #[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    /// Set whether the UI is resizable.
243    #[must_use]
244    pub fn with_resizable(mut self, resizable: bool) -> Self {
245        self.resizable = Some(resizable);
246        self
247    }
248
249    /// Set the display mode.
250    #[must_use]
251    pub fn with_mode(mut self, mode: UiDisplayMode) -> Self {
252        self.mode = Some(mode);
253        self
254    }
255}
256
257/// UI display mode.
258#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
259#[serde(rename_all = "lowercase")]
260pub enum UiDisplayMode {
261    /// Inline display within the conversation.
262    #[default]
263    Inline,
264
265    /// Modal/popup display.
266    Modal,
267
268    /// Sidebar display.
269    Sidebar,
270
271    /// Full-screen display.
272    Fullscreen,
273}
274
275/// MCP Apps extension configuration.
276///
277/// This structure configures the Apps extension capabilities.
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct AppsConfig {
281    /// Whether UI resources are supported.
282    #[serde(default = "default_true")]
283    pub ui_resources: bool,
284
285    /// Sandbox permissions for iframes.
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub sandbox_permissions: Vec<String>,
288
289    /// Maximum UI content size in bytes.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub max_content_size: Option<usize>,
292
293    /// Allowed MIME types.
294    #[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    /// Create a new Apps configuration with defaults.
311    #[must_use]
312    pub fn new() -> Self {
313        Self::default()
314    }
315
316    /// Set sandbox permissions.
317    ///
318    /// # Arguments
319    ///
320    /// * `permissions` - List of iframe sandbox permissions
321    ///
322    /// # Example
323    ///
324    /// ```rust
325    /// use mcpkit_core::extension::apps::AppsConfig;
326    ///
327    /// let config = AppsConfig::new()
328    ///     .with_sandbox_permissions(vec![
329    ///         "allow-scripts".to_string(),
330    ///         "allow-forms".to_string(),
331    ///     ]);
332    /// ```
333    #[must_use]
334    pub fn with_sandbox_permissions(mut self, permissions: Vec<String>) -> Self {
335        self.sandbox_permissions = permissions;
336        self
337    }
338
339    /// Set maximum content size.
340    ///
341    /// # Arguments
342    ///
343    /// * `size` - Maximum size in bytes
344    #[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    /// Set allowed MIME types.
351    ///
352    /// # Arguments
353    ///
354    /// * `types` - List of allowed MIME types
355    #[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    /// Convert to an Extension for registration.
362    #[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/// UI content for rendering.
372///
373/// This represents the actual HTML content of a UI resource.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase")]
376pub struct UiContent {
377    /// The HTML content.
378    pub html: String,
379
380    /// Optional inline styles.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub styles: Option<String>,
383
384    /// Optional inline scripts.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub scripts: Option<String>,
387}
388
389impl UiContent {
390    /// Create new UI content.
391    ///
392    /// # Arguments
393    ///
394    /// * `html` - The HTML content
395    #[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    /// Add inline styles.
405    #[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    /// Add inline scripts.
412    #[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    /// Render the complete HTML document.
419    ///
420    /// Combines HTML, styles, and scripts into a complete document.
421    #[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/// Builder for creating UI-enabled tools.
450///
451/// This builder helps create tools that are linked to UI resources.
452#[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    /// Create a new UI tool builder.
463    ///
464    /// # Arguments
465    ///
466    /// * `name` - Tool name
467    /// * `ui_resource_uri` - The UI resource URI
468    #[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    /// Set the tool description.
480    #[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    /// Set display hints.
487    #[must_use]
488    pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
489        self.display_hints = Some(hints);
490        self
491    }
492
493    /// Set fallback text for non-UI clients.
494    #[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    /// Build the tool UI metadata.
501    #[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    /// Get the tool name.
511    #[must_use]
512    pub fn name(&self) -> &str {
513        &self.name
514    }
515
516    /// Get the description.
517    #[must_use]
518    pub fn description(&self) -> Option<&str> {
519        self.description.as_deref()
520    }
521
522    /// Get the fallback text.
523    #[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}