1use serde_json::{Map, Value, json};
28
29use crate::context::Ctx;
30
31pub const EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
33
34pub const MIME_TYPE: &str = "text/html;profile=mcp-app";
36
37pub(crate) const URI_SCHEME: &str = "ui://";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42#[non_exhaustive]
43pub enum Visibility {
44 Model,
46 App,
48}
49
50impl Visibility {
51 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63#[non_exhaustive]
64pub enum Permission {
65 Camera,
67 Microphone,
69 Geolocation,
71 ClipboardWrite,
73}
74
75impl Permission {
76 #[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#[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 #[must_use]
115 pub fn new() -> Self {
116 Self::default()
117 }
118
119 #[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 #[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 #[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 #[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 #[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 #[must_use]
175 pub fn domain(mut self, domain: impl Into<String>) -> Self {
176 self.domain = Some(domain.into());
177 self
178 }
179
180 #[must_use]
182 pub fn prefers_border(mut self, border: bool) -> Self {
183 self.prefers_border = Some(border);
184 self
185 }
186
187 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 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
249fn is_safe_char(c: char) -> bool {
251 c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '*' | '_' | '[' | ']')
252}
253
254fn is_origin(origin: &str) -> bool {
256 let Some((scheme, host)) = origin.split_once("://") else {
257 return false;
258 };
259 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 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
274pub(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 meta.insert("ui/resourceUri".to_owned(), Value::from(uri));
299 }
300}
301
302pub(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 *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
319pub(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 #[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}