Skip to main content

flymcprust_core/
apps.rs

1//! MCP Apps (`io.modelcontextprotocol/ui`): tools that render an
2//! interactive HTML interface in the host.
3//!
4//! A server pairs a tool with a UI resource:
5//!
6//! - the resource has a `ui://` URI and the `text/html;profile=mcp-app` MIME
7//!   type, and returns an HTML5 document. Mark it with
8//!   [`ResourceDef::app`](crate::ResourceDef::app) (or `#[resource(.., app)]`),
9//!   which also describes what the sandboxed page may load ([`AppUi`]);
10//! - the tool names the resource with [`ToolDef::ui`](crate::ToolDef::ui)
11//!   (or `#[tool(ui = "ui://..")]`), and may restrict who can call it with
12//!   [`ToolDef::visibility`](crate::ToolDef::visibility).
13//!
14//! The server declares the extension in `server/discover` automatically.
15//! App-only tools (`visibility = ["app"]`) are listed and callable for every
16//! client: hosts that render interfaces hide them from the model, but a host
17//! without MCP Apps shows them like any tool. Visibility is not access
18//! control: protect sensitive tools with OAuth scopes.
19//!
20//! The page runs in the host's sandbox but can call tools through it: do not
21//! paste untrusted data into its HTML (cross-site scripting would let an
22//! attacker call tools as the user). Send data in tool results instead.
23//! Tools with a UI must still return meaningful `content`: hosts without
24//! MCP Apps support show that text, and the model reads it. Check
25//! [`Ctx::client_supports_apps`](crate::Ctx::client_supports_apps) to adapt
26//! a result.
27
28use serde_json::{Map, Value, json};
29
30use crate::context::Ctx;
31
32/// The extension identifier.
33pub const EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
34
35/// The MIME type of UI resources.
36pub const MIME_TYPE: &str = "text/html;profile=mcp-app";
37
38/// The URI scheme of UI resources.
39pub(crate) const URI_SCHEME: &str = "ui://";
40
41/// Who may call a tool.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[non_exhaustive]
44pub enum Visibility {
45    /// The model (the tool is in the agent's tool list).
46    Model,
47    /// The app's interface (the page can call it through the host).
48    App,
49}
50
51impl Visibility {
52    /// The wire value.
53    #[must_use]
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::Model => "model",
57            Self::App => "app",
58        }
59    }
60}
61
62/// A browser capability a UI asks the host for. Hosts may refuse.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64#[non_exhaustive]
65pub enum Permission {
66    /// The camera.
67    Camera,
68    /// The microphone.
69    Microphone,
70    /// The device's location.
71    Geolocation,
72    /// Writing to the clipboard.
73    ClipboardWrite,
74}
75
76impl Permission {
77    /// The wire key.
78    #[must_use]
79    pub fn as_str(self) -> &'static str {
80        match self {
81            Self::Camera => "camera",
82            Self::Microphone => "microphone",
83            Self::Geolocation => "geolocation",
84            Self::ClipboardWrite => "clipboardWrite",
85        }
86    }
87}
88
89/// How the host should sandbox a UI resource (`_meta.ui`).
90///
91/// By default the page may load nothing from the network, embed no frames
92/// and gets no extra permissions: declare every origin it needs. Hosts
93/// enforce this with a Content Security Policy.
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct AppUi {
96    connect_domains: Vec<String>,
97    resource_domains: Vec<String>,
98    frame_domains: Vec<String>,
99    base_uri_domains: Vec<String>,
100    permissions: Vec<Permission>,
101    domain: Option<String>,
102    prefers_border: Option<bool>,
103}
104
105fn strings<I, S>(items: I) -> Vec<String>
106where
107    I: IntoIterator<Item = S>,
108    S: Into<String>,
109{
110    items.into_iter().map(Into::into).collect()
111}
112
113impl AppUi {
114    /// No network access, no frames, no permissions.
115    #[must_use]
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Origins the page may connect to with `fetch`, XHR or WebSocket
121    /// (`connect-src`), e.g. `https://api.example.com`.
122    #[must_use]
123    pub fn connect_domains<I, S>(mut self, origins: I) -> Self
124    where
125        I: IntoIterator<Item = S>,
126        S: Into<String>,
127    {
128        self.connect_domains = strings(origins);
129        self
130    }
131
132    /// Origins of scripts, styles, images, fonts and media; wildcard
133    /// subdomains such as `https://*.example.com` are allowed.
134    #[must_use]
135    pub fn resource_domains<I, S>(mut self, origins: I) -> Self
136    where
137        I: IntoIterator<Item = S>,
138        S: Into<String>,
139    {
140        self.resource_domains = strings(origins);
141        self
142    }
143
144    /// Origins the page may embed in frames (`frame-src`).
145    #[must_use]
146    pub fn frame_domains<I, S>(mut self, origins: I) -> Self
147    where
148        I: IntoIterator<Item = S>,
149        S: Into<String>,
150    {
151        self.frame_domains = strings(origins);
152        self
153    }
154
155    /// Origins allowed in the document's `<base>` element (`base-uri`).
156    #[must_use]
157    pub fn base_uri_domains<I, S>(mut self, origins: I) -> Self
158    where
159        I: IntoIterator<Item = S>,
160        S: Into<String>,
161    {
162        self.base_uri_domains = strings(origins);
163        self
164    }
165
166    /// Browser capabilities the page asks for.
167    #[must_use]
168    pub fn permissions(mut self, permissions: impl IntoIterator<Item = Permission>) -> Self {
169        self.permissions = permissions.into_iter().collect();
170        self
171    }
172
173    /// A dedicated origin for the page; the format is host-specific (see
174    /// the host's documentation).
175    #[must_use]
176    pub fn domain(mut self, domain: impl Into<String>) -> Self {
177        self.domain = Some(domain.into());
178        self
179    }
180
181    /// Whether the host should draw a border around the page.
182    #[must_use]
183    pub fn prefers_border(mut self, border: bool) -> Self {
184        self.prefers_border = Some(border);
185        self
186    }
187
188    /// Checks the origins: hosts paste them into a Content Security
189    /// Policy, so separators or quotes could widen it.
190    pub(crate) fn validate(&self) -> Result<(), String> {
191        let lists = [
192            ("connect", &self.connect_domains),
193            ("resource", &self.resource_domains),
194            ("frame", &self.frame_domains),
195            ("base URI", &self.base_uri_domains),
196        ];
197        for (kind, origins) in lists {
198            for origin in origins {
199                if !is_origin(origin) {
200                    return Err(format!(
201                        "invalid {kind} origin `{origin}`: expected `scheme://host[:port]`, \
202                         such as `https://api.example.com` or `https://*.example.com`"
203                    ));
204                }
205            }
206        }
207        if let Some(domain) = &self.domain
208            && (domain.is_empty() || !domain.chars().all(is_safe_char))
209        {
210            return Err(format!("invalid domain `{domain}`"));
211        }
212        Ok(())
213    }
214
215    /// The `_meta.ui` object of the resource.
216    pub(crate) fn to_meta(&self) -> Value {
217        let mut ui = Map::new();
218        let mut csp = Map::new();
219        for (key, origins) in [
220            ("connectDomains", &self.connect_domains),
221            ("resourceDomains", &self.resource_domains),
222            ("frameDomains", &self.frame_domains),
223            ("baseUriDomains", &self.base_uri_domains),
224        ] {
225            if !origins.is_empty() {
226                csp.insert(key.to_owned(), json!(origins));
227            }
228        }
229        if !csp.is_empty() {
230            ui.insert("csp".to_owned(), Value::Object(csp));
231        }
232        if !self.permissions.is_empty() {
233            let permissions: Map<String, Value> = self
234                .permissions
235                .iter()
236                .map(|p| (p.as_str().to_owned(), json!({})))
237                .collect();
238            ui.insert("permissions".to_owned(), Value::Object(permissions));
239        }
240        if let Some(domain) = &self.domain {
241            ui.insert("domain".to_owned(), Value::from(domain.clone()));
242        }
243        if let Some(border) = self.prefers_border {
244            ui.insert("prefersBorder".to_owned(), Value::from(border));
245        }
246        Value::Object(ui)
247    }
248}
249
250/// Characters that cannot break out of a CSP source expression.
251fn is_safe_char(c: char) -> bool {
252    c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '*' | '_' | '[' | ']')
253}
254
255/// `scheme://host[:port]`, with an optional `*.` wildcard label.
256fn is_origin(origin: &str) -> bool {
257    let Some((scheme, host)) = origin.split_once("://") else {
258        return false;
259    };
260    // Web origins only: other schemes have no place in these directives.
261    let scheme_ok = matches!(scheme, "https" | "wss" | "http" | "ws");
262    let host_ok = !host.is_empty()
263        && host.chars().all(is_safe_char)
264        && match host.strip_prefix("*.") {
265            // A wildcard must keep at least two labels (`*.example.com`),
266            // not open a whole top-level domain (`*.com`).
267            Some(rest) => {
268                !rest.contains('*') && rest.split(':').next().is_some_and(|h| h.contains('.'))
269            }
270            None => !host.contains('*'),
271        };
272    scheme_ok && host_ok
273}
274
275/// The `_meta` entries of a tool with a UI or a restricted visibility.
276pub(crate) fn tool_meta(
277    meta: &mut Option<Map<String, Value>>,
278    resource_uri: Option<&str>,
279    visibility: Option<&[Visibility]>,
280) {
281    if resource_uri.is_none() && visibility.is_none() {
282        return;
283    }
284    let mut ui = Map::new();
285    if let Some(uri) = resource_uri {
286        ui.insert("resourceUri".to_owned(), Value::from(uri));
287    }
288    if let Some(visibility) = visibility {
289        ui.insert(
290            "visibility".to_owned(),
291            json!(visibility.iter().map(|v| v.as_str()).collect::<Vec<_>>()),
292        );
293    }
294    let meta = meta.get_or_insert_with(Map::new);
295    meta.insert("ui".to_owned(), Value::Object(ui));
296    if let Some(uri) = resource_uri {
297        // Deprecated flat key, still read by hosts that predate `_meta.ui`;
298        // the specification drops it before its final release.
299        meta.insert("ui/resourceUri".to_owned(), Value::from(uri));
300    }
301}
302
303/// Repeats an interface's `_meta.ui` in its read results and sets the MIME
304/// type when the handler left it out.
305pub(crate) fn mark_contents(contents: &mut [crate::resource::ResourceContents], ui: &Value) {
306    use crate::spec::ReadResourceResultContentsItem as Item;
307    for item in contents {
308        let (meta, mime) = match item {
309            Item::Text(t) => (&mut t.meta, &mut t.mime_type),
310            Item::Blob(b) => (&mut b.meta, &mut b.mime_type),
311        };
312        // Hosts render only this type; a handler cannot change it.
313        *mime = Some(MIME_TYPE.to_owned());
314        meta.get_or_insert_with(Map::new)
315            .entry("ui")
316            .or_insert_with(|| ui.clone());
317    }
318}
319
320/// Whether client capabilities declare MCP Apps with its MIME type.
321fn supported_by(capabilities: &crate::spec::ClientCapabilities) -> bool {
322    capabilities
323        .extensions
324        .as_ref()
325        .and_then(|e| e.get(EXTENSION_ID))
326        .and_then(|settings| settings.get("mimeTypes"))
327        .and_then(Value::as_array)
328        .is_some_and(|types| types.iter().any(|t| t.as_str() == Some(MIME_TYPE)))
329}
330
331impl Ctx {
332    /// Whether the client can render MCP Apps interfaces (it declared the
333    /// extension with the `text/html;profile=mcp-app` MIME type). A tool
334    /// can return a richer text answer when it cannot.
335    #[must_use]
336    pub fn client_supports_apps(&self) -> bool {
337        supported_by(self.client_capabilities())
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn metadata_shapes() {
347        let ui = AppUi::new()
348            .connect_domains(["https://api.example.com"])
349            .resource_domains(["https://*.cdn.example.com"])
350            .permissions([Permission::ClipboardWrite])
351            .prefers_border(true);
352        assert!(ui.validate().is_ok());
353        assert_eq!(
354            ui.to_meta(),
355            json!({
356                "csp": {
357                    "connectDomains": ["https://api.example.com"],
358                    "resourceDomains": ["https://*.cdn.example.com"],
359                },
360                "permissions": { "clipboardWrite": {} },
361                "prefersBorder": true,
362            })
363        );
364        assert_eq!(AppUi::new().to_meta(), json!({}));
365
366        let mut meta = None;
367        tool_meta(&mut meta, Some("ui://x/view"), Some(&[Visibility::App]));
368        assert_eq!(
369            Value::Object(meta.unwrap()),
370            json!({
371                "ui": { "resourceUri": "ui://x/view", "visibility": ["app"] },
372                "ui/resourceUri": "ui://x/view",
373            })
374        );
375    }
376
377    #[test]
378    fn origins_cannot_widen_the_policy() {
379        for bad in [
380            "api.example.com",
381            "https://a.example.com; script-src *",
382            "https://a.example.com 'unsafe-inline'",
383            "https://a.example.com/path",
384            "https://*",
385            "https://a.*.example.com",
386            "https://*.com",
387            "javascript://x",
388            "data://x",
389            "",
390        ] {
391            assert!(
392                AppUi::new().connect_domains([bad]).validate().is_err(),
393                "{bad}"
394            );
395        }
396        for good in [
397            "https://api.example.com",
398            "wss://live.example.com:8443",
399            "https://*.example.com",
400            "http://localhost:3000",
401        ] {
402            assert!(
403                AppUi::new().connect_domains([good]).validate().is_ok(),
404                "{good}"
405            );
406        }
407    }
408}