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