use serde_json::{Map, Value, json};
use crate::context::Ctx;
pub const EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
pub const MIME_TYPE: &str = "text/html;profile=mcp-app";
pub(crate) const URI_SCHEME: &str = "ui://";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Visibility {
Model,
App,
}
impl Visibility {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Model => "model",
Self::App => "app",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Permission {
Camera,
Microphone,
Geolocation,
ClipboardWrite,
}
impl Permission {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Camera => "camera",
Self::Microphone => "microphone",
Self::Geolocation => "geolocation",
Self::ClipboardWrite => "clipboardWrite",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AppUi {
connect_domains: Vec<String>,
resource_domains: Vec<String>,
frame_domains: Vec<String>,
base_uri_domains: Vec<String>,
permissions: Vec<Permission>,
domain: Option<String>,
prefers_border: Option<bool>,
}
fn strings<I, S>(items: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
items.into_iter().map(Into::into).collect()
}
impl AppUi {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn connect_domains<I, S>(mut self, origins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.connect_domains = strings(origins);
self
}
#[must_use]
pub fn resource_domains<I, S>(mut self, origins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.resource_domains = strings(origins);
self
}
#[must_use]
pub fn frame_domains<I, S>(mut self, origins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.frame_domains = strings(origins);
self
}
#[must_use]
pub fn base_uri_domains<I, S>(mut self, origins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.base_uri_domains = strings(origins);
self
}
#[must_use]
pub fn permissions(mut self, permissions: impl IntoIterator<Item = Permission>) -> Self {
self.permissions = permissions.into_iter().collect();
self
}
#[must_use]
pub fn domain(mut self, domain: impl Into<String>) -> Self {
self.domain = Some(domain.into());
self
}
#[must_use]
pub fn prefers_border(mut self, border: bool) -> Self {
self.prefers_border = Some(border);
self
}
pub(crate) fn validate(&self) -> Result<(), String> {
let lists = [
("connect", &self.connect_domains),
("resource", &self.resource_domains),
("frame", &self.frame_domains),
("base URI", &self.base_uri_domains),
];
for (kind, origins) in lists {
for origin in origins {
if !is_origin(origin) {
return Err(format!(
"invalid {kind} origin `{origin}`: expected `scheme://host[:port]`, \
such as `https://api.example.com` or `https://*.example.com`"
));
}
}
}
if let Some(domain) = &self.domain
&& (domain.is_empty() || !domain.chars().all(is_safe_char))
{
return Err(format!("invalid domain `{domain}`"));
}
Ok(())
}
pub(crate) fn to_meta(&self) -> Value {
let mut ui = Map::new();
let mut csp = Map::new();
for (key, origins) in [
("connectDomains", &self.connect_domains),
("resourceDomains", &self.resource_domains),
("frameDomains", &self.frame_domains),
("baseUriDomains", &self.base_uri_domains),
] {
if !origins.is_empty() {
csp.insert(key.to_owned(), json!(origins));
}
}
if !csp.is_empty() {
ui.insert("csp".to_owned(), Value::Object(csp));
}
if !self.permissions.is_empty() {
let permissions: Map<String, Value> = self
.permissions
.iter()
.map(|p| (p.as_str().to_owned(), json!({})))
.collect();
ui.insert("permissions".to_owned(), Value::Object(permissions));
}
if let Some(domain) = &self.domain {
ui.insert("domain".to_owned(), Value::from(domain.clone()));
}
if let Some(border) = self.prefers_border {
ui.insert("prefersBorder".to_owned(), Value::from(border));
}
Value::Object(ui)
}
}
fn is_safe_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '*' | '_' | '[' | ']')
}
fn is_origin(origin: &str) -> bool {
let Some((scheme, host)) = origin.split_once("://") else {
return false;
};
let scheme_ok = matches!(scheme, "https" | "wss" | "http" | "ws");
let host_ok = !host.is_empty()
&& host.chars().all(is_safe_char)
&& match host.strip_prefix("*.") {
Some(rest) => {
!rest.contains('*') && rest.split(':').next().is_some_and(|h| h.contains('.'))
}
None => !host.contains('*'),
};
scheme_ok && host_ok
}
pub(crate) fn tool_meta(
meta: &mut Option<Map<String, Value>>,
resource_uri: Option<&str>,
visibility: Option<&[Visibility]>,
) {
if resource_uri.is_none() && visibility.is_none() {
return;
}
let mut ui = Map::new();
if let Some(uri) = resource_uri {
ui.insert("resourceUri".to_owned(), Value::from(uri));
}
if let Some(visibility) = visibility {
ui.insert(
"visibility".to_owned(),
json!(visibility.iter().map(|v| v.as_str()).collect::<Vec<_>>()),
);
}
let meta = meta.get_or_insert_with(Map::new);
meta.insert("ui".to_owned(), Value::Object(ui));
if let Some(uri) = resource_uri {
meta.insert("ui/resourceUri".to_owned(), Value::from(uri));
}
}
pub(crate) fn mark_contents(contents: &mut [crate::resource::ResourceContents], ui: &Value) {
use crate::spec::ReadResourceResultContentsItem as Item;
for item in contents {
let (meta, mime) = match item {
Item::Text(t) => (&mut t.meta, &mut t.mime_type),
Item::Blob(b) => (&mut b.meta, &mut b.mime_type),
};
*mime = Some(MIME_TYPE.to_owned());
meta.get_or_insert_with(Map::new)
.entry("ui")
.or_insert_with(|| ui.clone());
}
}
fn supported_by(capabilities: &crate::spec::ClientCapabilities) -> bool {
capabilities
.extensions
.as_ref()
.and_then(|e| e.get(EXTENSION_ID))
.and_then(|settings| settings.get("mimeTypes"))
.and_then(Value::as_array)
.is_some_and(|types| types.iter().any(|t| t.as_str() == Some(MIME_TYPE)))
}
impl Ctx {
#[must_use]
pub fn client_supports_apps(&self) -> bool {
supported_by(self.client_capabilities())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_shapes() {
let ui = AppUi::new()
.connect_domains(["https://api.example.com"])
.resource_domains(["https://*.cdn.example.com"])
.permissions([Permission::ClipboardWrite])
.prefers_border(true);
assert!(ui.validate().is_ok());
assert_eq!(
ui.to_meta(),
json!({
"csp": {
"connectDomains": ["https://api.example.com"],
"resourceDomains": ["https://*.cdn.example.com"],
},
"permissions": { "clipboardWrite": {} },
"prefersBorder": true,
})
);
assert_eq!(AppUi::new().to_meta(), json!({}));
let mut meta = None;
tool_meta(&mut meta, Some("ui://x/view"), Some(&[Visibility::App]));
assert_eq!(
Value::Object(meta.unwrap()),
json!({
"ui": { "resourceUri": "ui://x/view", "visibility": ["app"] },
"ui/resourceUri": "ui://x/view",
})
);
}
#[test]
fn origins_cannot_widen_the_policy() {
for bad in [
"api.example.com",
"https://a.example.com; script-src *",
"https://a.example.com 'unsafe-inline'",
"https://a.example.com/path",
"https://*",
"https://a.*.example.com",
"https://*.com",
"javascript://x",
"data://x",
"",
] {
assert!(
AppUi::new().connect_domains([bad]).validate().is_err(),
"{bad}"
);
}
for good in [
"https://api.example.com",
"wss://live.example.com:8443",
"https://*.example.com",
"http://localhost:3000",
] {
assert!(
AppUi::new().connect_domains([good]).validate().is_ok(),
"{good}"
);
}
}
}