#[cfg(feature = "schema-generation")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct UIResource {
pub uri: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub mime_type: String,
}
impl UIResource {
pub fn new(uri: impl Into<String>, name: impl Into<String>, mime_type: UIMimeType) -> Self {
Self {
uri: uri.into(),
name: name.into(),
description: None,
mime_type: mime_type.as_str().to_string(),
}
}
pub fn html_mcp_app(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self::new(uri, name, UIMimeType::HtmlMcpApp)
}
#[deprecated(
since = "1.16.1",
note = "Use html_mcp_app() which produces text/html;profile=mcp-app recognized by Claude Desktop"
)]
pub fn html_mcp(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self::new(uri, name, UIMimeType::HtmlMcp)
}
pub fn html_skybridge(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self::new(uri, name, UIMimeType::HtmlSkybridge)
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn validate_uri(&self) -> crate::Result<()> {
if !self.uri.starts_with("ui://") {
return Err(crate::Error::validation(format!(
"UI resource URI must start with 'ui://', got: {}",
self.uri
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UIMimeType {
HtmlMcp,
HtmlSkybridge,
HtmlMcpApp,
}
impl UIMimeType {
pub fn as_str(&self) -> &'static str {
match self {
Self::HtmlMcp => "text/html+mcp",
Self::HtmlSkybridge => "text/html+skybridge",
Self::HtmlMcpApp => "text/html;profile=mcp-app",
}
}
pub fn is_chatgpt(&self) -> bool {
matches!(self, Self::HtmlSkybridge | Self::HtmlMcpApp)
}
pub fn is_mcp_apps(&self) -> bool {
matches!(self, Self::HtmlMcp | Self::HtmlMcpApp)
}
}
impl std::fmt::Display for UIMimeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for UIMimeType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"text/html+mcp" => Ok(Self::HtmlMcp),
"text/html+skybridge" => Ok(Self::HtmlSkybridge),
"text/html;profile=mcp-app" => Ok(Self::HtmlMcpApp),
_ => Err(format!("Unknown UI MIME type: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct UIResourceContents {
pub uri: String,
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Map<String, serde_json::Value>>,
}
impl UIResourceContents {
pub fn html(uri: impl Into<String>, html: impl Into<String>) -> Self {
Self {
uri: uri.into(),
mime_type: UIMimeType::HtmlMcpApp.as_str().to_string(),
text: Some(html.into()),
blob: None,
meta: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ToolUIMetadata {
pub ui_resource_uri: Option<String>,
pub additional: HashMap<String, serde_json::Value>,
}
pub fn deep_merge(
base: &mut serde_json::Map<String, serde_json::Value>,
overlay: serde_json::Map<String, serde_json::Value>,
) {
for (key, overlay_value) in overlay {
match base.get_mut(&key) {
Some(base_value) if base_value.is_object() && overlay_value.is_object() => {
let base_obj = base_value.as_object_mut().expect("checked is_object");
if let serde_json::Value::Object(overlay_obj) = overlay_value {
deep_merge(base_obj, overlay_obj);
}
},
Some(_existing) => {
tracing::debug!(key = %key, "deep_merge: overwriting existing _meta key");
base.insert(key, overlay_value);
},
None => {
base.insert(key, overlay_value);
},
}
}
}
pub const CHATGPT_DESCRIPTOR_KEYS: &[&str] = &[
"openai/outputTemplate",
"openai/toolInvocation/invoking",
"openai/toolInvocation/invoked",
"openai/widgetAccessible",
];
pub fn filter_to_descriptor_keys(
meta: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
meta.iter()
.filter(|(k, _)| CHATGPT_DESCRIPTOR_KEYS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
pub fn filter_meta_by_prefix(
meta: &serde_json::Map<String, serde_json::Value>,
prefix: &str,
) -> serde_json::Map<String, serde_json::Value> {
meta.iter()
.filter(|(k, _)| k.starts_with(prefix))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
pub fn emit_resource_uri_keys(ui_obj: &mut serde_json::Map<String, serde_json::Value>, uri: &str) {
let uri_val = serde_json::Value::String(uri.to_string());
ui_obj.insert("resourceUri".to_string(), uri_val);
}
pub(crate) const META_KEY_UI_RESOURCE_URI: &str = "ui/resourceUri";
pub(crate) fn insert_legacy_resource_uri_key(
meta: &mut serde_json::Map<String, serde_json::Value>,
uri: &str,
) {
meta.insert(
META_KEY_UI_RESOURCE_URI.to_string(),
serde_json::Value::String(uri.to_string()),
);
}
pub(crate) fn build_ui_meta(
ui_resource_uri: Option<&str>,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let uri = ui_resource_uri?;
Some(ToolUIMetadata::build_meta_map(uri))
}
impl ToolUIMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_ui_resource(mut self, uri: impl Into<String>) -> Self {
self.ui_resource_uri = Some(uri.into());
self
}
pub fn build_meta_map(uri: &str) -> serde_json::Map<String, serde_json::Value> {
let mut meta = serde_json::Map::with_capacity(2);
let mut ui_obj = serde_json::Map::with_capacity(1);
emit_resource_uri_keys(&mut ui_obj, uri);
meta.insert("ui".to_string(), serde_json::Value::Object(ui_obj));
insert_legacy_resource_uri_key(&mut meta, uri);
meta
}
pub fn from_metadata(metadata: &HashMap<String, serde_json::Value>) -> Self {
let ui_resource_uri = metadata
.get("ui")
.and_then(|v| v.get("resourceUri"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
metadata
.get(META_KEY_UI_RESOURCE_URI)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let additional = metadata
.iter()
.filter(|(k, _)| {
!matches!(
k.as_str(),
"ui" | META_KEY_UI_RESOURCE_URI | "openai/outputTemplate"
)
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Self {
ui_resource_uri,
additional,
}
}
pub fn to_metadata(&self) -> HashMap<String, serde_json::Value> {
let mut map = self.additional.clone();
if let Some(uri) = &self.ui_resource_uri {
let meta = Self::build_meta_map(uri);
map.extend(meta);
}
map
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_ui_resource_creation() {
let resource = UIResource::new("ui://test/resource", "Test Resource", UIMimeType::HtmlMcp);
assert_eq!(resource.uri, "ui://test/resource");
assert_eq!(resource.name, "Test Resource");
assert_eq!(resource.mime_type, "text/html+mcp");
assert_eq!(resource.description, None);
}
#[test]
fn test_ui_resource_with_description() {
let resource = UIResource::new("ui://test/resource", "Test", UIMimeType::HtmlMcp)
.with_description("A test resource");
assert_eq!(resource.description, Some("A test resource".to_string()));
}
#[test]
fn test_ui_resource_validation() {
let valid = UIResource::new("ui://valid", "Valid", UIMimeType::HtmlMcp);
assert!(valid.validate_uri().is_ok());
let invalid = UIResource {
uri: "http://invalid".to_string(),
name: "Invalid".to_string(),
description: None,
mime_type: "text/html+mcp".to_string(),
};
assert!(invalid.validate_uri().is_err());
}
#[test]
fn test_mime_type_conversions() {
use std::str::FromStr;
assert_eq!(UIMimeType::HtmlMcp.as_str(), "text/html+mcp");
assert_eq!(UIMimeType::HtmlSkybridge.as_str(), "text/html+skybridge");
assert_eq!(UIMimeType::HtmlMcpApp.as_str(), "text/html;profile=mcp-app");
assert_eq!(
UIMimeType::from_str("text/html+mcp"),
Ok(UIMimeType::HtmlMcp)
);
assert_eq!(
UIMimeType::from_str("text/html+skybridge"),
Ok(UIMimeType::HtmlSkybridge)
);
assert_eq!(
UIMimeType::from_str("text/html;profile=mcp-app"),
Ok(UIMimeType::HtmlMcpApp)
);
assert!(UIMimeType::from_str("invalid").is_err());
}
#[test]
fn test_mime_type_platform_checks() {
assert!(UIMimeType::HtmlSkybridge.is_chatgpt());
assert!(!UIMimeType::HtmlSkybridge.is_mcp_apps());
assert!(UIMimeType::HtmlMcp.is_mcp_apps());
assert!(!UIMimeType::HtmlMcp.is_chatgpt());
assert!(UIMimeType::HtmlMcpApp.is_chatgpt());
assert!(UIMimeType::HtmlMcpApp.is_mcp_apps());
}
#[test]
fn test_ui_resource_contents_html() {
let contents = UIResourceContents::html("ui://test", "<html>test</html>");
assert_eq!(contents.uri, "ui://test");
assert_eq!(contents.mime_type, "text/html;profile=mcp-app");
assert_eq!(contents.text, Some("<html>test</html>".to_string()));
assert_eq!(contents.blob, None);
}
#[test]
fn test_tool_ui_metadata_to_nested_format() {
let meta = ToolUIMetadata::new().with_ui_resource("ui://test");
assert_eq!(meta.ui_resource_uri, Some("ui://test".to_string()));
let map = meta.to_metadata();
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://test");
assert!(
!map.contains_key("openai/outputTemplate"),
"must NOT emit openai/outputTemplate in standard-only mode"
);
assert_eq!(
map.get("ui/resourceUri"),
Some(&serde_json::Value::String("ui://test".to_string())),
"must emit legacy flat ui/resourceUri key for Claude Desktop/ChatGPT"
);
}
#[test]
fn test_tool_ui_metadata_from_nested_format() {
let mut map = HashMap::new();
map.insert(
"ui".to_string(),
serde_json::json!({"resourceUri": "ui://test"}),
);
map.insert(
"custom".to_string(),
serde_json::Value::String("value".to_string()),
);
let meta = ToolUIMetadata::from_metadata(&map);
assert_eq!(meta.ui_resource_uri, Some("ui://test".to_string()));
assert_eq!(
meta.additional.get("custom"),
Some(&serde_json::Value::String("value".to_string()))
);
}
#[test]
fn test_deep_merge_disjoint_keys() {
let mut base = serde_json::Map::new();
base.insert("a".into(), json!(1));
let mut overlay = serde_json::Map::new();
overlay.insert("b".into(), json!(2));
super::deep_merge(&mut base, overlay);
assert_eq!(base.get("a"), Some(&json!(1)));
assert_eq!(base.get("b"), Some(&json!(2)));
}
#[test]
fn test_deep_merge_nested_objects() {
let mut base = serde_json::Map::new();
base.insert("ui".into(), json!({"resourceUri": "x"}));
let mut overlay = serde_json::Map::new();
overlay.insert("ui".into(), json!({"prefersBorder": true}));
super::deep_merge(&mut base, overlay);
let ui = base.get("ui").unwrap();
assert_eq!(ui["resourceUri"], "x");
assert_eq!(ui["prefersBorder"], true);
}
#[test]
fn test_deep_merge_leaf_collision_last_in_wins() {
let mut base = serde_json::Map::new();
base.insert("key".into(), json!("old"));
let mut overlay = serde_json::Map::new();
overlay.insert("key".into(), json!("new"));
super::deep_merge(&mut base, overlay);
assert_eq!(base.get("key"), Some(&json!("new")));
}
#[test]
fn test_deep_merge_array_replaced_not_concatenated() {
let mut base = serde_json::Map::new();
base.insert("tags".into(), json!(["a", "b"]));
let mut overlay = serde_json::Map::new();
overlay.insert("tags".into(), json!(["c"]));
super::deep_merge(&mut base, overlay);
assert_eq!(base.get("tags"), Some(&json!(["c"])));
}
#[test]
fn test_deep_merge_empty_overlay() {
let mut base = serde_json::Map::new();
base.insert("a".into(), json!(1));
let overlay = serde_json::Map::new();
super::deep_merge(&mut base, overlay);
assert_eq!(base.get("a"), Some(&json!(1)));
assert_eq!(base.len(), 1);
}
#[test]
fn test_deep_merge_empty_base() {
let mut base = serde_json::Map::new();
let mut overlay = serde_json::Map::new();
overlay.insert("b".into(), json!(2));
super::deep_merge(&mut base, overlay);
assert_eq!(base.get("b"), Some(&json!(2)));
}
#[test]
fn test_deep_merge_three_levels_deep() {
let mut base = serde_json::Map::new();
base.insert("a".into(), json!({"b": {"c": 1}}));
let mut overlay = serde_json::Map::new();
overlay.insert("a".into(), json!({"b": {"d": 2}}));
super::deep_merge(&mut base, overlay);
let a = base.get("a").unwrap();
assert_eq!(a["b"]["c"], 1);
assert_eq!(a["b"]["d"], 2);
}
#[test]
fn test_build_meta_map_emits_dual_keys() {
let map = ToolUIMetadata::build_meta_map("ui://chess/board");
let ui_obj = map.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://chess/board");
assert_eq!(
map.get("ui/resourceUri"),
Some(&serde_json::Value::String("ui://chess/board".to_string())),
"must emit legacy flat ui/resourceUri key for host compatibility"
);
assert!(
!map.contains_key("openai/outputTemplate"),
"must NOT emit openai/outputTemplate in standard-only mode"
);
assert_eq!(
map.len(),
2,
"build_meta_map must produce exactly 2 keys (ui + ui/resourceUri)"
);
}
#[test]
fn test_deep_merge_preserves_standard_key() {
let mut map = ToolUIMetadata::build_meta_map("ui://x");
let mut overlay = serde_json::Map::new();
overlay.insert("ui".into(), json!({"prefersBorder": true}));
super::deep_merge(&mut map, overlay);
assert_eq!(
map.get("ui/resourceUri"),
Some(&serde_json::Value::String("ui://x".to_string())),
"legacy flat key must survive deep merge"
);
let ui_obj = map.get("ui").unwrap();
assert_eq!(ui_obj["resourceUri"], "ui://x");
assert_eq!(ui_obj["prefersBorder"], true);
}
#[test]
fn test_emit_resource_uri_keys_standard_only() {
let mut ui_obj = serde_json::Map::new();
emit_resource_uri_keys(&mut ui_obj, "ui://test/widget");
assert_eq!(
ui_obj.get("resourceUri"),
Some(&serde_json::Value::String("ui://test/widget".to_string()))
);
}
#[test]
fn test_from_metadata_reads_both_nested_and_legacy() {
let mut nested = HashMap::new();
nested.insert("ui".to_string(), json!({"resourceUri": "ui://a"}));
let meta = ToolUIMetadata::from_metadata(&nested);
assert_eq!(meta.ui_resource_uri, Some("ui://a".to_string()));
let mut legacy = HashMap::new();
legacy.insert(
"ui/resourceUri".to_string(),
serde_json::Value::String("ui://b".to_string()),
);
let meta = ToolUIMetadata::from_metadata(&legacy);
assert_eq!(meta.ui_resource_uri, Some("ui://b".to_string()));
}
#[test]
fn test_tool_ui_metadata_from_legacy_flat_format() {
let mut map = HashMap::new();
map.insert(
"ui/resourceUri".to_string(),
serde_json::Value::String("ui://test".to_string()),
);
map.insert(
"custom".to_string(),
serde_json::Value::String("value".to_string()),
);
let meta = ToolUIMetadata::from_metadata(&map);
assert_eq!(meta.ui_resource_uri, Some("ui://test".to_string()));
assert_eq!(
meta.additional.get("custom"),
Some(&serde_json::Value::String("value".to_string()))
);
}
}