---
phase: 40-review-chatgpt-compatibility-for-apps
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - src/types/mcp_apps.rs
autonomous: true
requirements:
  - COMPAT-02
  - COMPAT-03
  - COMPAT-04

must_haves:
  truths:
    - "WidgetMeta::to_meta_map() emits nested ui.domain alongside flat openai/widgetDomain"
    - "WidgetMeta::to_meta_map() emits nested ui.csp with spec field names alongside flat openai/widgetCSP"
    - "WidgetCSP has base_uri_domains field matching spec McpUiResourceCsp.baseUriDomains"
    - "ToolVisibility maps to spec-compatible array format: Public -> [model, app], Private -> [app], ModelOnly -> [model]"
  artifacts:
    - path: "src/types/mcp_apps.rs"
      provides: "Nested ui.csp, ui.domain, ui.visibility emission; baseUriDomains on WidgetCSP; ModelOnly variant"
      contains: "baseUriDomains"
  key_links:
    - from: "WidgetMeta::to_meta_map()"
      to: "ui.csp nested object"
      via: "dual-emit pattern"
      pattern: "connectDomains"
    - from: "ToolVisibility"
      to: "ChatGptToolMeta::to_meta_map()"
      via: "visibility array emission"
      pattern: "to_visibility_array"
---

<objective>
Extend WidgetMeta and WidgetCSP to dual-emit nested `ui.csp`, `ui.domain` alongside existing flat `openai/*` keys, add `baseUriDomains` to WidgetCSP, add `ModelOnly` variant to ToolVisibility, and emit `ui.visibility` as a spec-compatible array in ChatGptToolMeta.

Purpose: The official ext-apps spec defines CSP, domain, and visibility as nested fields inside the `ui` object. Our SDK currently only emits flat `openai/*` keys. Dual-emitting both formats ensures compatibility with both ChatGPT (flat) and standard MCP hosts (nested).

Output: Updated WidgetMeta with nested dual-emit for csp/domain, updated WidgetCSP with base_uri_domains, updated ToolVisibility with ModelOnly, ChatGptToolMeta emitting nested ui.visibility array.
</objective>

<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@src/types/mcp_apps.rs

<interfaces>
<!-- Key types and methods the executor modifies -->

From src/types/mcp_apps.rs:
```rust
pub struct WidgetCSP {
    pub connect_domains: Vec<String>,
    pub resource_domains: Vec<String>,
    pub redirect_domains: Option<Vec<String>>,
    pub frame_domains: Option<Vec<String>>,
    // MISSING: base_uri_domains (spec has baseUriDomains)
}

pub struct WidgetMeta {
    #[serde(rename = "openai/widgetPrefersBorder")]
    pub prefers_border: Option<bool>,
    #[serde(rename = "openai/widgetDomain")]
    pub domain: Option<String>,
    #[serde(rename = "openai/widgetCSP")]
    pub csp: Option<WidgetCSP>,
    #[serde(rename = "openai/widgetDescription")]
    pub description: Option<String>,
}

// to_meta_map() currently only dual-emits prefersBorder into nested ui
// Needs to also emit domain and csp into nested ui

pub enum ToolVisibility {
    Public,   // -> ["model", "app"]
    Private,  // -> ["app"]
    // MISSING: ModelOnly -> ["model"]
}

pub struct ChatGptToolMeta {
    #[serde(rename = "openai/visibility")]
    pub visibility: Option<ToolVisibility>,
    // to_meta_map() needs to also emit nested ui.visibility array
}
```

Spec mapping for CSP nested fields:
| Spec (McpUiResourceCsp) | Our WidgetCSP field |
|--------------------------|---------------------|
| connectDomains           | connect_domains     |
| resourceDomains          | resource_domains    |
| frameDomains             | frame_domains       |
| baseUriDomains           | (add) base_uri_domains |

Note: redirect_domains is ChatGPT-specific (not in spec) -- keep in flat openai/widgetCSP only, do NOT emit in nested ui.csp.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add baseUriDomains to WidgetCSP and ModelOnly to ToolVisibility</name>
  <files>src/types/mcp_apps.rs</files>
  <behavior>
    - Test: WidgetCSP::new().base_uri("https://example.com") sets base_uri_domains
    - Test: WidgetCSP with base_uri_domains serializes correctly
    - Test: WidgetCSP::is_empty() returns false when base_uri_domains has entries
    - Test: ToolVisibility::ModelOnly serializes as "modelonly" (serde rename_all lowercase)
    - Test: ToolVisibility has 3 variants: Public, Private, ModelOnly
  </behavior>
  <action>
1. Add `base_uri_domains` field to `WidgetCSP`:
   ```rust
   #[serde(default, skip_serializing_if = "Option::is_none")]
   pub base_uri_domains: Option<Vec<String>>,
   ```

2. Add builder method:
   ```rust
   pub fn base_uri(mut self, domain: impl Into<String>) -> Self {
       self.base_uri_domains.get_or_insert_with(Vec::new).push(domain.into());
       self
   }
   ```

3. Update `WidgetCSP::is_empty()` to check `base_uri_domains`.

4. Add `ModelOnly` variant to `ToolVisibility`:
   ```rust
   /// Tool is visible to the model only, not callable from widgets.
   ///
   /// The model can call this tool but widgets cannot invoke it via UI actions.
   ModelOnly,
   ```
   Note: serde rename_all = "lowercase" will serialize as "modelonly". This is fine for the flat `openai/visibility` key. The nested `ui.visibility` array uses custom serialization (Task 2).

5. Add a helper method `to_visibility_array()` on `ToolVisibility` that returns the spec-compatible array:
   ```rust
   pub fn to_visibility_array(&self) -> Vec<&'static str> {
       match self {
           Self::Public => vec!["model", "app"],
           Self::Private => vec!["app"],
           Self::ModelOnly => vec!["model"],
       }
   }
   ```
  </action>
  <verify>
    <automated>cd /Users/guy/Development/mcp/sdk/rust-mcp-sdk && cargo test --features mcp-apps types::mcp_apps -- --test-threads=1 2>&1 | tail -20</automated>
  </verify>
  <done>WidgetCSP has base_uri_domains field with builder. ToolVisibility has ModelOnly variant with to_visibility_array() helper. All existing tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Dual-emit nested ui.csp, ui.domain in WidgetMeta and ui.visibility in ChatGptToolMeta</name>
  <files>src/types/mcp_apps.rs</files>
  <behavior>
    - Test: WidgetMeta with domain emits both flat "openai/widgetDomain" and nested "ui.domain"
    - Test: WidgetMeta with csp emits both flat "openai/widgetCSP" and nested "ui.csp" with spec field names (connectDomains, resourceDomains, frameDomains, baseUriDomains)
    - Test: Nested ui.csp does NOT include redirect_domains (ChatGPT-specific, flat only)
    - Test: WidgetMeta with all fields emits complete nested ui object (prefersBorder + domain + csp)
    - Test: ChatGptToolMeta with visibility Public emits both flat "openai/visibility" and nested "ui.visibility" as ["model", "app"]
    - Test: ChatGptToolMeta with visibility Private emits nested "ui.visibility" as ["app"]
    - Test: ChatGptToolMeta with visibility ModelOnly emits nested "ui.visibility" as ["model"]
  </behavior>
  <action>
1. Update `WidgetMeta::to_meta_map()` to extend the nested `ui` object with `domain` and `csp`:

   After the existing `prefersBorder` insertion into `ui_obj`, add:
   ```rust
   if let Some(domain) = &self.domain {
       ui_obj.insert("domain".to_string(), serde_json::Value::String(domain.clone()));
   }
   if let Some(csp) = &self.csp {
       let mut csp_obj = serde_json::Map::new();
       if !csp.connect_domains.is_empty() {
           csp_obj.insert("connectDomains".into(), serde_json::json!(csp.connect_domains));
       }
       if !csp.resource_domains.is_empty() {
           csp_obj.insert("resourceDomains".into(), serde_json::json!(csp.resource_domains));
       }
       if let Some(frames) = &csp.frame_domains {
           if !frames.is_empty() {
               csp_obj.insert("frameDomains".into(), serde_json::json!(frames));
           }
       }
       if let Some(base_uris) = &csp.base_uri_domains {
           if !base_uris.is_empty() {
               csp_obj.insert("baseUriDomains".into(), serde_json::json!(base_uris));
           }
       }
       // NOTE: redirect_domains is ChatGPT-specific, NOT part of the ext-apps spec
       // It stays in the flat openai/widgetCSP only
       if !csp_obj.is_empty() {
           ui_obj.insert("csp".to_string(), serde_json::Value::Object(csp_obj));
       }
   }
   ```

2. Update `ChatGptToolMeta::to_meta_map()` to also emit nested `ui.visibility`:

   Change from simple serde serialization to also add a nested `ui` object:
   ```rust
   pub fn to_meta_map(&self) -> serde_json::Map<String, serde_json::Value> {
       let mut map = serde_json::to_value(self)
           .ok()
           .and_then(|v| v.as_object().cloned())
           .unwrap_or_default();
       // Dual-emit: nested ui.visibility array for MCP standard
       if let Some(vis) = &self.visibility {
           let arr = vis.to_visibility_array();
           let json_arr = serde_json::Value::Array(
               arr.iter().map(|s| serde_json::Value::String(s.to_string())).collect()
           );
           let ui_obj = map
               .entry("ui".to_string())
               .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
           if let Some(obj) = ui_obj.as_object_mut() {
               obj.insert("visibility".to_string(), json_arr);
           }
       }
       map
   }
   ```

3. Update the doc comment on `WidgetMeta::to_meta_map()` to document that it now dual-emits domain and csp (not just prefersBorder).

4. Update the doc comment on `ChatGptToolMeta::to_meta_map()` to document the nested ui.visibility emission.
  </action>
  <verify>
    <automated>cd /Users/guy/Development/mcp/sdk/rust-mcp-sdk && cargo test --features mcp-apps types::mcp_apps -- --test-threads=1 2>&1 | tail -30</automated>
  </verify>
  <done>WidgetMeta::to_meta_map() dual-emits domain and csp in nested ui object. ChatGptToolMeta::to_meta_map() dual-emits visibility as nested ui.visibility array. All 3 ToolVisibility variants map correctly to spec arrays. redirect_domains excluded from nested ui.csp.</done>
</task>

</tasks>

<verification>
- `cargo test --features mcp-apps types::mcp_apps` passes with zero failures
- `cargo clippy --features mcp-apps -- -D warnings` reports zero warnings
- `cargo doc --features mcp-apps --no-deps` builds without warnings
- Verify that WidgetMeta with CSP containing redirect_domains does NOT emit redirect_domains in the nested ui.csp
</verification>

<success_criteria>
- WidgetMeta::to_meta_map() produces nested ui object with prefersBorder, domain, and csp fields
- WidgetCSP has base_uri_domains field serialized as baseUriDomains in nested ui.csp
- ChatGptToolMeta::to_meta_map() produces nested ui.visibility as spec array
- ToolVisibility has 3 variants: Public, Private, ModelOnly
- All flat openai/* keys preserved for ChatGPT backward compatibility
- redirect_domains excluded from nested ui.csp (ChatGPT-specific)
</success_criteria>

<output>
After completion, create `.planning/phases/40-review-chatgpt-compatibility-for-apps/40-02-SUMMARY.md`
</output>
