---
phase: 39-add-deep-merge-for-ui-meta-key-to-prevent-collision
plan: 02
type: execute
wave: 2
depends_on: ["39-01"]
files_modified:
  - src/server/typed_tool.rs
  - src/server/wasm_typed_tool.rs
autonomous: true
requirements:
  - MERGE-02
must_haves:
  truths:
    - "TypedTool metadata() uses deep_merge for _meta instead of replacing"
    - "TypedSyncTool metadata() uses deep_merge for _meta instead of replacing"
    - "TypedToolWithOutput has with_ui() builder and metadata() merges UI meta with output schema annotations"
    - "WasmTypedTool info() uses deep_merge for _meta instead of replacing"
    - "TypedToolWithOutput with_ui + output_schema coexist without data loss"
  artifacts:
    - path: "src/server/typed_tool.rs"
      provides: "Updated metadata() for TypedTool, TypedSyncTool, TypedToolWithOutput; with_ui on TypedToolWithOutput"
      contains: "deep_merge"
    - path: "src/server/wasm_typed_tool.rs"
      provides: "Updated info() for WasmTypedTool"
      contains: "deep_merge"
  key_links:
    - from: "src/server/typed_tool.rs"
      to: "src/types/ui.rs"
      via: "metadata() calls deep_merge"
      pattern: "deep_merge"
    - from: "src/server/wasm_typed_tool.rs"
      to: "src/types/ui.rs"
      via: "info() calls deep_merge"
      pattern: "deep_merge"
---

<objective>
Update all metadata()/info() implementations to use deep_merge and add with_ui() to TypedToolWithOutput.

Purpose: Eliminate _meta collision across all four tool types. The primary bug is TypedToolWithOutput::metadata() which hardcodes `_meta: None`, discarding any UI metadata. This plan fixes all four tool types and adds with_ui() support to TypedToolWithOutput so UI tools can have typed output schemas.

Output: All tool types use deep_merge for _meta construction, TypedToolWithOutput gains with_ui(), comprehensive tests.
</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
@.planning/phases/39-add-deep-merge-for-ui-meta-key-to-prevent-collision/39-CONTEXT.md
@.planning/phases/39-add-deep-merge-for-ui-meta-key-to-prevent-collision/39-RESEARCH.md
@.planning/phases/39-add-deep-merge-for-ui-meta-key-to-prevent-collision/39-01-SUMMARY.md

<interfaces>
<!-- From Plan 01 (deep_merge and with_meta_entry) -->

From src/types/ui.rs (created in Plan 01):
```rust
/// Recursively merge overlay into base. Objects merge recursively,
/// all other types replaced entirely. Last-in wins at leaf level.
pub fn deep_merge(
    base: &mut serde_json::Map<String, serde_json::Value>,
    overlay: serde_json::Map<String, serde_json::Value>,
)
```

From src/types/protocol.rs (created in Plan 01):
```rust
impl ToolInfo {
    /// Add a single key-value pair to _meta, merging with existing entries.
    pub fn with_meta_entry(mut self, key: impl Into<String>, value: serde_json::Value) -> Self
}
```

<!-- Existing code to modify -->

From src/server/typed_tool.rs (TypedToolWithOutput struct, line 478-493):
```rust
pub struct TypedToolWithOutput<TIn, TOut, F> {
    name: String,
    description: Option<String>,
    input_schema: Value,
    output_schema: Option<Value>,
    annotations: Option<ToolAnnotations>,
    handler: F,
    _phantom: PhantomData<(TIn, TOut)>,
    // NOTE: No ui_resource_uri field yet -- must add
}
```

From src/server/typed_tool.rs (TypedToolWithOutput::metadata(), line 683-718):
```rust
fn metadata(&self) -> Option<ToolInfo> {
    // ... annotation logic ...
    Some(ToolInfo {
        // ...
        _meta: None,  // <-- THE BUG: always None, discards UI
        execution: None,
    })
}
```

From src/server/typed_tool.rs (TypedTool::metadata(), line 229-243):
```rust
fn metadata(&self) -> Option<ToolInfo> {
    let meta = self.ui_resource_uri.as_deref()
        .map(ToolUIMetadata::build_meta_map);
    Some(ToolInfo { ..., _meta: meta, ... })
}
```

From src/server/wasm_typed_tool.rs (WasmTypedTool::info(), line 107-119):
```rust
fn info(&self) -> ToolInfo {
    let meta = self.ui_resource_uri.as_deref()
        .map(ToolUIMetadata::build_meta_map);
    ToolInfo { ..., _meta: meta, ... }
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Update TypedTool and TypedSyncTool metadata() to use deep_merge</name>
  <files>src/server/typed_tool.rs</files>
  <behavior>
    - TypedTool with_ui metadata still produces correct _meta (regression)
    - TypedTool without with_ui still produces _meta: None (regression)
    - TypedSyncTool with_ui metadata still produces correct _meta (regression)
    - TypedSyncTool without with_ui still produces _meta: None (regression)
  </behavior>
  <action>
    Update TypedTool::metadata() (line 229-243) to use the deep_merge pattern:
    ```rust
    fn metadata(&self) -> Option<ToolInfo> {
        let mut meta = serde_json::Map::new();
        if let Some(uri) = &self.ui_resource_uri {
            let ui_meta = crate::types::ui::ToolUIMetadata::build_meta_map(uri);
            crate::types::ui::deep_merge(&mut meta, ui_meta);
        }
        Some(ToolInfo {
            name: self.name.clone(),
            description: self.description.clone(),
            input_schema: self.input_schema.clone(),
            annotations: self.annotations.clone(),
            _meta: if meta.is_empty() { None } else { Some(meta) },
            execution: None,
        })
    }
    ```

    Apply the identical pattern to TypedSyncTool::metadata() (line 396-410).

    Existing tests should continue to pass unchanged. The behavior is identical -- deep_merge of a single source into an empty map produces the same result as the old direct assignment.
  </action>
  <verify>
    <automated>cargo test typed_tool -p pmcp --lib && cargo test typed_sync -p pmcp --lib</automated>
  </verify>
  <done>TypedTool and TypedSyncTool metadata() use deep_merge pattern, all existing tests pass, _meta output is identical to before</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add with_ui() to TypedToolWithOutput and fix metadata() to merge</name>
  <files>src/server/typed_tool.rs</files>
  <behavior>
    - TypedToolWithOutput with_ui sets ui_resource_uri
    - TypedToolWithOutput metadata() with with_ui produces _meta with ui.resourceUri and openai/outputTemplate
    - TypedToolWithOutput metadata() with with_ui AND output_schema: both _meta and annotations.output_schema are present (no data loss)
    - TypedToolWithOutput metadata() without with_ui still produces _meta: None
    - TypedToolWithOutput metadata() with with_ui AND with_annotations: all three coexist
  </behavior>
  <action>
    1. Add `ui_resource_uri: Option<String>` field to the TypedToolWithOutput struct (line ~492, before `_phantom`).

    2. Initialize it as `None` in the `new` constructor.

    3. Add `with_ui` builder method (matching TypedTool pattern exactly):
    ```rust
    /// Associate this tool with a UI resource (MCP Apps Extension).
    ///
    /// Sets `_meta.ui.resourceUri` and `openai/outputTemplate` in the tool's
    /// `ToolInfo` metadata for MCP and ChatGPT host compatibility.
    /// Deep-merged with any other `_meta` entries to prevent collision.
    pub fn with_ui(mut self, ui_resource_uri: impl Into<String>) -> Self {
        self.ui_resource_uri = Some(ui_resource_uri.into());
        self
    }
    ```

    4. Update metadata() (line 683-718) to merge UI meta:
    ```rust
    fn metadata(&self) -> Option<ToolInfo> {
        let mut annotations = self.annotations.clone().unwrap_or_default();
        if let Some(schema) = &self.output_schema {
            let type_name = schema.get("title")
                .and_then(|t| t.as_str())
                .unwrap_or("Output").to_string();
            annotations = annotations.with_output_schema(schema.clone(), type_name);
        }
        let has_annotations = annotations.read_only_hint.is_some()
            || annotations.destructive_hint.is_some()
            || annotations.idempotent_hint.is_some()
            || annotations.open_world_hint.is_some()
            || annotations.output_schema.is_some();

        let mut meta = serde_json::Map::new();
        if let Some(uri) = &self.ui_resource_uri {
            let ui_meta = crate::types::ui::ToolUIMetadata::build_meta_map(uri);
            crate::types::ui::deep_merge(&mut meta, ui_meta);
        }

        Some(ToolInfo {
            name: self.name.clone(),
            description: self.description.clone(),
            input_schema: self.input_schema.clone(),
            annotations: if has_annotations { Some(annotations) } else { None },
            _meta: if meta.is_empty() { None } else { Some(meta) },
            execution: None,
        })
    }
    ```

    5. Update the Debug impl to include ui_resource_uri field.

    6. Write tests in the existing `mod tests` block:
    - `test_typed_tool_with_output_with_ui_metadata` -- verify _meta has ui.resourceUri and openai/outputTemplate
    - `test_typed_tool_with_output_with_ui_and_output_schema_coexist` -- verify both _meta and annotations.output_schema present
    - `test_typed_tool_with_output_without_ui_has_no_meta` -- verify _meta is None when no with_ui called (regression)
  </action>
  <verify>
    <automated>cargo test typed_tool_with_output -p pmcp --lib</automated>
  </verify>
  <done>TypedToolWithOutput has with_ui(), metadata() merges UI meta via deep_merge, output_schema annotations and UI _meta coexist without data loss</done>
</task>

<task type="auto">
  <name>Task 3: Update WasmTypedTool info() to use deep_merge</name>
  <files>src/server/wasm_typed_tool.rs</files>
  <action>
    Update WasmTypedTool::info() (line 107-119) to use the deep_merge pattern:
    ```rust
    fn info(&self) -> ToolInfo {
        let mut meta = serde_json::Map::new();
        if let Some(uri) = &self.ui_resource_uri {
            let ui_meta = crate::types::ui::ToolUIMetadata::build_meta_map(uri);
            crate::types::ui::deep_merge(&mut meta, ui_meta);
        }
        ToolInfo {
            name: self.name.clone(),
            description: self.description.clone(),
            input_schema: self.input_schema.clone(),
            annotations: self.annotations.clone(),
            _meta: if meta.is_empty() { None } else { Some(meta) },
            execution: None,
        }
    }
    ```

    This is the same pattern applied to TypedTool and TypedSyncTool. The change ensures consistency across all four tool types.

    Note: WasmTypedTool tests are gated behind #[cfg(target_arch = "wasm32")], so verify via compilation only on the host target.
  </action>
  <verify>
    <automated>cargo build -p pmcp 2>&1 | head -20</automated>
  </verify>
  <done>WasmTypedTool::info() uses deep_merge pattern, compiles cleanly, all four tool types now use consistent _meta merge approach</done>
</task>

</tasks>

<verification>
- `cargo test -p pmcp --lib` -- full library test suite passes
- `cargo clippy -p pmcp -- -D warnings` -- zero warnings
- `cargo test tool_info_with_ui -p pmcp --lib` -- existing UI tests pass (regression)
- `cargo test typed_tool_with_output -p pmcp --lib` -- new TypedToolWithOutput UI tests pass
- Verify no changes to CallToolResult::with_meta or GetPromptResult::with_meta (scope guard)
</verification>

<success_criteria>
- All four tool types (TypedTool, TypedSyncTool, TypedToolWithOutput, WasmTypedTool) use deep_merge for _meta
- TypedToolWithOutput has with_ui() builder method
- TypedToolWithOutput with_ui + output_schema coexist without data loss
- Zero clippy warnings, all tests pass
- CallToolResult and GetPromptResult with_meta methods are untouched
</success_criteria>

<output>
After completion, create `.planning/phases/39-add-deep-merge-for-ui-meta-key-to-prevent-collision/39-02-SUMMARY.md`
</output>
