Skip to main content

tower_mcp/
apps.rs

1//! Typed server support for the stable MCP Apps extension (SEP-1865).
2//!
3//! This module deliberately separates compile-time availability from runtime
4//! activation:
5//!
6//! - enable the `mcp-apps` Cargo feature to compile these APIs;
7//! - call [`McpRouter::with_mcp_apps`] or [`McpClientBuilder::with_mcp_apps`]
8//!   to declare support on the wire;
9//! - check [`RequestContext::supports_mcp_apps`] before applying
10//!   extension-specific behavior.
11//!
12//! UI resources built here use the required `ui://` scheme and
13//! `text/html;profile=mcp-app` MIME type. External CSP sources are restricted
14//! to origins: credentials, paths, queries, fragments, and unsupported schemes
15//! are rejected.
16//!
17//! See [`crate::guides::mcp_apps`] for the task-oriented setup, security
18//! boundary, CSP and permissions policy, and visibility guidance.
19
20use std::collections::HashSet;
21use std::fmt;
22
23use serde::Serialize;
24use serde_json::{Map, Value};
25use thiserror::Error;
26use url::Url;
27
28use crate::protocol::{
29    CallToolResult, Content, MetaValidationError, ReadResourceResult, ResourceContent,
30    validate_meta_object,
31};
32use crate::{
33    ExtensionDeclaration, McpClientBuilder, McpRouter, RequestContext, Resource, ResourceBuilder,
34    Tool,
35};
36
37/// Stable extension identifier reserved for MCP Apps.
38pub const MCP_APPS_EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
39
40/// The only content type defined by the stable MCP Apps MVP.
41pub const MCP_APP_HTML_MIME_TYPE: &str = "text/html;profile=mcp-app";
42
43/// A validation or construction error from the typed MCP Apps API.
44#[derive(Debug, Error)]
45#[non_exhaustive]
46pub enum McpAppError {
47    /// The resource URI did not satisfy the MCP Apps `ui://` requirement.
48    #[error("invalid MCP Apps UI URI {0:?}")]
49    InvalidUiUri(String),
50    /// The resource body did not look like a complete HTML5 document.
51    #[error("MCP Apps content must be a complete HTML5 document")]
52    InvalidHtmlDocument,
53    /// A CSP source was not a permitted origin.
54    #[error("invalid MCP Apps CSP origin {origin:?} for {directive}")]
55    InvalidCspOrigin {
56        /// The rejected source.
57        origin: String,
58        /// The metadata field being configured.
59        directive: &'static str,
60    },
61    /// A dedicated sandbox domain was malformed.
62    #[error("invalid MCP Apps dedicated domain {0:?}")]
63    InvalidDomain(String),
64    /// Tool visibility was empty or contained duplicate entries.
65    #[error("MCP Apps tool visibility must be non-empty and contain no duplicates")]
66    InvalidVisibility,
67    /// A required human-readable value was empty.
68    #[error("MCP Apps {0} must not be empty")]
69    EmptyField(&'static str),
70    /// Protocol metadata failed the core `_meta` grammar.
71    #[error(transparent)]
72    Metadata(#[from] MetaValidationError),
73    /// Typed metadata could not be converted to JSON.
74    #[error("failed to serialize MCP Apps metadata: {0}")]
75    Serialization(#[from] serde_json::Error),
76}
77
78/// A validated MCP Apps resource identifier.
79#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
80#[serde(transparent)]
81pub struct McpAppUri(String);
82
83impl McpAppUri {
84    /// Validate an MCP Apps resource URI.
85    pub fn new(uri: impl Into<String>) -> Result<Self, McpAppError> {
86        let uri = uri.into();
87        let parsed = Url::parse(&uri).map_err(|_| McpAppError::InvalidUiUri(uri.clone()))?;
88        if !uri.starts_with("ui://")
89            || parsed.scheme() != "ui"
90            || parsed.host_str().is_none()
91            || !parsed.username().is_empty()
92            || parsed.password().is_some()
93            || parsed.port().is_some()
94            || parsed.query().is_some()
95            || parsed.fragment().is_some()
96        {
97            return Err(McpAppError::InvalidUiUri(uri));
98        }
99        Ok(Self(uri))
100    }
101
102    /// Borrow the wire URI.
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106}
107
108impl fmt::Display for McpAppUri {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        self.0.fmt(f)
111    }
112}
113
114impl TryFrom<String> for McpAppUri {
115    type Error = McpAppError;
116
117    fn try_from(value: String) -> Result<Self, Self::Error> {
118        Self::new(value)
119    }
120}
121
122impl TryFrom<&str> for McpAppUri {
123    type Error = McpAppError;
124
125    fn try_from(value: &str) -> Result<Self, Self::Error> {
126        Self::new(value)
127    }
128}
129
130/// A minimally validated complete HTML5 document.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct McpAppHtml(String);
133
134impl McpAppHtml {
135    /// Validate a complete HTML5 document.
136    ///
137    /// This structural check requires an HTML5 doctype, an `<html` root, and
138    /// rejects NUL bytes. It is intentionally not a sanitizer: MCP Apps hosts
139    /// must still sandbox the document and enforce its declared CSP.
140    pub fn new(html: impl Into<String>) -> Result<Self, McpAppError> {
141        let html = html.into();
142        let lower = html.trim_start().to_ascii_lowercase();
143        if html.contains('\0') || !lower.starts_with("<!doctype html>") || !lower.contains("<html")
144        {
145            return Err(McpAppError::InvalidHtmlDocument);
146        }
147        Ok(Self(html))
148    }
149
150    /// Borrow the HTML source.
151    pub fn as_str(&self) -> &str {
152        &self.0
153    }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
157#[serde(transparent)]
158struct CspOrigin(String);
159
160#[derive(Debug, Clone, Copy)]
161enum CspDirective {
162    Connect,
163    Resource,
164    Frame,
165    BaseUri,
166}
167
168impl CspDirective {
169    fn name(self) -> &'static str {
170        match self {
171            Self::Connect => "connectDomains",
172            Self::Resource => "resourceDomains",
173            Self::Frame => "frameDomains",
174            Self::BaseUri => "baseUriDomains",
175        }
176    }
177
178    fn allows_scheme(self, scheme: &str) -> bool {
179        match self {
180            Self::Connect => matches!(scheme, "http" | "https" | "ws" | "wss"),
181            Self::Resource | Self::Frame | Self::BaseUri => matches!(scheme, "http" | "https"),
182        }
183    }
184
185    fn allows_wildcard(self) -> bool {
186        matches!(self, Self::Resource)
187    }
188}
189
190impl CspOrigin {
191    fn parse(origin: impl Into<String>, directive: CspDirective) -> Result<Self, McpAppError> {
192        let origin = origin.into();
193        let invalid = || McpAppError::InvalidCspOrigin {
194            origin: origin.clone(),
195            directive: directive.name(),
196        };
197        if origin.trim() != origin || origin.ends_with("//") {
198            return Err(invalid());
199        }
200
201        let wildcard = origin.contains("://*.");
202        if wildcard && !directive.allows_wildcard() {
203            return Err(invalid());
204        }
205        if origin.contains('*') && !wildcard {
206            return Err(invalid());
207        }
208
209        let parseable = if wildcard {
210            origin.replacen("://*.", "://wildcard.", 1)
211        } else {
212            origin.clone()
213        };
214        let parsed = Url::parse(&parseable).map_err(|_| invalid())?;
215        if !directive.allows_scheme(parsed.scheme())
216            || parsed.host_str().is_none()
217            || !parsed.username().is_empty()
218            || parsed.password().is_some()
219            || !matches!(parsed.path(), "" | "/")
220            || parsed.query().is_some()
221            || parsed.fragment().is_some()
222        {
223            return Err(invalid());
224        }
225        if wildcard {
226            let host = parsed.host_str().ok_or_else(invalid)?;
227            let suffix = host.strip_prefix("wildcard.").ok_or_else(invalid)?;
228            if !suffix.contains('.') {
229                return Err(invalid());
230            }
231        }
232
233        Ok(Self(
234            origin.strip_suffix('/').unwrap_or(&origin).to_string(),
235        ))
236    }
237}
238
239/// Content Security Policy inputs declared by an MCP Apps resource.
240///
241/// Empty fields are omitted, giving hosts the specification's restrictive
242/// default. No API in this type emits wildcard `*`, `data:`, `'unsafe-eval'`,
243/// or arbitrary CSP fragments.
244#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
245#[serde(rename_all = "camelCase")]
246pub struct McpUiResourceCsp {
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    connect_domains: Vec<CspOrigin>,
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    resource_domains: Vec<CspOrigin>,
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    frame_domains: Vec<CspOrigin>,
253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
254    base_uri_domains: Vec<CspOrigin>,
255}
256
257impl McpUiResourceCsp {
258    /// Allow one HTTP(S) or WebSocket origin for fetch/XHR/WebSocket.
259    pub fn allow_connect(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
260        push_unique(
261            &mut self.connect_domains,
262            CspOrigin::parse(origin, CspDirective::Connect)?,
263        );
264        Ok(self)
265    }
266
267    /// Allow one HTTP(S) origin for scripts, styles, images, fonts, or media.
268    ///
269    /// This is the only field that accepts a wildcard subdomain such as
270    /// `https://*.example.com`, matching SEP-1865.
271    pub fn allow_resource(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
272        push_unique(
273            &mut self.resource_domains,
274            CspOrigin::parse(origin, CspDirective::Resource)?,
275        );
276        Ok(self)
277    }
278
279    /// Allow one HTTP(S) origin for nested iframes.
280    pub fn allow_frame(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
281        push_unique(
282            &mut self.frame_domains,
283            CspOrigin::parse(origin, CspDirective::Frame)?,
284        );
285        Ok(self)
286    }
287
288    /// Allow one HTTP(S) origin for the document's base URI.
289    pub fn allow_base_uri(mut self, origin: impl Into<String>) -> Result<Self, McpAppError> {
290        push_unique(
291            &mut self.base_uri_domains,
292            CspOrigin::parse(origin, CspDirective::BaseUri)?,
293        );
294        Ok(self)
295    }
296
297    /// Declared connection origins.
298    pub fn connect_domains(&self) -> impl Iterator<Item = &str> {
299        self.connect_domains.iter().map(|origin| origin.0.as_str())
300    }
301
302    /// Declared static-resource origins.
303    pub fn resource_domains(&self) -> impl Iterator<Item = &str> {
304        self.resource_domains.iter().map(|origin| origin.0.as_str())
305    }
306
307    /// Declared nested-frame origins.
308    pub fn frame_domains(&self) -> impl Iterator<Item = &str> {
309        self.frame_domains.iter().map(|origin| origin.0.as_str())
310    }
311
312    /// Declared base-URI origins.
313    pub fn base_uri_domains(&self) -> impl Iterator<Item = &str> {
314        self.base_uri_domains.iter().map(|origin| origin.0.as_str())
315    }
316}
317
318fn push_unique(values: &mut Vec<CspOrigin>, value: CspOrigin) {
319    if !values.contains(&value) {
320        values.push(value);
321    }
322}
323
324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
325struct EmptyPermission {}
326
327/// Browser permissions requested by an MCP App.
328///
329/// Hosts may deny every requested permission; Apps must use feature detection
330/// and remain functional when permissions are unavailable.
331#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
332#[serde(rename_all = "camelCase")]
333pub struct McpUiPermissions {
334    #[serde(skip_serializing_if = "Option::is_none")]
335    camera: Option<EmptyPermission>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    microphone: Option<EmptyPermission>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    geolocation: Option<EmptyPermission>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    clipboard_write: Option<EmptyPermission>,
342}
343
344impl McpUiPermissions {
345    /// Request camera access.
346    pub fn camera(mut self) -> Self {
347        self.camera = Some(EmptyPermission {});
348        self
349    }
350
351    /// Request microphone access.
352    pub fn microphone(mut self) -> Self {
353        self.microphone = Some(EmptyPermission {});
354        self
355    }
356
357    /// Request geolocation access.
358    pub fn geolocation(mut self) -> Self {
359        self.geolocation = Some(EmptyPermission {});
360        self
361    }
362
363    /// Request clipboard-write access.
364    pub fn clipboard_write(mut self) -> Self {
365        self.clipboard_write = Some(EmptyPermission {});
366        self
367    }
368}
369
370/// A validated host-dependent dedicated sandbox domain.
371#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
372#[serde(transparent)]
373pub struct McpAppDomain(String);
374
375impl McpAppDomain {
376    /// Validate a bare DNS-style domain without a scheme, port, or path.
377    pub fn new(domain: impl Into<String>) -> Result<Self, McpAppError> {
378        let domain = domain.into();
379        let valid = !domain.is_empty()
380            && domain.len() <= 253
381            && domain.trim() == domain
382            && !domain.contains(['/', ':', '?', '#', '@'])
383            && domain.split('.').all(|label| {
384                let bytes = label.as_bytes();
385                !bytes.is_empty()
386                    && bytes.len() <= 63
387                    && bytes[0].is_ascii_alphanumeric()
388                    && bytes[bytes.len() - 1].is_ascii_alphanumeric()
389                    && bytes
390                        .iter()
391                        .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
392            });
393        if !valid {
394            return Err(McpAppError::InvalidDomain(domain));
395        }
396        Ok(Self(domain))
397    }
398
399    /// Borrow the dedicated domain.
400    pub fn as_str(&self) -> &str {
401        &self.0
402    }
403}
404
405/// Rendering and security metadata for a UI resource.
406#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
407#[serde(rename_all = "camelCase")]
408pub struct McpUiResourceMeta {
409    #[serde(skip_serializing_if = "Option::is_none")]
410    csp: Option<McpUiResourceCsp>,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    permissions: Option<McpUiPermissions>,
413    #[serde(skip_serializing_if = "Option::is_none")]
414    domain: Option<McpAppDomain>,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    prefers_border: Option<bool>,
417}
418
419impl McpUiResourceMeta {
420    /// Declare the external origins the App needs.
421    pub fn csp(mut self, csp: McpUiResourceCsp) -> Self {
422        self.csp = Some(csp);
423        self
424    }
425
426    /// Declare optional browser permissions.
427    pub fn permissions(mut self, permissions: McpUiPermissions) -> Self {
428        self.permissions = Some(permissions);
429        self
430    }
431
432    /// Request a host-specific dedicated sandbox domain.
433    pub fn domain(mut self, domain: McpAppDomain) -> Self {
434        self.domain = Some(domain);
435        self
436    }
437
438    /// Request whether the host displays a visible border/background.
439    pub fn prefers_border(mut self, prefers_border: bool) -> Self {
440        self.prefers_border = Some(prefers_border);
441        self
442    }
443}
444
445/// Who may see or call a UI-linked tool.
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
447#[serde(rename_all = "lowercase")]
448#[non_exhaustive]
449pub enum McpUiToolVisibility {
450    /// Visible and callable by the model/agent.
451    Model,
452    /// Callable by an App from the same server connection.
453    App,
454}
455
456/// MCP Apps metadata attached to a tool definition.
457#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
458#[serde(rename_all = "camelCase")]
459pub struct McpUiToolMeta {
460    resource_uri: McpAppUri,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    visibility: Option<Vec<McpUiToolVisibility>>,
463}
464
465impl McpUiToolMeta {
466    /// Link a tool to a UI resource.
467    ///
468    /// Omitted visibility uses the specification default of model + App.
469    pub fn new(resource_uri: McpAppUri) -> Self {
470        Self {
471            resource_uri,
472            visibility: None,
473        }
474    }
475
476    /// Make a tool callable only by Apps, not visible to the model.
477    pub fn app_only(resource_uri: McpAppUri) -> Self {
478        Self::new(resource_uri).visibility_unchecked(vec![McpUiToolVisibility::App])
479    }
480
481    /// Make a tool visible only to the model, not callable from Apps.
482    pub fn model_only(resource_uri: McpAppUri) -> Self {
483        Self::new(resource_uri).visibility_unchecked(vec![McpUiToolVisibility::Model])
484    }
485
486    /// Set an explicit non-empty, duplicate-free visibility policy.
487    pub fn visibility(
488        mut self,
489        visibility: impl IntoIterator<Item = McpUiToolVisibility>,
490    ) -> Result<Self, McpAppError> {
491        let visibility: Vec<_> = visibility.into_iter().collect();
492        let unique: HashSet<_> = visibility.iter().copied().collect();
493        if visibility.is_empty() || unique.len() != visibility.len() {
494            return Err(McpAppError::InvalidVisibility);
495        }
496        self.visibility = Some(visibility);
497        Ok(self)
498    }
499
500    fn visibility_unchecked(mut self, visibility: Vec<McpUiToolVisibility>) -> Self {
501        self.visibility = Some(visibility);
502        self
503    }
504
505    /// Linked UI resource URI.
506    pub fn resource_uri(&self) -> &McpAppUri {
507        &self.resource_uri
508    }
509
510    /// Effective visibility, including the model + App default.
511    pub fn effective_visibility(&self) -> impl Iterator<Item = McpUiToolVisibility> + '_ {
512        self.visibility
513            .as_deref()
514            .unwrap_or(&[McpUiToolVisibility::Model, McpUiToolVisibility::App])
515            .iter()
516            .copied()
517    }
518}
519
520/// Capability settings advertised for MCP Apps.
521#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
522#[serde(rename_all = "camelCase")]
523pub struct McpAppsCapabilitySettings {
524    mime_types: Vec<String>,
525}
526
527impl McpAppsCapabilitySettings {
528    /// Settings for the stable raw-HTML MCP Apps MVP.
529    pub fn html() -> Self {
530        Self {
531            mime_types: vec![MCP_APP_HTML_MIME_TYPE.to_string()],
532        }
533    }
534
535    /// Supported MIME types.
536    pub fn mime_types(&self) -> impl Iterator<Item = &str> {
537        self.mime_types.iter().map(String::as_str)
538    }
539}
540
541/// Build the validated core extension declaration for MCP Apps.
542pub fn mcp_apps_extension() -> ExtensionDeclaration {
543    ExtensionDeclaration::new(MCP_APPS_EXTENSION_ID, McpAppsCapabilitySettings::html())
544        .expect("the built-in MCP Apps extension declaration is valid")
545}
546
547impl McpRouter {
548    /// Advertise stable MCP Apps HTML support from this server.
549    pub fn with_mcp_apps(self) -> Self {
550        self.with_protocol_extension(mcp_apps_extension())
551    }
552}
553
554impl McpClientBuilder {
555    /// Advertise stable MCP Apps HTML support from this client/host.
556    pub fn with_mcp_apps(self) -> Self {
557        self.with_protocol_extension(mcp_apps_extension())
558    }
559}
560
561impl RequestContext {
562    /// Return whether both peers negotiated MCP Apps with the stable HTML MIME.
563    pub fn supports_mcp_apps(&self) -> bool {
564        let Some(extension) = self
565            .negotiated_extensions()
566            .and_then(|extensions| extensions.get(MCP_APPS_EXTENSION_ID))
567        else {
568            return false;
569        };
570        settings_support_html(extension.client_settings())
571            && settings_support_html(extension.server_settings())
572    }
573}
574
575fn settings_support_html(settings: &Value) -> bool {
576    settings
577        .get("mimeTypes")
578        .and_then(Value::as_array)
579        .is_some_and(|mime_types| {
580            mime_types
581                .iter()
582                .any(|mime_type| mime_type.as_str() == Some(MCP_APP_HTML_MIME_TYPE))
583        })
584}
585
586impl Tool {
587    /// Attach typed `_meta.ui` linkage and visibility to this built tool.
588    ///
589    /// Existing unrelated metadata keys are preserved. A prior `ui` key is
590    /// replaced by the typed value.
591    pub fn with_mcp_app(mut self, metadata: McpUiToolMeta) -> Result<Self, McpAppError> {
592        let ui = serde_json::to_value(metadata)?;
593        self.meta = Some(merge_ui_meta(self.meta.take(), ui)?);
594        Ok(self)
595    }
596}
597
598fn merge_ui_meta(existing: Option<Value>, ui: Value) -> Result<Value, McpAppError> {
599    let mut object = match existing {
600        Some(Value::Object(object)) => object,
601        Some(_) => return Err(McpAppError::Metadata(MetaValidationError::ExpectedObject)),
602        None => Map::new(),
603    };
604    object.insert("ui".to_string(), ui);
605    let meta = Value::Object(object);
606    validate_meta_object(&meta)?;
607    Ok(meta)
608}
609
610/// Builder for one predeclared raw-HTML MCP Apps resource.
611pub struct McpAppResourceBuilder {
612    uri: McpAppUri,
613    name: String,
614    description: Option<String>,
615    html: McpAppHtml,
616    metadata: McpUiResourceMeta,
617}
618
619impl McpAppResourceBuilder {
620    /// Create a UI resource with validated URI and HTML content.
621    pub fn new(
622        uri: impl Into<String>,
623        name: impl Into<String>,
624        html: impl Into<String>,
625    ) -> Result<Self, McpAppError> {
626        let name = name.into();
627        if name.trim().is_empty() {
628            return Err(McpAppError::EmptyField("resource name"));
629        }
630        Ok(Self {
631            uri: McpAppUri::new(uri)?,
632            name,
633            description: None,
634            html: McpAppHtml::new(html)?,
635            metadata: McpUiResourceMeta::default(),
636        })
637    }
638
639    /// Set a human-readable resource description.
640    pub fn description(mut self, description: impl Into<String>) -> Self {
641        self.description = Some(description.into());
642        self
643    }
644
645    /// Set typed UI security/rendering metadata.
646    pub fn metadata(mut self, metadata: McpUiResourceMeta) -> Self {
647        self.metadata = metadata;
648        self
649    }
650
651    /// Build a regular [`Resource`] with Apps metadata on both the declaration
652    /// and returned content.
653    pub fn build(self) -> Result<Resource, McpAppError> {
654        let uri = self.uri.to_string();
655        let html = self.html.0;
656        let meta = merge_ui_meta(None, serde_json::to_value(self.metadata)?)?;
657        let content_meta = meta.clone();
658        let content_uri = uri.clone();
659
660        let mut builder = ResourceBuilder::new(uri)
661            .name(self.name)
662            .mime_type(MCP_APP_HTML_MIME_TYPE);
663        if let Some(description) = self.description {
664            builder = builder.description(description);
665        }
666        let resource = builder
667            .handler(move || {
668                let uri = content_uri.clone();
669                let html = html.clone();
670                let meta = content_meta.clone();
671                async move {
672                    Ok(ReadResourceResult {
673                        contents: vec![ResourceContent {
674                            uri,
675                            mime_type: Some(MCP_APP_HTML_MIME_TYPE.to_string()),
676                            text: Some(html),
677                            blob: None,
678                            meta: Some(meta),
679                        }],
680                        ..ReadResourceResult::default()
681                    })
682                }
683            })
684            .build()
685            .with_meta(meta)?;
686        Ok(resource)
687    }
688}
689
690/// Construct an Apps-friendly tool result with a useful text-only fallback.
691///
692/// The fallback remains meaningful for hosts that did not negotiate MCP Apps;
693/// the structured content is available to a rendered App.
694pub fn mcp_app_tool_result(
695    fallback_text: impl Into<String>,
696    structured_content: impl Serialize,
697) -> Result<CallToolResult, serde_json::Error> {
698    Ok(CallToolResult {
699        content: vec![Content::text(fallback_text)],
700        is_error: false,
701        structured_content: Some(serde_json::to_value(structured_content)?),
702        meta: None,
703    })
704}
705
706#[cfg(test)]
707mod tests {
708    use std::collections::HashMap;
709
710    use serde_json::json;
711
712    use super::*;
713    use crate::protocol::{ClientCapabilities, ServerCapabilities};
714
715    const HTML: &str = "<!doctype html><html><body>weather</body></html>";
716
717    #[test]
718    fn ui_uri_rejects_non_ui_and_ambiguous_values() {
719        assert!(McpAppUri::new("ui://weather/dashboard").is_ok());
720        for invalid in [
721            "https://example.com/app",
722            "ui:///missing-authority",
723            "ui://user@example.com/app",
724            "ui://example.com/app?mode=wide",
725            "UI://example.com/app",
726        ] {
727            assert!(McpAppUri::new(invalid).is_err(), "{invalid}");
728        }
729    }
730
731    #[test]
732    fn html_requires_a_complete_document_shape() {
733        assert!(McpAppHtml::new(HTML).is_ok());
734        assert!(McpAppHtml::new("<div>fragment</div>").is_err());
735        assert!(McpAppHtml::new("<!doctype html><body>missing root</body>").is_err());
736    }
737
738    #[test]
739    fn csp_accepts_only_field_appropriate_origins() {
740        let csp = McpUiResourceCsp::default()
741            .allow_connect("wss://events.example.com")
742            .unwrap()
743            .allow_resource("https://*.cdn.example.com")
744            .unwrap()
745            .allow_frame("https://player.example.com")
746            .unwrap()
747            .allow_base_uri("https://assets.example.com/")
748            .unwrap();
749
750        assert_eq!(
751            serde_json::to_value(csp).unwrap(),
752            json!({
753                "connectDomains": ["wss://events.example.com"],
754                "resourceDomains": ["https://*.cdn.example.com"],
755                "frameDomains": ["https://player.example.com"],
756                "baseUriDomains": ["https://assets.example.com"]
757            })
758        );
759        assert!(
760            McpUiResourceCsp::default()
761                .allow_connect("https://example.com/api")
762                .is_err()
763        );
764        assert!(
765            McpUiResourceCsp::default()
766                .allow_frame("https://*.example.com")
767                .is_err()
768        );
769        assert!(
770            McpUiResourceCsp::default()
771                .allow_resource("data:text/javascript,alert(1)")
772                .is_err()
773        );
774    }
775
776    #[test]
777    fn permissions_serialize_as_empty_capability_objects() {
778        assert_eq!(
779            serde_json::to_value(
780                McpUiPermissions::default()
781                    .camera()
782                    .geolocation()
783                    .clipboard_write()
784            )
785            .unwrap(),
786            json!({"camera": {}, "geolocation": {}, "clipboardWrite": {}})
787        );
788    }
789
790    #[test]
791    fn visibility_is_typed_and_validated() {
792        let uri = McpAppUri::new("ui://weather/dashboard").unwrap();
793        assert_eq!(
794            McpUiToolMeta::new(uri.clone())
795                .effective_visibility()
796                .collect::<Vec<_>>(),
797            vec![McpUiToolVisibility::Model, McpUiToolVisibility::App]
798        );
799        assert!(McpUiToolMeta::new(uri.clone()).visibility([]).is_err());
800        assert!(
801            McpUiToolMeta::new(uri)
802                .visibility([McpUiToolVisibility::App, McpUiToolVisibility::App])
803                .is_err()
804        );
805    }
806
807    #[tokio::test]
808    async fn app_resource_uses_exact_wire_shape_on_definition_and_content() {
809        let metadata = McpUiResourceMeta::default()
810            .csp(
811                McpUiResourceCsp::default()
812                    .allow_connect("https://api.example.com")
813                    .unwrap(),
814            )
815            .permissions(McpUiPermissions::default().geolocation())
816            .domain(McpAppDomain::new("app.example.com").unwrap())
817            .prefers_border(true);
818        let resource = McpAppResourceBuilder::new("ui://weather/dashboard", "Weather", HTML)
819            .unwrap()
820            .metadata(metadata)
821            .build()
822            .unwrap();
823
824        let definition = serde_json::to_value(resource.definition()).unwrap();
825        assert_eq!(definition["mimeType"], MCP_APP_HTML_MIME_TYPE);
826        assert_eq!(definition["_meta"]["ui"]["prefersBorder"], true);
827        assert_eq!(
828            definition["_meta"]["ui"]["csp"]["connectDomains"][0],
829            "https://api.example.com"
830        );
831
832        let result = resource.read().await;
833        assert_eq!(result.contents[0].uri, "ui://weather/dashboard");
834        assert_eq!(
835            result.contents[0].mime_type.as_deref(),
836            Some(MCP_APP_HTML_MIME_TYPE)
837        );
838        assert_eq!(
839            result.contents[0].meta.as_ref().unwrap()["ui"]["permissions"]["geolocation"],
840            json!({})
841        );
842    }
843
844    #[test]
845    fn tool_metadata_preserves_other_extension_keys() {
846        let tool = crate::ToolBuilder::new("weather")
847            .handler(|()| async { Ok(CallToolResult::text("sunny")) })
848            .build()
849            .with_meta(json!({"com.example/audit": {"level": "full"}}))
850            .unwrap()
851            .with_mcp_app(McpUiToolMeta::app_only(
852                McpAppUri::new("ui://weather/dashboard").unwrap(),
853            ))
854            .unwrap();
855
856        let definition = serde_json::to_value(tool.definition()).unwrap();
857        assert_eq!(definition["_meta"]["com.example/audit"]["level"], "full");
858        assert_eq!(
859            definition["_meta"]["ui"]["resourceUri"],
860            "ui://weather/dashboard"
861        );
862        assert_eq!(definition["_meta"]["ui"]["visibility"], json!(["app"]));
863    }
864
865    #[test]
866    fn runtime_support_requires_both_peers_and_the_html_mime() {
867        let client = ClientCapabilities {
868            extensions: Some(HashMap::from([(
869                MCP_APPS_EXTENSION_ID.to_string(),
870                serde_json::to_value(McpAppsCapabilitySettings::html()).unwrap(),
871            )])),
872            ..ClientCapabilities::default()
873        };
874        let server = ServerCapabilities {
875            extensions: Some(HashMap::from([(
876                MCP_APPS_EXTENSION_ID.to_string(),
877                serde_json::to_value(McpAppsCapabilitySettings::html()).unwrap(),
878            )])),
879            ..ServerCapabilities::default()
880        };
881        let mut context = RequestContext::new(crate::protocol::RequestId::Number(1));
882        context
883            .extensions_mut()
884            .insert(crate::NegotiatedExtensions::from_capabilities(
885                &client, &server,
886            ));
887        assert!(context.supports_mcp_apps());
888
889        let mismatched_server = ServerCapabilities {
890            extensions: Some(HashMap::from([(
891                MCP_APPS_EXTENSION_ID.to_string(),
892                json!({"mimeTypes": ["text/plain"]}),
893            )])),
894            ..ServerCapabilities::default()
895        };
896        context
897            .extensions_mut()
898            .insert(crate::NegotiatedExtensions::from_capabilities(
899                &client,
900                &mismatched_server,
901            ));
902        assert!(!context.supports_mcp_apps());
903    }
904
905    #[test]
906    fn opt_in_uses_the_exact_reserved_declaration() {
907        let extension = mcp_apps_extension();
908        let encoded = serde_json::to_value(extension.settings()).unwrap();
909        assert_eq!(encoded["mimeTypes"][0], MCP_APP_HTML_MIME_TYPE);
910        assert_eq!(extension.identifier(), MCP_APPS_EXTENSION_ID);
911
912        // Both methods remain explicit runtime opt-ins and compile independently
913        // of the core protocol-version feature.
914        let _router = McpRouter::new().with_mcp_apps();
915        let _client = McpClientBuilder::new().with_mcp_apps();
916    }
917
918    #[test]
919    fn result_helper_always_carries_text_fallback() {
920        let result = mcp_app_tool_result("72 F and sunny", json!({"temperature": 72})).unwrap();
921        assert_eq!(result.first_text(), Some("72 F and sunny"));
922        assert_eq!(result.structured_content.unwrap()["temperature"], 72);
923    }
924}