Skip to main content

pmcp/types/
mcp_apps.rs

1// Allow doc_markdown since this module has many technical terms (ChatGPT, MCP-UI, etc.)
2#![allow(clippy::doc_markdown)]
3
4//! MCP Apps Extension types for interactive UI support.
5//!
6//! This module provides types for building interactive widgets that work across
7//! multiple MCP host platforms:
8//!
9//! - **ChatGPT Apps** (OpenAI Apps SDK) - Uses `text/html+skybridge` and `window.openai`
10//! - **MCP Apps (SEP-1865)** - Standard MCP extension using `text/html;profile=mcp-app`
11//! - **MCP-UI** - Community standard supporting HTML, URLs, and Remote DOM
12//!
13//! # Architecture
14//!
15//! The types in this module follow a layered architecture:
16//!
17//! 1. **Core Types** - Host-agnostic abstractions (`UIAction`, `WidgetCSP`, etc.)
18//! 2. **Platform-Specific Metadata** - ChatGPT-specific fields (`ChatGptToolMeta`, etc.)
19//! 3. **Adapters** - Transform core types for specific hosts (see `server::mcp_apps`)
20//!
21//! # Example
22//!
23//! ```rust
24//! use pmcp::types::mcp_apps::{WidgetCSP, ChatGptToolMeta, ToolVisibility};
25//!
26//! // Configure Content Security Policy
27//! let csp = WidgetCSP::new()
28//!     .connect("https://api.example.com")
29//!     .resources("https://cdn.example.com");
30//!
31//! // Configure ChatGPT-specific tool metadata
32//! let meta = ChatGptToolMeta::new()
33//!     .output_template("ui://widget/my-widget.html")
34//!     .invoking("Loading...")
35//!     .invoked("Ready!")
36//!     .widget_accessible(true);
37//! ```
38//!
39//! # Feature Flag
40//!
41//! This module requires the `mcp-apps` feature:
42//!
43//! ```toml
44//! [dependencies]
45//! pmcp = { version = "1.9", features = ["mcp-apps"] }
46//! ```
47
48use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50
51#[cfg(feature = "schema-generation")]
52use schemars::JsonSchema;
53
54// =============================================================================
55// Content Security Policy
56// =============================================================================
57
58/// Widget Content Security Policy for ChatGPT Apps.
59///
60/// Defines which domains the widget can interact with. ChatGPT enforces these
61/// restrictions in the sandboxed iframe environment.
62///
63/// # Example
64///
65/// ```rust
66/// use pmcp::types::mcp_apps::WidgetCSP;
67///
68/// let csp = WidgetCSP::new()
69///     .connect("https://api.example.com")      // Allow fetch/XHR
70///     .resources("https://cdn.example.com")    // Allow images, scripts
71///     .resources("https://*.oaistatic.com")    // OpenAI's CDN
72///     .redirect("https://checkout.example.com"); // Allow external redirects
73/// ```
74#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
75#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
76pub struct WidgetCSP {
77    /// Domains the widget can fetch from (connect-src).
78    ///
79    /// Use for API endpoints that the widget calls directly.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub connect_domains: Vec<String>,
82
83    /// Domains for static assets like images, fonts, and scripts.
84    ///
85    /// Use for CDN-hosted widget bundles and media.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub resource_domains: Vec<String>,
88
89    /// Domains for external redirects via `openExternal`.
90    ///
91    /// ChatGPT appends a `redirectUrl` query parameter to help external
92    /// flows return to the conversation.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub redirect_domains: Option<Vec<String>>,
95
96    /// Domains allowed for iframes within the widget.
97    ///
98    /// **Warning:** Using `frame_domains` is discouraged and triggers
99    /// extra scrutiny during ChatGPT App review. Only use when embedding
100    /// iframes is essential to your experience.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub frame_domains: Option<Vec<String>>,
103
104    /// Base URI domains for the widget.
105    ///
106    /// Maps to the spec's `McpUiResourceCsp.baseUriDomains` field.
107    /// Restricts which domains can be used as the base URI for relative URLs.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub base_uri_domains: Option<Vec<String>>,
110}
111
112impl WidgetCSP {
113    /// Create an empty CSP configuration.
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Add a connect domain (for fetch/XHR requests).
119    ///
120    /// # Example
121    ///
122    /// ```rust
123    /// use pmcp::types::mcp_apps::WidgetCSP;
124    ///
125    /// let csp = WidgetCSP::new()
126    ///     .connect("https://api.example.com")
127    ///     .connect("https://api2.example.com");
128    /// ```
129    pub fn connect(mut self, domain: impl Into<String>) -> Self {
130        self.connect_domains.push(domain.into());
131        self
132    }
133
134    /// Add a resource domain (for images, scripts, fonts, etc.).
135    ///
136    /// # Example
137    ///
138    /// ```rust
139    /// use pmcp::types::mcp_apps::WidgetCSP;
140    ///
141    /// let csp = WidgetCSP::new()
142    ///     .resources("https://cdn.example.com")
143    ///     .resources("https://*.cloudfront.net");
144    /// ```
145    pub fn resources(mut self, domain: impl Into<String>) -> Self {
146        self.resource_domains.push(domain.into());
147        self
148    }
149
150    /// Add a redirect domain (for `openExternal` navigation).
151    pub fn redirect(mut self, domain: impl Into<String>) -> Self {
152        self.redirect_domains
153            .get_or_insert_with(Vec::new)
154            .push(domain.into());
155        self
156    }
157
158    /// Add a frame domain (for iframes - use with caution).
159    ///
160    /// **Warning:** Widgets with `frame_domains` are subject to higher
161    /// scrutiny during review and may be rejected.
162    pub fn frame(mut self, domain: impl Into<String>) -> Self {
163        self.frame_domains
164            .get_or_insert_with(Vec::new)
165            .push(domain.into());
166        self
167    }
168
169    /// Add a base URI domain.
170    ///
171    /// Maps to the spec's `McpUiResourceCsp.baseUriDomains` field.
172    pub fn base_uri(mut self, domain: impl Into<String>) -> Self {
173        self.base_uri_domains
174            .get_or_insert_with(Vec::new)
175            .push(domain.into());
176        self
177    }
178
179    /// Serialize as a spec-compatible JSON map (ext-apps `McpUiResourceCsp` shape).
180    ///
181    /// Uses camelCase field names per spec. `redirect_domains` is excluded
182    /// because it is ChatGPT-specific and not part of the ext-apps spec.
183    pub fn to_spec_map(&self) -> serde_json::Map<String, serde_json::Value> {
184        let mut csp_obj = serde_json::Map::with_capacity(4);
185        if !self.connect_domains.is_empty() {
186            csp_obj.insert(
187                "connectDomains".into(),
188                serde_json::json!(self.connect_domains),
189            );
190        }
191        if !self.resource_domains.is_empty() {
192            csp_obj.insert(
193                "resourceDomains".into(),
194                serde_json::json!(self.resource_domains),
195            );
196        }
197        if let Some(frames) = &self.frame_domains {
198            if !frames.is_empty() {
199                csp_obj.insert("frameDomains".into(), serde_json::json!(frames));
200            }
201        }
202        if let Some(base_uris) = &self.base_uri_domains {
203            if !base_uris.is_empty() {
204                csp_obj.insert("baseUriDomains".into(), serde_json::json!(base_uris));
205            }
206        }
207        csp_obj
208    }
209
210    /// Check if the CSP has any domains configured.
211    pub fn is_empty(&self) -> bool {
212        self.connect_domains.is_empty()
213            && self.resource_domains.is_empty()
214            && self.redirect_domains.as_ref().is_none_or(Vec::is_empty)
215            && self.frame_domains.as_ref().is_none_or(Vec::is_empty)
216            && self.base_uri_domains.as_ref().is_none_or(Vec::is_empty)
217    }
218}
219
220// =============================================================================
221// Widget Metadata (Resource-level)
222// =============================================================================
223
224/// Widget configuration metadata for ChatGPT Apps.
225///
226/// These fields are added to the resource's `_meta` field and control
227/// how ChatGPT renders and configures the widget.
228///
229/// # Example
230///
231/// ```rust
232/// use pmcp::types::mcp_apps::{WidgetMeta, WidgetCSP};
233///
234/// // Complete _meta in one builder — no separate build_meta_map needed
235/// let meta = WidgetMeta::new()
236///     .resource_uri("ui://chess/board.html")
237///     .prefers_border(true)
238///     .domain("https://chatgpt.com")
239///     .description("Interactive chess board")
240///     .csp(WidgetCSP::new().connect("https://api.chess.com"))
241///     .to_meta_map();
242/// // => { "ui": { "resourceUri": "...", "prefersBorder": true, "domain": "...", "csp": {...} },
243/// //      "ui/resourceUri": "...", "openai/outputTemplate": "...",
244/// //      "openai/widgetPrefersBorder": true, "openai/widgetDomain": "...", ... }
245/// ```
246#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
247#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
248pub struct WidgetMeta {
249    /// UI resource URI for this widget.
250    ///
251    /// When set, `to_meta_map()` emits the standard `ui.resourceUri`
252    /// nested key, eliminating the need for a separate `build_meta_map` call.
253    /// Host-specific keys (e.g., `openai/outputTemplate`) are added by the
254    /// host-layer enrichment pipeline at server build time.
255    #[serde(skip)]
256    pub resource_uri: Option<String>,
257
258    /// Whether widget prefers a border around it.
259    #[serde(
260        rename = "openai/widgetPrefersBorder",
261        skip_serializing_if = "Option::is_none"
262    )]
263    pub prefers_border: Option<bool>,
264
265    /// Dedicated origin for the widget.
266    ///
267    /// When set, ChatGPT renders the widget under
268    /// `<domain>.web-sandbox.oaiusercontent.com`, enabling features
269    /// like API key allowlists and fullscreen punch-out.
270    #[serde(
271        rename = "openai/widgetDomain",
272        skip_serializing_if = "Option::is_none"
273    )]
274    pub domain: Option<String>,
275
276    /// Content Security Policy configuration.
277    #[serde(rename = "openai/widgetCSP", skip_serializing_if = "Option::is_none")]
278    pub csp: Option<WidgetCSP>,
279
280    /// Widget self-description.
281    ///
282    /// Reduces redundant text beneath the widget by letting it describe itself.
283    #[serde(
284        rename = "openai/widgetDescription",
285        skip_serializing_if = "Option::is_none"
286    )]
287    pub description: Option<String>,
288}
289
290impl WidgetMeta {
291    /// Create empty widget metadata.
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    /// Set the UI resource URI.
297    ///
298    /// When set, `to_meta_map()` emits the standard `ui.resourceUri` nested key.
299    /// Host-specific keys are added by the enrichment pipeline at build time.
300    pub fn resource_uri(mut self, uri: impl Into<String>) -> Self {
301        self.resource_uri = Some(uri.into());
302        self
303    }
304
305    /// Set border preference.
306    pub fn prefers_border(mut self, prefers: bool) -> Self {
307        self.prefers_border = Some(prefers);
308        self
309    }
310
311    /// Set widget domain for dedicated origin.
312    pub fn domain(mut self, domain: impl Into<String>) -> Self {
313        self.domain = Some(domain.into());
314        self
315    }
316
317    /// Set widget description.
318    pub fn description(mut self, desc: impl Into<String>) -> Self {
319        self.description = Some(desc.into());
320        self
321    }
322
323    /// Set Content Security Policy.
324    pub fn csp(mut self, csp: WidgetCSP) -> Self {
325        self.csp = Some(csp);
326        self
327    }
328
329    /// Convert to a `serde_json::Map` for merging into resource `_meta`.
330    ///
331    /// Produces both flat `openai/*` keys (via serde) and a nested `"ui"` object
332    /// containing MCP standard equivalents (`prefersBorder`, `domain`, `csp`).
333    ///
334    /// The nested `ui.csp` uses spec field names (`connectDomains`, `resourceDomains`,
335    /// `frameDomains`, `baseUriDomains`). Note that `redirect_domains` is ChatGPT-specific
336    /// and only appears in the flat `openai/widgetCSP` key, not in nested `ui.csp`.
337    pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
338        let mut map = match serde_json::to_value(self).ok() {
339            Some(serde_json::Value::Object(m)) => m,
340            _ => serde_json::Map::new(),
341        };
342        // Dual-emit: add nested ui object for MCP standard fields
343        let mut ui_obj = serde_json::Map::new();
344        if let Some(uri) = &self.resource_uri {
345            crate::types::ui::emit_resource_uri_keys(&mut ui_obj, uri);
346        }
347        if let Some(prefers) = self.prefers_border {
348            ui_obj.insert(
349                "prefersBorder".to_string(),
350                serde_json::Value::Bool(prefers),
351            );
352        }
353        if let Some(domain) = &self.domain {
354            ui_obj.insert(
355                "domain".to_string(),
356                serde_json::Value::String(domain.clone()),
357            );
358        }
359        if let Some(csp) = &self.csp {
360            let csp_obj = csp.to_spec_map();
361            if !csp_obj.is_empty() {
362                ui_obj.insert("csp".to_string(), serde_json::Value::Object(csp_obj));
363            }
364        }
365        if !ui_obj.is_empty() {
366            map.insert("ui".to_string(), serde_json::Value::Object(ui_obj));
367        }
368        // Legacy flat key for hosts that read "ui/resourceUri" directly
369        if let Some(uri) = &self.resource_uri {
370            crate::types::ui::insert_legacy_resource_uri_key(&mut map, uri);
371        }
372        map
373    }
374
375    /// Check if the metadata is empty (all fields are None).
376    pub fn is_empty(&self) -> bool {
377        self.resource_uri.is_none()
378            && self.prefers_border.is_none()
379            && self.domain.is_none()
380            && self.csp.is_none()
381            && self.description.is_none()
382    }
383}
384
385// =============================================================================
386// Tool Visibility
387// =============================================================================
388
389/// Tool visibility setting for ChatGPT Apps.
390///
391/// Controls whether a tool is visible to the model or only callable from widgets.
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
393#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
394#[serde(rename_all = "lowercase")]
395pub enum ToolVisibility {
396    /// Tool is visible to the model (default).
397    ///
398    /// The model can decide to call this tool based on user prompts.
399    #[default]
400    Public,
401
402    /// Tool is hidden from the model.
403    ///
404    /// Only callable from widgets via `window.openai.callTool()`.
405    /// Useful for internal widget operations that shouldn't be
406    /// triggered by user prompts.
407    Private,
408
409    /// Tool is visible to the model only, not callable from widgets.
410    ///
411    /// The model can call this tool but widgets cannot invoke it via UI actions.
412    ModelOnly,
413}
414
415impl ToolVisibility {
416    /// Convert to a spec-compatible visibility array.
417    ///
418    /// Returns the visibility as an array of audience strings matching
419    /// the ext-apps spec format:
420    /// - `Public` -> `["model", "app"]`
421    /// - `Private` -> `["app"]`
422    /// - `ModelOnly` -> `["model"]`
423    pub fn to_visibility_array(&self) -> &'static [&'static str] {
424        match self {
425            Self::Public => &["model", "app"],
426            Self::Private => &["app"],
427            Self::ModelOnly => &["model"],
428        }
429    }
430}
431
432// =============================================================================
433// Tool Metadata (Tool-level)
434// =============================================================================
435
436/// ChatGPT-specific tool metadata.
437///
438/// These fields are added to the tool's `_meta` field and control
439/// how ChatGPT presents and executes the tool.
440///
441/// # Example
442///
443/// ```rust
444/// use pmcp::types::mcp_apps::{ChatGptToolMeta, ToolVisibility};
445///
446/// let meta = ChatGptToolMeta::new()
447///     .output_template("ui://widget/kanban-board.html")
448///     .invoking("Preparing the board...")
449///     .invoked("Board ready!")
450///     .widget_accessible(true)
451///     .visibility(ToolVisibility::Public);
452/// ```
453#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
454#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
455pub struct ChatGptToolMeta {
456    /// UI template URI that ChatGPT loads when this tool is called.
457    ///
458    /// Must point to a resource with `text/html;profile=mcp-app` MIME type.
459    #[serde(
460        rename = "openai/outputTemplate",
461        skip_serializing_if = "Option::is_none"
462    )]
463    pub output_template: Option<String>,
464
465    /// Message shown while the tool is running.
466    ///
467    /// Example: "Loading data from server..."
468    #[serde(
469        rename = "openai/toolInvocation/invoking",
470        skip_serializing_if = "Option::is_none"
471    )]
472    pub invoking: Option<String>,
473
474    /// Message shown when the tool completes.
475    ///
476    /// Example: "Data loaded successfully!"
477    #[serde(
478        rename = "openai/toolInvocation/invoked",
479        skip_serializing_if = "Option::is_none"
480    )]
481    pub invoked: Option<String>,
482
483    /// Whether the widget can call this tool via `window.openai.callTool()`.
484    ///
485    /// Set to `true` to enable widget-initiated tool calls (e.g., refresh button).
486    #[serde(
487        rename = "openai/widgetAccessible",
488        skip_serializing_if = "Option::is_none"
489    )]
490    pub widget_accessible: Option<bool>,
491
492    /// Tool visibility to the model.
493    #[serde(rename = "openai/visibility", skip_serializing_if = "Option::is_none")]
494    pub visibility: Option<ToolVisibility>,
495
496    /// Names of parameters that accept file uploads.
497    ///
498    /// Each named parameter must be an object with `download_url` and `file_id` fields.
499    #[serde(rename = "openai/fileParams", skip_serializing_if = "Option::is_none")]
500    pub file_params: Option<Vec<String>>,
501}
502
503impl ChatGptToolMeta {
504    /// Create empty tool metadata.
505    pub fn new() -> Self {
506        Self::default()
507    }
508
509    /// Set the UI template URI.
510    pub fn output_template(mut self, uri: impl Into<String>) -> Self {
511        self.output_template = Some(uri.into());
512        self
513    }
514
515    /// Set the message shown while the tool is running.
516    pub fn invoking(mut self, msg: impl Into<String>) -> Self {
517        self.invoking = Some(msg.into());
518        self
519    }
520
521    /// Set the message shown when the tool completes.
522    pub fn invoked(mut self, msg: impl Into<String>) -> Self {
523        self.invoked = Some(msg.into());
524        self
525    }
526
527    /// Set whether the widget can call this tool.
528    pub fn widget_accessible(mut self, accessible: bool) -> Self {
529        self.widget_accessible = Some(accessible);
530        self
531    }
532
533    /// Set tool visibility.
534    pub fn visibility(mut self, visibility: ToolVisibility) -> Self {
535        self.visibility = Some(visibility);
536        self
537    }
538
539    /// Set file parameter names.
540    pub fn file_params(mut self, params: impl IntoIterator<Item = impl Into<String>>) -> Self {
541        self.file_params = Some(params.into_iter().map(Into::into).collect());
542        self
543    }
544
545    /// Convert to a `serde_json::Map` for merging into tool `_meta`.
546    ///
547    /// Produces both flat `openai/*` keys (via serde) and a nested `"ui"` object
548    /// containing the spec-compatible `visibility` array (e.g., `["model", "app"]`).
549    pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
550        let mut map = serde_json::to_value(self)
551            .ok()
552            .and_then(|v| v.as_object().cloned())
553            .unwrap_or_default();
554        // Dual-emit: nested ui.visibility array for MCP standard
555        if let Some(vis) = &self.visibility {
556            let mut ui_obj = serde_json::Map::with_capacity(1);
557            ui_obj.insert(
558                "visibility".to_string(),
559                serde_json::json!(vis.to_visibility_array()),
560            );
561            map.insert("ui".to_string(), serde_json::Value::Object(ui_obj));
562        }
563        map
564    }
565
566    /// Check if the metadata is empty (all fields are None).
567    pub fn is_empty(&self) -> bool {
568        self.output_template.is_none()
569            && self.invoking.is_none()
570            && self.invoked.is_none()
571            && self.widget_accessible.is_none()
572            && self.visibility.is_none()
573            && self.file_params.is_none()
574    }
575}
576
577// =============================================================================
578// Widget Response Metadata (Server-to-Widget Communication)
579// =============================================================================
580
581/// Metadata for tool responses in ChatGPT Apps.
582///
583/// These fields control widget behavior after a tool response is received.
584/// They are included in the `_meta` field of `CallToolResult`.
585///
586/// # Example
587///
588/// ```rust
589/// use pmcp::types::mcp_apps::WidgetResponseMeta;
590///
591/// // Close the widget after this response
592/// let meta = WidgetResponseMeta::new()
593///     .close_widget(true)
594///     .widget_session_id("session-abc123");
595/// ```
596#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
597#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
598pub struct WidgetResponseMeta {
599    /// Close the widget when this response arrives.
600    ///
601    /// When set to `true`, ChatGPT will hide the widget after processing
602    /// this tool response. Useful for "done" or "cancel" actions.
603    #[serde(rename = "openai/closeWidget", skip_serializing_if = "Option::is_none")]
604    pub close_widget: Option<bool>,
605
606    /// Widget session ID for correlating tool calls.
607    ///
608    /// This ID is unique per widget instance and can be used to correlate
609    /// multiple tool calls from the same widget session for logging or analytics.
610    #[serde(
611        rename = "openai/widgetSessionId",
612        skip_serializing_if = "Option::is_none"
613    )]
614    pub widget_session_id: Option<String>,
615}
616
617impl WidgetResponseMeta {
618    /// Create empty response metadata.
619    pub fn new() -> Self {
620        Self::default()
621    }
622
623    /// Set whether to close the widget.
624    pub fn close_widget(mut self, close: bool) -> Self {
625        self.close_widget = Some(close);
626        self
627    }
628
629    /// Set the widget session ID.
630    pub fn widget_session_id(mut self, id: impl Into<String>) -> Self {
631        self.widget_session_id = Some(id.into());
632        self
633    }
634
635    /// Convert to a serde_json::Map for merging into tool result `_meta`.
636    pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
637        serde_json::to_value(self)
638            .ok()
639            .and_then(|v| v.as_object().cloned())
640            .unwrap_or_default()
641    }
642
643    /// Check if the metadata is empty (all fields are None).
644    pub fn is_empty(&self) -> bool {
645        self.close_widget.is_none() && self.widget_session_id.is_none()
646    }
647}
648
649// =============================================================================
650// UI Actions (Widget-to-Host Communication)
651// =============================================================================
652
653/// Notification severity level for UI notifications.
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
655#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
656#[serde(rename_all = "lowercase")]
657pub enum NotifyLevel {
658    /// Informational message.
659    Info,
660    /// Success message.
661    Success,
662    /// Warning message.
663    Warning,
664    /// Error message.
665    Error,
666}
667
668/// UI actions that widgets can emit to communicate with the host.
669///
670/// This is a superset of actions supported across all platforms:
671/// - **MCP Apps**: `Tool` only
672/// - **ChatGPT Apps**: `Tool`, `SetState`, `SendMessage`
673/// - **MCP-UI**: `Tool`, `Prompt`, `Intent`, `Notify`, `Link`
674///
675/// # Example
676///
677/// ```rust
678/// use pmcp::types::mcp_apps::UIAction;
679/// use serde_json::json;
680///
681/// // Call an MCP tool
682/// let action = UIAction::Tool {
683///     name: "chess_move".to_string(),
684///     arguments: json!({ "from": "e2", "to": "e4" }),
685///     message_id: Some("req-123".to_string()),
686/// };
687///
688/// // Update widget state (ChatGPT)
689/// let action = UIAction::SetState {
690///     state: json!({ "selectedPiece": "e2" }),
691/// };
692/// ```
693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
694#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
695#[serde(tag = "type", rename_all = "camelCase")]
696pub enum UIAction {
697    /// Call an MCP tool.
698    ///
699    /// Supported by: MCP Apps, ChatGPT Apps, MCP-UI
700    #[serde(rename_all = "camelCase")]
701    Tool {
702        /// Tool name to invoke.
703        name: String,
704        /// Tool arguments.
705        arguments: serde_json::Value,
706        /// Optional message ID for async response correlation.
707        #[serde(skip_serializing_if = "Option::is_none")]
708        message_id: Option<String>,
709    },
710
711    /// Send a message to the AI (MCP-UI).
712    ///
713    /// Supported by: MCP-UI, ChatGPT Apps (as `sendFollowUpMessage`)
714    Prompt {
715        /// Text to send to the AI.
716        text: String,
717    },
718
719    /// High-level intent action (MCP-UI).
720    ///
721    /// Supported by: MCP-UI only
722    Intent {
723        /// Intent action name.
724        action: String,
725        /// Intent data.
726        data: serde_json::Value,
727    },
728
729    /// Notification to the user (MCP-UI).
730    ///
731    /// Supported by: MCP-UI only
732    Notify {
733        /// Notification severity.
734        level: NotifyLevel,
735        /// Notification message.
736        message: String,
737    },
738
739    /// Navigation link (MCP-UI, limited support).
740    ///
741    /// Supported by: MCP-UI (limited), ChatGPT Apps (via `openExternal`)
742    Link {
743        /// URL to navigate to.
744        url: String,
745    },
746
747    /// Update widget state (ChatGPT Apps).
748    ///
749    /// Supported by: ChatGPT Apps only (via `setWidgetState`)
750    SetState {
751        /// New state object.
752        state: serde_json::Value,
753    },
754
755    /// Send follow-up message (ChatGPT Apps).
756    ///
757    /// Supported by: ChatGPT Apps only
758    SendMessage {
759        /// Message text.
760        message: String,
761    },
762}
763
764// =============================================================================
765// Extended UI MIME Types
766// =============================================================================
767
768/// Extended MIME types for MCP Apps.
769///
770/// Includes support for ChatGPT's Skybridge format and MCP-UI's Remote DOM.
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
772pub enum ExtendedUIMimeType {
773    /// Standard MCP Apps HTML (`text/html+mcp`).
774    ///
775    /// Widgets communicate via postMessage with JSON-RPC protocol.
776    HtmlMcp,
777
778    /// ChatGPT Apps Skybridge HTML (`text/html+skybridge`).
779    ///
780    /// Required by ChatGPT for widget resource recognition in the Templates section.
781    /// ChatGPT injects `window.openai` API for widget communication.
782    HtmlSkybridge,
783
784    /// HTML with MCP App profile (`text/html;profile=mcp-app`).
785    ///
786    /// Alternative profile-based MIME type. ChatGPT currently requires
787    /// `HtmlSkybridge` instead for Templates section rendering.
788    HtmlMcpApp,
789
790    /// Plain HTML for MCP-UI hosts (`text/html`).
791    HtmlPlain,
792
793    /// URL list for MCP-UI (`text/uri-list`).
794    ///
795    /// First valid HTTP(S) URL is loaded in an iframe.
796    UriList,
797
798    /// Remote DOM for MCP-UI (`application/vnd.mcp-ui.remote-dom+javascript`).
799    ///
800    /// JavaScript-based UI using Shopify's Remote DOM library.
801    RemoteDom,
802
803    /// Remote DOM with React framework.
804    RemoteDomReact,
805}
806
807impl ExtendedUIMimeType {
808    /// Get the MIME type string.
809    pub fn as_str(&self) -> &'static str {
810        match self {
811            Self::HtmlMcp => "text/html+mcp",
812            Self::HtmlSkybridge => "text/html+skybridge",
813            Self::HtmlMcpApp => "text/html;profile=mcp-app",
814            Self::HtmlPlain => "text/html",
815            Self::UriList => "text/uri-list",
816            Self::RemoteDom => "application/vnd.mcp-ui.remote-dom+javascript",
817            Self::RemoteDomReact => "application/vnd.mcp-ui.remote-dom+javascript; framework=react",
818        }
819    }
820
821    /// Check if this MIME type is for ChatGPT Apps.
822    pub fn is_chatgpt(&self) -> bool {
823        matches!(self, Self::HtmlSkybridge | Self::HtmlMcpApp)
824    }
825
826    /// Check if this MIME type is for standard MCP Apps.
827    pub fn is_mcp_apps(&self) -> bool {
828        matches!(self, Self::HtmlMcp | Self::HtmlMcpApp)
829    }
830
831    /// Check if this MIME type is for MCP-UI.
832    pub fn is_mcp_ui(&self) -> bool {
833        matches!(
834            self,
835            Self::HtmlPlain | Self::UriList | Self::RemoteDom | Self::RemoteDomReact
836        )
837    }
838}
839
840impl std::fmt::Display for ExtendedUIMimeType {
841    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
842        write!(f, "{}", self.as_str())
843    }
844}
845
846impl std::str::FromStr for ExtendedUIMimeType {
847    type Err = String;
848
849    fn from_str(s: &str) -> Result<Self, Self::Err> {
850        match s {
851            "text/html+mcp" => Ok(Self::HtmlMcp),
852            "text/html+skybridge" => Ok(Self::HtmlSkybridge),
853            "text/html;profile=mcp-app" => Ok(Self::HtmlMcpApp),
854            "text/html" => Ok(Self::HtmlPlain),
855            "text/uri-list" => Ok(Self::UriList),
856            "application/vnd.mcp-ui.remote-dom+javascript" => Ok(Self::RemoteDom),
857            s if s.starts_with("application/vnd.mcp-ui.remote-dom+javascript") => {
858                if s.contains("framework=react") {
859                    Ok(Self::RemoteDomReact)
860                } else {
861                    Ok(Self::RemoteDom)
862                }
863            },
864            _ => Err(format!("Unknown UI MIME type: {}", s)),
865        }
866    }
867}
868
869// =============================================================================
870// UIMimeType <-> ExtendedUIMimeType Bridge
871// =============================================================================
872
873/// Infallible conversion from [`UIMimeType`](crate::types::ui::UIMimeType) to
874/// [`ExtendedUIMimeType`].
875///
876/// Every `UIMimeType` variant has a matching `ExtendedUIMimeType` variant,
877/// so this conversion always succeeds.
878impl From<crate::types::ui::UIMimeType> for ExtendedUIMimeType {
879    fn from(value: crate::types::ui::UIMimeType) -> Self {
880        match value {
881            crate::types::ui::UIMimeType::HtmlMcp => Self::HtmlMcp,
882            crate::types::ui::UIMimeType::HtmlSkybridge => Self::HtmlSkybridge,
883            crate::types::ui::UIMimeType::HtmlMcpApp => Self::HtmlMcpApp,
884        }
885    }
886}
887
888/// Fallible conversion from [`ExtendedUIMimeType`] to
889/// [`UIMimeType`](crate::types::ui::UIMimeType).
890///
891/// Only the three shared variants (`HtmlMcp`, `HtmlSkybridge`, `HtmlMcpApp`)
892/// can be converted. Extended-only variants (`HtmlPlain`, `UriList`,
893/// `RemoteDom`, `RemoteDomReact`) return a descriptive error.
894impl TryFrom<ExtendedUIMimeType> for crate::types::ui::UIMimeType {
895    type Error = String;
896
897    fn try_from(value: ExtendedUIMimeType) -> Result<Self, Self::Error> {
898        match value {
899            ExtendedUIMimeType::HtmlMcp => Ok(Self::HtmlMcp),
900            ExtendedUIMimeType::HtmlSkybridge => Ok(Self::HtmlSkybridge),
901            ExtendedUIMimeType::HtmlMcpApp => Ok(Self::HtmlMcpApp),
902            other => Err(format!(
903                "Cannot convert {} to UIMimeType (extended-only variant)",
904                other
905            )),
906        }
907    }
908}
909
910// =============================================================================
911// UI Content Types
912// =============================================================================
913
914/// Content types for UI resources.
915///
916/// Represents the different ways UI content can be delivered.
917#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
918#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
919#[serde(tag = "type", rename_all = "camelCase")]
920pub enum UIContent {
921    /// Inline HTML content.
922    Html {
923        /// The HTML content.
924        html: String,
925    },
926
927    /// External URL to load.
928    Url {
929        /// The URL to load in an iframe.
930        url: String,
931    },
932
933    /// Remote DOM JavaScript (MCP-UI).
934    #[cfg(feature = "mcp-apps")]
935    RemoteDom {
936        /// JavaScript code defining the UI.
937        script: String,
938        /// Framework to use for rendering.
939        framework: RemoteDomFramework,
940    },
941}
942
943/// Framework for Remote DOM rendering.
944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
945#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
946#[serde(rename_all = "lowercase")]
947pub enum RemoteDomFramework {
948    /// Web Components (default).
949    #[default]
950    WebComponents,
951    /// React.
952    React,
953}
954
955// =============================================================================
956// Universal UI Resource
957// =============================================================================
958
959/// Widget dimensions configuration.
960#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
961#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
962pub struct UIDimensions {
963    /// Preferred width in pixels.
964    #[serde(skip_serializing_if = "Option::is_none")]
965    pub width: Option<u32>,
966    /// Preferred height in pixels.
967    #[serde(skip_serializing_if = "Option::is_none")]
968    pub height: Option<u32>,
969    /// Minimum width in pixels.
970    #[serde(skip_serializing_if = "Option::is_none")]
971    pub min_width: Option<u32>,
972    /// Minimum height in pixels.
973    #[serde(skip_serializing_if = "Option::is_none")]
974    pub min_height: Option<u32>,
975    /// Maximum width in pixels.
976    #[serde(skip_serializing_if = "Option::is_none")]
977    pub max_width: Option<u32>,
978    /// Maximum height in pixels.
979    #[serde(skip_serializing_if = "Option::is_none")]
980    pub max_height: Option<u32>,
981}
982
983/// Universal UI metadata (merged from all platforms).
984#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
985#[cfg_attr(feature = "schema-generation", derive(JsonSchema))]
986pub struct UIMetadata {
987    /// Widget description.
988    #[serde(skip_serializing_if = "Option::is_none")]
989    pub description: Option<String>,
990
991    /// Preferred dimensions.
992    #[serde(skip_serializing_if = "Option::is_none")]
993    pub dimensions: Option<UIDimensions>,
994
995    /// Initial data to pass to the widget.
996    #[serde(skip_serializing_if = "Option::is_none")]
997    pub initial_data: Option<serde_json::Value>,
998
999    /// Content Security Policy (ChatGPT).
1000    #[serde(skip_serializing_if = "Option::is_none")]
1001    pub csp: Option<WidgetCSP>,
1002
1003    /// Additional host-specific metadata.
1004    #[serde(flatten)]
1005    pub extra: HashMap<String, serde_json::Value>,
1006}
1007
1008// =============================================================================
1009// Host Type Detection
1010// =============================================================================
1011
1012/// Known MCP host types.
1013#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1014pub enum HostType {
1015    /// OpenAI ChatGPT.
1016    ChatGpt,
1017    /// Anthropic Claude.
1018    Claude,
1019    /// Nanobot (MCP-UI).
1020    Nanobot,
1021    /// MCPJam (MCP-UI).
1022    McpJam,
1023    /// Generic MCP host.
1024    Generic,
1025}
1026
1027impl HostType {
1028    /// Get the preferred MIME type for this host.
1029    pub fn preferred_mime_type(&self) -> ExtendedUIMimeType {
1030        match self {
1031            Self::ChatGpt => ExtendedUIMimeType::HtmlSkybridge,
1032            // Official MCP Apps MIME: text/html;profile=mcp-app
1033            Self::Claude | Self::Generic => ExtendedUIMimeType::HtmlMcpApp,
1034            Self::Nanobot | Self::McpJam => ExtendedUIMimeType::HtmlPlain,
1035        }
1036    }
1037
1038    /// Check if this host supports the given MIME type.
1039    pub fn supports_mime_type(&self, mime_type: ExtendedUIMimeType) -> bool {
1040        match self {
1041            Self::ChatGpt => matches!(
1042                mime_type,
1043                ExtendedUIMimeType::HtmlSkybridge | ExtendedUIMimeType::HtmlMcpApp
1044            ),
1045            // Accept both legacy text/html+mcp and official text/html;profile=mcp-app
1046            Self::Claude | Self::Generic => matches!(
1047                mime_type,
1048                ExtendedUIMimeType::HtmlMcp | ExtendedUIMimeType::HtmlMcpApp
1049            ),
1050            Self::Nanobot | Self::McpJam => mime_type.is_mcp_ui(),
1051        }
1052    }
1053}
1054
1055// =============================================================================
1056// Tests
1057// =============================================================================
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062    use serde_json::json;
1063
1064    #[test]
1065    fn test_widget_csp_builder() {
1066        let csp = WidgetCSP::new()
1067            .connect("https://api.example.com")
1068            .connect("https://api2.example.com")
1069            .resources("https://cdn.example.com")
1070            .redirect("https://checkout.example.com")
1071            .frame("https://embed.example.com");
1072
1073        assert_eq!(csp.connect_domains.len(), 2);
1074        assert_eq!(csp.resource_domains.len(), 1);
1075        assert_eq!(csp.redirect_domains.as_ref().unwrap().len(), 1);
1076        assert_eq!(csp.frame_domains.as_ref().unwrap().len(), 1);
1077    }
1078
1079    #[test]
1080    fn test_widget_csp_serialization() {
1081        let csp = WidgetCSP::new()
1082            .connect("https://api.example.com")
1083            .resources("https://cdn.example.com");
1084
1085        let json = serde_json::to_value(&csp).unwrap();
1086        assert_eq!(json["connect_domains"][0], "https://api.example.com");
1087        assert_eq!(json["resource_domains"][0], "https://cdn.example.com");
1088        assert!(json.get("redirect_domains").is_none());
1089        assert!(json.get("frame_domains").is_none());
1090    }
1091
1092    #[test]
1093    fn test_widget_meta_builder() {
1094        let meta = WidgetMeta::new()
1095            .prefers_border(true)
1096            .domain("https://chatgpt.com")
1097            .description("Test widget")
1098            .csp(WidgetCSP::new().connect("https://api.example.com"));
1099
1100        assert_eq!(meta.prefers_border, Some(true));
1101        assert_eq!(meta.domain, Some("https://chatgpt.com".to_string()));
1102        assert_eq!(meta.description, Some("Test widget".to_string()));
1103        assert!(meta.csp.is_some());
1104    }
1105
1106    #[test]
1107    fn test_widget_meta_serialization() {
1108        let meta = WidgetMeta::new().prefers_border(true).description("Test");
1109
1110        let json = serde_json::to_value(&meta).unwrap();
1111        assert_eq!(json["openai/widgetPrefersBorder"], true);
1112        assert_eq!(json["openai/widgetDescription"], "Test");
1113    }
1114
1115    #[test]
1116    fn test_chatgpt_tool_meta_builder() {
1117        let meta = ChatGptToolMeta::new()
1118            .output_template("ui://widget/test.html")
1119            .invoking("Loading...")
1120            .invoked("Done!")
1121            .widget_accessible(true)
1122            .visibility(ToolVisibility::Public)
1123            .file_params(vec!["imageFile", "documentFile"]);
1124
1125        assert_eq!(
1126            meta.output_template,
1127            Some("ui://widget/test.html".to_string())
1128        );
1129        assert_eq!(meta.invoking, Some("Loading...".to_string()));
1130        assert_eq!(meta.invoked, Some("Done!".to_string()));
1131        assert_eq!(meta.widget_accessible, Some(true));
1132        assert_eq!(meta.visibility, Some(ToolVisibility::Public));
1133        assert_eq!(meta.file_params.as_ref().unwrap().len(), 2);
1134    }
1135
1136    #[test]
1137    fn test_chatgpt_tool_meta_serialization() {
1138        let meta = ChatGptToolMeta::new()
1139            .output_template("ui://widget/test.html")
1140            .invoking("Loading...")
1141            .widget_accessible(true)
1142            .visibility(ToolVisibility::Private);
1143
1144        let json = serde_json::to_value(&meta).unwrap();
1145        assert_eq!(json["openai/outputTemplate"], "ui://widget/test.html");
1146        assert_eq!(json["openai/toolInvocation/invoking"], "Loading...");
1147        assert_eq!(json["openai/widgetAccessible"], true);
1148        assert_eq!(json["openai/visibility"], "private");
1149    }
1150
1151    #[test]
1152    fn test_tool_visibility_serialization() {
1153        assert_eq!(
1154            serde_json::to_value(ToolVisibility::Public).unwrap(),
1155            "public"
1156        );
1157        assert_eq!(
1158            serde_json::to_value(ToolVisibility::Private).unwrap(),
1159            "private"
1160        );
1161    }
1162
1163    #[test]
1164    fn test_ui_action_tool() {
1165        let action = UIAction::Tool {
1166            name: "test_tool".to_string(),
1167            arguments: json!({ "param": "value" }),
1168            message_id: Some("123".to_string()),
1169        };
1170
1171        let json = serde_json::to_value(&action).unwrap();
1172        assert_eq!(json["type"], "tool");
1173        assert_eq!(json["name"], "test_tool");
1174        assert_eq!(json["arguments"]["param"], "value");
1175        assert_eq!(json["messageId"], "123");
1176    }
1177
1178    #[test]
1179    fn test_ui_action_set_state() {
1180        let action = UIAction::SetState {
1181            state: json!({ "selected": "item1" }),
1182        };
1183
1184        let json = serde_json::to_value(&action).unwrap();
1185        assert_eq!(json["type"], "setState");
1186        assert_eq!(json["state"]["selected"], "item1");
1187    }
1188
1189    #[test]
1190    fn test_extended_ui_mime_type() {
1191        assert_eq!(ExtendedUIMimeType::HtmlMcp.as_str(), "text/html+mcp");
1192        assert_eq!(
1193            ExtendedUIMimeType::HtmlSkybridge.as_str(),
1194            "text/html+skybridge"
1195        );
1196        assert_eq!(
1197            ExtendedUIMimeType::HtmlMcpApp.as_str(),
1198            "text/html;profile=mcp-app"
1199        );
1200        assert_eq!(ExtendedUIMimeType::HtmlPlain.as_str(), "text/html");
1201
1202        assert!(ExtendedUIMimeType::HtmlSkybridge.is_chatgpt());
1203        assert!(ExtendedUIMimeType::HtmlMcpApp.is_chatgpt());
1204        assert!(ExtendedUIMimeType::HtmlMcp.is_mcp_apps());
1205        assert!(ExtendedUIMimeType::HtmlMcpApp.is_mcp_apps());
1206        assert!(ExtendedUIMimeType::HtmlPlain.is_mcp_ui());
1207    }
1208
1209    #[test]
1210    fn test_extended_ui_mime_type_from_str() {
1211        assert_eq!(
1212            "text/html+mcp".parse::<ExtendedUIMimeType>().unwrap(),
1213            ExtendedUIMimeType::HtmlMcp
1214        );
1215        assert_eq!(
1216            "text/html+skybridge".parse::<ExtendedUIMimeType>().unwrap(),
1217            ExtendedUIMimeType::HtmlSkybridge
1218        );
1219        assert_eq!(
1220            "text/html;profile=mcp-app"
1221                .parse::<ExtendedUIMimeType>()
1222                .unwrap(),
1223            ExtendedUIMimeType::HtmlMcpApp
1224        );
1225        assert_eq!(
1226            "text/html".parse::<ExtendedUIMimeType>().unwrap(),
1227            ExtendedUIMimeType::HtmlPlain
1228        );
1229        assert!("invalid".parse::<ExtendedUIMimeType>().is_err());
1230    }
1231
1232    #[test]
1233    fn test_host_type_mime_type() {
1234        assert_eq!(
1235            HostType::ChatGpt.preferred_mime_type(),
1236            ExtendedUIMimeType::HtmlSkybridge
1237        );
1238        assert_eq!(
1239            HostType::Claude.preferred_mime_type(),
1240            ExtendedUIMimeType::HtmlMcpApp
1241        );
1242        assert_eq!(
1243            HostType::Nanobot.preferred_mime_type(),
1244            ExtendedUIMimeType::HtmlPlain
1245        );
1246    }
1247
1248    #[test]
1249    fn test_to_meta_map() {
1250        let widget_meta = WidgetMeta::new().prefers_border(true).description("Test");
1251
1252        let map = widget_meta.to_meta_map();
1253        assert_eq!(
1254            map.get("openai/widgetPrefersBorder"),
1255            Some(&serde_json::Value::Bool(true))
1256        );
1257
1258        let tool_meta = ChatGptToolMeta::new()
1259            .output_template("ui://test")
1260            .widget_accessible(true);
1261
1262        let map = tool_meta.to_meta_map();
1263        assert_eq!(
1264            map.get("openai/outputTemplate"),
1265            Some(&serde_json::Value::String("ui://test".to_string()))
1266        );
1267    }
1268
1269    #[test]
1270    fn test_widget_meta_dual_emit_prefers_border() {
1271        let meta = WidgetMeta::new().prefers_border(true);
1272        let map = meta.to_meta_map();
1273
1274        // Flat openai/* key
1275        assert_eq!(
1276            map.get("openai/widgetPrefersBorder"),
1277            Some(&serde_json::Value::Bool(true))
1278        );
1279
1280        // Nested ui object
1281        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1282        assert_eq!(ui_obj["prefersBorder"], true);
1283    }
1284
1285    #[test]
1286    fn test_widget_meta_dual_emit_with_domain() {
1287        let meta = WidgetMeta::new().prefers_border(true).domain("x.com");
1288        let map = meta.to_meta_map();
1289
1290        // prefersBorder in nested ui object
1291        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1292        assert_eq!(ui_obj["prefersBorder"], true);
1293        // domain is now dual-emitted: both flat and nested
1294        assert_eq!(ui_obj["domain"], "x.com");
1295        assert_eq!(
1296            map.get("openai/widgetDomain"),
1297            Some(&serde_json::Value::String("x.com".to_string()))
1298        );
1299    }
1300
1301    #[test]
1302    fn test_widget_meta_empty_no_ui_key() {
1303        let meta = WidgetMeta::new();
1304        let map = meta.to_meta_map();
1305
1306        // No spurious ui key when no fields set
1307        assert!(
1308            map.get("ui").is_none(),
1309            "empty WidgetMeta should not have 'ui' key"
1310        );
1311        assert!(map.is_empty(), "empty WidgetMeta should produce empty map");
1312    }
1313
1314    #[test]
1315    fn test_from_ui_mime_type() {
1316        use crate::types::ui::UIMimeType;
1317
1318        // Each UIMimeType variant converts infallibly to its ExtendedUIMimeType counterpart
1319        let html_mcp: ExtendedUIMimeType = UIMimeType::HtmlMcp.into();
1320        assert_eq!(html_mcp, ExtendedUIMimeType::HtmlMcp);
1321        assert_eq!(html_mcp.as_str(), UIMimeType::HtmlMcp.as_str());
1322
1323        let html_skybridge: ExtendedUIMimeType = UIMimeType::HtmlSkybridge.into();
1324        assert_eq!(html_skybridge, ExtendedUIMimeType::HtmlSkybridge);
1325        assert_eq!(html_skybridge.as_str(), UIMimeType::HtmlSkybridge.as_str());
1326
1327        let html_mcp_app: ExtendedUIMimeType = UIMimeType::HtmlMcpApp.into();
1328        assert_eq!(html_mcp_app, ExtendedUIMimeType::HtmlMcpApp);
1329        assert_eq!(html_mcp_app.as_str(), UIMimeType::HtmlMcpApp.as_str());
1330    }
1331
1332    #[test]
1333    fn test_try_from_extended_shared() {
1334        use crate::types::ui::UIMimeType;
1335        use std::convert::TryFrom;
1336
1337        // Shared variants convert back successfully
1338        let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlMcp);
1339        assert_eq!(result, Ok(UIMimeType::HtmlMcp));
1340        assert_eq!(
1341            result.unwrap().as_str(),
1342            ExtendedUIMimeType::HtmlMcp.as_str()
1343        );
1344
1345        let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlSkybridge);
1346        assert_eq!(result, Ok(UIMimeType::HtmlSkybridge));
1347        assert_eq!(
1348            result.unwrap().as_str(),
1349            ExtendedUIMimeType::HtmlSkybridge.as_str()
1350        );
1351
1352        let result = UIMimeType::try_from(ExtendedUIMimeType::HtmlMcpApp);
1353        assert_eq!(result, Ok(UIMimeType::HtmlMcpApp));
1354        assert_eq!(
1355            result.unwrap().as_str(),
1356            ExtendedUIMimeType::HtmlMcpApp.as_str()
1357        );
1358    }
1359
1360    #[test]
1361    fn test_try_from_extended_fails() {
1362        use crate::types::ui::UIMimeType;
1363        use std::convert::TryFrom;
1364
1365        // Extended-only variants fail conversion with descriptive error
1366        let extended_only = [
1367            ExtendedUIMimeType::HtmlPlain,
1368            ExtendedUIMimeType::UriList,
1369            ExtendedUIMimeType::RemoteDom,
1370            ExtendedUIMimeType::RemoteDomReact,
1371        ];
1372
1373        for ext in &extended_only {
1374            let result = UIMimeType::try_from(*ext);
1375            assert!(result.is_err(), "Expected Err for {:?}", ext);
1376            let err = result.unwrap_err();
1377            assert!(
1378                err.contains("extended-only variant"),
1379                "Error for {:?} should contain 'extended-only variant', got: {}",
1380                ext,
1381                err
1382            );
1383        }
1384    }
1385
1386    #[test]
1387    fn test_widget_meta_dual_emit_domain() {
1388        let meta = WidgetMeta::new().domain("x.com");
1389        let map = meta.to_meta_map();
1390
1391        // Flat key preserved
1392        assert_eq!(
1393            map.get("openai/widgetDomain"),
1394            Some(&serde_json::Value::String("x.com".to_string()))
1395        );
1396
1397        // Nested ui.domain also emitted
1398        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1399        assert_eq!(ui_obj["domain"], "x.com");
1400    }
1401
1402    #[test]
1403    fn test_widget_meta_dual_emit_csp() {
1404        let csp = WidgetCSP::new()
1405            .connect("https://api.example.com")
1406            .resources("https://cdn.example.com")
1407            .frame("https://embed.example.com")
1408            .base_uri("https://base.example.com")
1409            .redirect("https://redirect.example.com");
1410
1411        let meta = WidgetMeta::new().csp(csp);
1412        let map = meta.to_meta_map();
1413
1414        // Flat key preserved
1415        assert!(map.get("openai/widgetCSP").is_some());
1416
1417        // Nested ui.csp with spec field names
1418        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1419        let nested_csp = ui_obj.get("csp").expect("must have nested csp");
1420        assert_eq!(nested_csp["connectDomains"][0], "https://api.example.com");
1421        assert_eq!(nested_csp["resourceDomains"][0], "https://cdn.example.com");
1422        assert_eq!(nested_csp["frameDomains"][0], "https://embed.example.com");
1423        assert_eq!(nested_csp["baseUriDomains"][0], "https://base.example.com");
1424
1425        // redirect_domains is ChatGPT-specific, must NOT appear in nested ui.csp
1426        assert!(
1427            nested_csp.get("redirect_domains").is_none()
1428                && nested_csp.get("redirectDomains").is_none(),
1429            "redirect_domains should not be in nested ui.csp (ChatGPT-specific)"
1430        );
1431    }
1432
1433    #[test]
1434    fn test_widget_meta_dual_emit_all_fields() {
1435        let meta = WidgetMeta::new()
1436            .prefers_border(true)
1437            .domain("x.com")
1438            .csp(WidgetCSP::new().connect("https://api.example.com"));
1439
1440        let map = meta.to_meta_map();
1441        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1442
1443        // All three fields in nested ui
1444        assert_eq!(ui_obj["prefersBorder"], true);
1445        assert_eq!(ui_obj["domain"], "x.com");
1446        assert!(ui_obj.get("csp").is_some());
1447    }
1448
1449    #[test]
1450    fn test_chatgpt_tool_meta_dual_emit_visibility_public() {
1451        let meta = ChatGptToolMeta::new().visibility(ToolVisibility::Public);
1452        let map = meta.to_meta_map();
1453
1454        // Flat key preserved
1455        assert_eq!(map.get("openai/visibility"), Some(&json!("public")));
1456
1457        // Nested ui.visibility as array
1458        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1459        assert_eq!(ui_obj["visibility"], json!(["model", "app"]));
1460    }
1461
1462    #[test]
1463    fn test_chatgpt_tool_meta_dual_emit_visibility_private() {
1464        let meta = ChatGptToolMeta::new().visibility(ToolVisibility::Private);
1465        let map = meta.to_meta_map();
1466
1467        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1468        assert_eq!(ui_obj["visibility"], json!(["app"]));
1469    }
1470
1471    #[test]
1472    fn test_chatgpt_tool_meta_dual_emit_visibility_model_only() {
1473        let meta = ChatGptToolMeta::new().visibility(ToolVisibility::ModelOnly);
1474        let map = meta.to_meta_map();
1475
1476        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1477        assert_eq!(ui_obj["visibility"], json!(["model"]));
1478    }
1479
1480    #[test]
1481    fn test_widget_csp_base_uri_builder() {
1482        let csp = WidgetCSP::new().base_uri("https://example.com");
1483        assert_eq!(
1484            csp.base_uri_domains,
1485            Some(vec!["https://example.com".to_string()])
1486        );
1487    }
1488
1489    #[test]
1490    fn test_widget_csp_base_uri_serialization() {
1491        let csp = WidgetCSP::new()
1492            .connect("https://api.example.com")
1493            .base_uri("https://base.example.com");
1494
1495        let json = serde_json::to_value(&csp).unwrap();
1496        assert_eq!(json["base_uri_domains"][0], "https://base.example.com");
1497    }
1498
1499    #[test]
1500    fn test_widget_csp_is_empty_with_base_uri() {
1501        let csp = WidgetCSP::new().base_uri("https://example.com");
1502        assert!(
1503            !csp.is_empty(),
1504            "CSP with base_uri_domains should not be empty"
1505        );
1506    }
1507
1508    #[test]
1509    fn test_tool_visibility_model_only_serialization() {
1510        assert_eq!(
1511            serde_json::to_value(ToolVisibility::ModelOnly).unwrap(),
1512            "modelonly"
1513        );
1514    }
1515
1516    #[test]
1517    fn test_tool_visibility_has_three_variants() {
1518        // Verify all 3 variants exist and serialize correctly
1519        let variants = vec![
1520            (ToolVisibility::Public, "public"),
1521            (ToolVisibility::Private, "private"),
1522            (ToolVisibility::ModelOnly, "modelonly"),
1523        ];
1524        for (variant, expected) in variants {
1525            assert_eq!(
1526                serde_json::to_value(variant).unwrap(),
1527                expected,
1528                "ToolVisibility::{:?} should serialize to {:?}",
1529                variant,
1530                expected
1531            );
1532        }
1533    }
1534
1535    #[test]
1536    fn test_tool_visibility_to_visibility_array() {
1537        assert_eq!(
1538            ToolVisibility::Public.to_visibility_array(),
1539            &["model", "app"]
1540        );
1541        assert_eq!(ToolVisibility::Private.to_visibility_array(), &["app"]);
1542        assert_eq!(ToolVisibility::ModelOnly.to_visibility_array(), &["model"]);
1543    }
1544
1545    #[test]
1546    fn test_widget_meta_resource_uri_emits_standard_key_only() {
1547        let meta = WidgetMeta::new().resource_uri("ui://chess/board.html");
1548        let map = meta.to_meta_map();
1549
1550        // Nested ui.resourceUri (standard key)
1551        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1552        assert_eq!(ui_obj["resourceUri"], "ui://chess/board.html");
1553
1554        // Legacy flat key for host compatibility
1555        assert_eq!(
1556            map.get("ui/resourceUri"),
1557            Some(&serde_json::Value::String(
1558                "ui://chess/board.html".to_string()
1559            )),
1560            "must emit legacy flat ui/resourceUri key for Claude Desktop/ChatGPT"
1561        );
1562
1563        // No ChatGPT alias (standard-only)
1564        assert!(
1565            map.get("openai/outputTemplate").is_none(),
1566            "must NOT emit openai/outputTemplate in standard-only mode"
1567        );
1568    }
1569
1570    #[test]
1571    fn test_widget_meta_resource_uri_with_widget_fields() {
1572        let meta = WidgetMeta::new()
1573            .resource_uri("ui://chess/board.html")
1574            .prefers_border(true)
1575            .domain("chess.com");
1576        let map = meta.to_meta_map();
1577
1578        let ui_obj = map.get("ui").expect("must have nested 'ui' key");
1579        // All standard fields coexist in the nested ui object
1580        assert_eq!(ui_obj["resourceUri"], "ui://chess/board.html");
1581        assert_eq!(ui_obj["prefersBorder"], true);
1582        assert_eq!(ui_obj["domain"], "chess.com");
1583
1584        // Legacy flat key emitted for Claude Desktop compatibility
1585        assert_eq!(
1586            map.get("ui/resourceUri").and_then(|v| v.as_str()),
1587            Some("ui://chess/board.html"),
1588            "must emit legacy flat key for Claude Desktop"
1589        );
1590        assert!(
1591            map.get("openai/outputTemplate").is_none(),
1592            "must NOT emit openai/outputTemplate"
1593        );
1594        // openai/* display keys still emitted via serde (they are widget display fields)
1595        assert!(map.get("openai/widgetPrefersBorder").is_some());
1596        assert!(map.get("openai/widgetDomain").is_some());
1597    }
1598
1599    #[test]
1600    fn test_widget_meta_resource_uri_not_in_serde_output() {
1601        // resource_uri should not appear as a raw key in the map
1602        let meta = WidgetMeta::new().resource_uri("ui://x");
1603        let map = meta.to_meta_map();
1604        assert!(
1605            map.get("resource_uri").is_none(),
1606            "resource_uri should be removed from serde output"
1607        );
1608    }
1609
1610    #[test]
1611    fn test_widget_meta_is_empty_with_resource_uri() {
1612        let meta = WidgetMeta::new().resource_uri("ui://x");
1613        assert!(!meta.is_empty());
1614    }
1615
1616    #[test]
1617    fn test_mime_type_round_trip() {
1618        use crate::types::ui::UIMimeType;
1619        use std::convert::TryFrom;
1620
1621        // Round-trip: UIMimeType -> ExtendedUIMimeType -> UIMimeType preserves value
1622        let variants = [
1623            UIMimeType::HtmlMcp,
1624            UIMimeType::HtmlSkybridge,
1625            UIMimeType::HtmlMcpApp,
1626        ];
1627
1628        for original in &variants {
1629            let extended: ExtendedUIMimeType = (*original).into();
1630            let round_tripped = UIMimeType::try_from(extended)
1631                .unwrap_or_else(|e| panic!("Round-trip failed for {:?}: {}", original, e));
1632            assert_eq!(
1633                *original, round_tripped,
1634                "Round-trip failed: {:?} != {:?}",
1635                original, round_tripped
1636            );
1637        }
1638    }
1639}