Skip to main content

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 Apps HTML content, as published by SEP-1865.
54///
55/// This is a parameterized `text/html`, not a `+suffix` type: a host that does
56/// not understand the profile still sees HTML.
57pub const MIME_TYPE_HTML_MCP: &str = "text/html;profile=mcp-app";
58
59/// Standard MIME type for HTML content.
60pub const MIME_TYPE_HTML: &str = "text/html";
61
62/// A UI resource declaration.
63///
64/// UI resources use the `ui://` URI scheme and contain HTML content
65/// that can be rendered in sandboxed iframes.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "camelCase")]
68pub struct UiResource {
69    /// The UI resource URI (e.g., `ui://charts/bar-chart`).
70    pub uri: String,
71
72    /// Human-readable name for the UI.
73    pub name: String,
74
75    /// MIME type (typically "text/html" or "text/html;profile=mcp-app").
76    #[serde(default = "default_mime_type")]
77    pub mime_type: String,
78
79    /// Optional description of the UI.
80    #[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    /// Create a new UI resource.
90    ///
91    /// # Arguments
92    ///
93    /// * `uri` - The UI resource URI (should use `ui://` scheme)
94    /// * `name` - Human-readable name
95    ///
96    /// # Example
97    ///
98    /// ```rust
99    /// use mcpkit_core::extension::apps::UiResource;
100    ///
101    /// let ui = UiResource::new("ui://widgets/counter", "Counter Widget");
102    /// assert!(ui.uri.starts_with("ui://"));
103    /// ```
104    #[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    /// Set the MIME type.
115    ///
116    /// # Arguments
117    ///
118    /// * `mime_type` - The MIME type (e.g., "text/html;profile=mcp-app")
119    #[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    /// Set the description.
126    ///
127    /// # Arguments
128    ///
129    /// * `description` - Human-readable description
130    #[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    /// Check if this resource uses the MCP-enhanced HTML MIME type.
137    #[must_use]
138    pub fn is_mcp_html(&self) -> bool {
139        self.mime_type == MIME_TYPE_HTML_MCP
140    }
141
142    /// Validate the URI scheme.
143    ///
144    /// Returns `true` if the URI uses the `ui://` scheme.
145    #[must_use]
146    pub fn has_valid_scheme(&self) -> bool {
147        self.uri.starts_with("ui://")
148    }
149}
150
151/// Tool metadata for UI linking.
152///
153/// This metadata is included in the `_meta` field of tool definitions
154/// to link tools to UI resources.
155/// Tool `_meta` for MCP Apps.
156///
157/// **Known staleness (verified 2026-07-27 against ext-apps
158/// `specification/2026-01-26/apps.mdx`):** this emits the *flat* key shape, which
159/// that revision deprecates —
160///
161/// > The flat `_meta["ui/resourceUri"]` format is deprecated. Use
162/// > `_meta.ui.resourceUri` instead. The deprecated format will be removed
163/// > before GA.
164///
165/// and `ui/displayHints` does not appear in that revision at all. Neither is a
166/// conformance break against the 2025-11-25 target: MCP Apps is an optional
167/// extension and that revision postdates it. Tracked with the next-revision work
168/// in `ROADMAP.md`; moving to the nested shape is a wire change and wants its own
169/// decision.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "camelCase")]
172pub struct ToolUiMeta {
173    /// The UI resource URI to render for this tool.
174    #[serde(rename = "ui/resourceUri")]
175    pub resource_uri: String,
176
177    /// Optional display hints for the host.
178    #[serde(rename = "ui/displayHints", skip_serializing_if = "Option::is_none")]
179    pub display_hints: Option<UiDisplayHints>,
180}
181
182impl ToolUiMeta {
183    /// Create new tool UI metadata.
184    ///
185    /// # Arguments
186    ///
187    /// * `resource_uri` - The UI resource URI to link
188    ///
189    /// # Example
190    ///
191    /// ```rust
192    /// use mcpkit_core::extension::apps::ToolUiMeta;
193    ///
194    /// let meta = ToolUiMeta::new("ui://charts/bar-chart");
195    /// ```
196    #[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    /// Set display hints.
205    ///
206    /// # Arguments
207    ///
208    /// * `hints` - Display hints for the host
209    #[must_use]
210    pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
211        self.display_hints = Some(hints);
212        self
213    }
214
215    /// Convert to a JSON value for inclusion in tool `_meta`.
216    #[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/// Display hints for UI rendering.
223///
224/// Hosts may use these hints to determine how to display the UI.
225#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
226#[serde(rename_all = "camelCase")]
227pub struct UiDisplayHints {
228    /// Suggested width in pixels.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub width: Option<u32>,
231
232    /// Suggested height in pixels.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub height: Option<u32>,
235
236    /// Whether the UI should be resizable.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub resizable: Option<bool>,
239
240    /// Display mode preference.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub mode: Option<UiDisplayMode>,
243}
244
245impl UiDisplayHints {
246    /// Create new display hints.
247    #[must_use]
248    pub fn new() -> Self {
249        Self::default()
250    }
251
252    /// Set the suggested size.
253    #[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    /// Set whether the UI is resizable.
261    #[must_use]
262    pub fn with_resizable(mut self, resizable: bool) -> Self {
263        self.resizable = Some(resizable);
264        self
265    }
266
267    /// Set the display mode.
268    #[must_use]
269    pub fn with_mode(mut self, mode: UiDisplayMode) -> Self {
270        self.mode = Some(mode);
271        self
272    }
273}
274
275/// UI display mode.
276#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
277#[serde(rename_all = "lowercase")]
278pub enum UiDisplayMode {
279    /// Inline display within the conversation.
280    #[default]
281    Inline,
282
283    /// Modal/popup display.
284    Modal,
285
286    /// Sidebar display.
287    Sidebar,
288
289    /// Full-screen display.
290    Fullscreen,
291}
292
293/// MCP Apps extension configuration.
294///
295/// This structure configures the Apps extension capabilities.
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298#[non_exhaustive]
299pub struct AppsConfig {
300    /// Whether UI resources are supported.
301    #[serde(default = "default_true")]
302    pub ui_resources: bool,
303
304    /// Sandbox permissions for iframes.
305    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306    pub sandbox_permissions: Vec<String>,
307
308    /// Maximum UI content size in bytes.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub max_content_size: Option<usize>,
311
312    /// Allowed MIME types.
313    #[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    /// Create a new Apps configuration with defaults.
330    #[must_use]
331    pub fn new() -> Self {
332        Self::default()
333    }
334
335    /// Set whether UI resources are supported.
336    #[must_use]
337    pub const fn with_ui_resources(mut self, supported: bool) -> Self {
338        self.ui_resources = supported;
339        self
340    }
341
342    /// Set sandbox permissions.
343    ///
344    /// # Arguments
345    ///
346    /// * `permissions` - List of iframe sandbox permissions
347    ///
348    /// # Example
349    ///
350    /// ```rust
351    /// use mcpkit_core::extension::apps::AppsConfig;
352    ///
353    /// let config = AppsConfig::new()
354    ///     .with_sandbox_permissions(vec![
355    ///         "allow-scripts".to_string(),
356    ///         "allow-forms".to_string(),
357    ///     ]);
358    /// ```
359    #[must_use]
360    pub fn with_sandbox_permissions(mut self, permissions: Vec<String>) -> Self {
361        self.sandbox_permissions = permissions;
362        self
363    }
364
365    /// Set maximum content size.
366    ///
367    /// # Arguments
368    ///
369    /// * `size` - Maximum size in bytes
370    #[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    /// Set allowed MIME types.
377    ///
378    /// # Arguments
379    ///
380    /// * `types` - List of allowed MIME types
381    #[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    /// Convert to an Extension for registration.
388    #[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/// UI content for rendering.
398///
399/// This represents the actual HTML content of a UI resource.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct UiContent {
403    /// The HTML content.
404    pub html: String,
405
406    /// Optional inline styles.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub styles: Option<String>,
409
410    /// Optional inline scripts.
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub scripts: Option<String>,
413}
414
415impl UiContent {
416    /// Create new UI content.
417    ///
418    /// # Arguments
419    ///
420    /// * `html` - The HTML content
421    #[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    /// Add inline styles.
431    #[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    /// Add inline scripts.
438    #[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    /// Render the complete HTML document.
445    ///
446    /// Combines HTML, styles, and scripts into a complete document.
447    #[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/// Builder for creating UI-enabled tools.
476///
477/// This builder helps create tools that are linked to UI resources.
478#[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    /// Create a new UI tool builder.
489    ///
490    /// # Arguments
491    ///
492    /// * `name` - Tool name
493    /// * `ui_resource_uri` - The UI resource URI
494    #[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    /// Set the tool description.
506    #[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    /// Set display hints.
513    #[must_use]
514    pub fn with_display_hints(mut self, hints: UiDisplayHints) -> Self {
515        self.display_hints = Some(hints);
516        self
517    }
518
519    /// Set fallback text for non-UI clients.
520    #[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    /// Build the tool UI metadata.
527    #[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    /// Get the tool name.
537    #[must_use]
538    pub fn name(&self) -> &str {
539        &self.name
540    }
541
542    /// Get the description.
543    #[must_use]
544    pub fn description(&self) -> Option<&str> {
545        self.description.as_deref()
546    }
547
548    /// Get the fallback text.
549    #[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}