1use serde_json::{Map, Value, json};
29
30use crate::context::Ctx;
31
32pub const EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
34
35pub const MIME_TYPE: &str = "text/html;profile=mcp-app";
37
38pub(crate) const URI_SCHEME: &str = "ui://";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[non_exhaustive]
44pub enum Visibility {
45 Model,
47 App,
49}
50
51impl Visibility {
52 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64#[non_exhaustive]
65pub enum Permission {
66 Camera,
68 Microphone,
70 Geolocation,
72 ClipboardWrite,
74}
75
76impl Permission {
77 #[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#[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 #[must_use]
116 pub fn new() -> Self {
117 Self::default()
118 }
119
120 #[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 #[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 #[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 #[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 #[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 #[must_use]
176 pub fn domain(mut self, domain: impl Into<String>) -> Self {
177 self.domain = Some(domain.into());
178 self
179 }
180
181 #[must_use]
183 pub fn prefers_border(mut self, border: bool) -> Self {
184 self.prefers_border = Some(border);
185 self
186 }
187
188 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 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
250fn is_safe_char(c: char) -> bool {
252 c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '*' | '_' | '[' | ']')
253}
254
255fn is_origin(origin: &str) -> bool {
257 let Some((scheme, host)) = origin.split_once("://") else {
258 return false;
259 };
260 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 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
275pub(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 meta.insert("ui/resourceUri".to_owned(), Value::from(uri));
300 }
301}
302
303pub(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 *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
320fn 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 #[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}