# MCP Tool Call JSON Tree Rendering — Tech Spec Linear: APP-2527 Companion product spec: [`specs/APP-2527/PRODUCT.md`](./PRODUCT.md) ## Context Today an expanded MCP tool-call detail is rendered as a single selectable, monospace, pretty-printed JSON string. Both the request arguments and the response are concatenated into one `String` and shown in one `Text` element. We are replacing that body with an interactive, collapsible, theme-colored JSON tree — built as a generic, reusable `warpui`-style component so it can serve other surfaces (MCP resource results, structured agent outputs, etc.) without re-implementation. The collapsed header row, accept/reject flow, and all non-MCP action rendering are unchanged. All references pinned to commit `46265f499a3a32a488f640c0fce7565bb763496f`. Key existing code: - [`app/src/ai/blocklist/inline_action/requested_command.rs` (1393-1484) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/requested_command.rs#L1393-L1484) — `RequestedCommandView::render`'s `should_render_mcp_content` branch. This is the exact block being replaced. - [`app/src/ai/blocklist/inline_action/requested_command.rs` (1438-1445) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/requested_command.rs#L1438-L1445) — where `CallMCPToolResult::{Success,Error,Cancelled}` is turned into `result_text`. - [`app/src/ai/blocklist/inline_action/requested_command.rs` (475-481) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/requested_command.rs#L475-L481) — `RequestedCommandView` fields including `mcp_content_selection_handle` and `mcp_content_selected_text`. Tree expansion state will be added here. - [`app/src/ai/blocklist/block.rs` (2043-2080) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/block.rs#L2043-L2080) — where `command_text` is built as `MCP Tool: {name} ({display_input})` including integer-coercion via `coerce_integer_args`. The raw `input: serde_json::Value` and `name` are available here. - [`app/src/ai/blocklist/action_model/execute/call_mcp_tool.rs` (105-126, 169-286) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/action_model/execute/call_mcp_tool.rs#L105-L126) — `coerce_integer_args` (`pub(crate)`), already reused by `block.rs`. - [`crates/ai/src/agent/action_result/mod.rs` (1056-1077) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/crates/ai/src/agent/action_result/mod.rs#L1056-L1077) — `CallMCPToolResult::Success { result: rmcp::model::CallToolResult }`, `Error(String)`, `Cancelled`. `CallToolResult` carries `structured_content: Option` and `content: Vec` (text items). - [`crates/warp_core/src/ui/theme/color.rs` (361-424) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/crates/warp_core/src/ui/theme/color.rs#L361-L424) — `WarpTheme` ANSI accessors (`ansi_fg_green/yellow/blue/cyan/magenta`) and `internal_colors::{text_main, text_sub, text_disabled}`. - [`app/src/ai/blocklist/inline_action/inline_action_header.rs` (96-107) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/inline_action_header.rs#L96-L107) — existing chevron/expansion plumbing for reference; the generic component uses `Icon::ChevronRight`/`Icon::ChevronDown` from `warpui` directly rather than importing `render_expansion_icon`, to avoid a wrong-direction module dependency. See `PRODUCT.md` for user-visible behavior; this spec does not restate it. ## Design alternatives ### A. Widget architecture: where does the tree component live? **Option A1 — Generic `warpui`-level component (recommended)** Add a standalone `JsonTreeView` as a `warpui`-level element in `app/src/ui_components/json_tree.rs` (same layer as other reusable view utilities, avoiding a `serde_json` dependency in the `warpui` crate itself). The component takes a `&serde_json::Value`, a `JsonTreeState` (expansion map), a `JsonTreeColors` (pre-resolved theme colors), and callbacks for toggle/copy, and returns a `Box`. It has no dependency on agent-specific types. The `JsonTreeColors` mapping (resolved from `WarpTheme` at render time, no hard-coded values): - key / index → `theme.ansi_fg_cyan()` - string value → `theme.ansi_fg_green()` - number value → `theme.ansi_fg_yellow()` - bool value → `theme.ansi_fg_magenta()` - null value → `internal_colors::text_disabled(theme, background)` - type/size annotation (`{} 4 keys`) and punctuation → `internal_colors::text_sub(theme, background)` Pros: - Directly reusable for `ReadMCPResourceResult`, structured agent outputs, settings inspectors, or any future surface showing JSON. - Clear ownership boundary; agent code calls the component but does not contain rendering logic. - Testable in isolation without agent scaffolding. Cons: - Requires deciding the right crate layer (app-level component vs. warpui crate) before starting — small upfront decision. - Slightly more initial setup than embedding inline. **Option A2 — Inline in `requested_command.rs`** Put the tree rendering functions directly inside `requested_command.rs` or a sibling `mcp_json_tree.rs` in the `inline_action` module. Pros: - Zero new crate surface; minimal change to module organization. - Faster to write initially. Cons: - Code is not reusable without copy-paste or moving it later. - Conflates MCP-specific logic (result parsing, integer coercion) with generic tree rendering. **Recommendation: A1.** The minimal extra setup pays off immediately — `ReadMCPResourceResult` is the obvious next user, and the generic component is the right level of abstraction. --- ### B. Element construction: recursive build vs. flattened virtualized list **Option B1 — Recursive element build (recommended)** Build a `Flex::column` of rows recursively, traversing only the expanded portion of the tree. Collapsed nodes contribute one row; their children are skipped entirely. Pros: - Simple implementation: natural match to the JSON recursive structure. - Zero per-node overhead for collapsed subtrees — large payloads stay fast as long as users don't expand everything. - Straightforward to add per-row click handlers, indentation spacers, and formatted-text spans. Cons: - If a user expands a very deep/wide tree, all rows are materialized at once. In pathological cases (e.g. 10,000-element flat array fully expanded) this could be slow. **Option B2 — Flattened virtualized list** Pre-walk the visible tree into a flat `Vec`, then render only the rows in the viewport using a virtualized scroll container. Pros: - Handles arbitrarily large fully-expanded trees efficiently. Cons: - Substantially more complex: requires a virtualization primitive that doesn't exist in `warpui` today. - MCP payloads are rarely large enough to need this. **Recommendation: B1**, with a follow-up cap (e.g. "show first N items then a '…show more' row") if real-world payloads prove problematic. The cap can be added entirely inside the `JsonTreeView` component without changing the caller. --- ### C. Expansion state storage: path-keyed vs. node-identity-keyed **Option C1 — Path-keyed `HashMap` (recommended)** A `JsonPath` is a stable sequence of key/index segments (e.g. `["response", "files", 2]`) derived by traversing the tree. State is looked up by path on each render. Pros: - Robust to streaming re-parses: the same logical node keeps its expansion state as bytes arrive, because the path is deterministic for a given position in the JSON structure. - No need to assign stable IDs to tree nodes. Cons: - Path derivation adds a small cost per render; negligible for MCP payload sizes. - Two structurally identical sibling objects share the same path — but in practice this is harmless (toggling either sibling restores the same state for both, which is acceptable). **Option C2 — Node-identity-keyed (e.g. pointer or arena index)** Assign each node a stable integer ID at parse time. Pros: - O(1) lookup by ID; truly independent state for structurally identical siblings. Cons: - Requires an arena allocator or pre-walk step to assign IDs. - IDs are invalidated on re-parse (streaming), requiring a reconciliation step to preserve expansion state. **Recommendation: C1.** The streaming-stability advantage is decisive; the structural-sibling limitation is not meaningful in practice. --- ### D. Request data flow: structured value vs. re-parsing the string **Option D1 — Store coerced `serde_json::Value` on `RequestedCommandView` (recommended)** Extend `handle_mcp_tool_stream_update` in `block.rs` (lines 2059-2079) to pass the coerced `display_input: serde_json::Value` and `name: String` alongside `command_text`. Add a `mcp_request: Option` field to `RequestedCommandView`. Pros: - Clean: no lossy string round-trip; integer coercion is inherited from the existing `coerce_integer_args` path. - The structured value is already available at the call site. Cons: - Requires touching the `handle_mcp_tool_stream_update` call signature. **Option D2 — Re-parse `command_text` in the view** Extract the JSON from the `"MCP Tool: name ()"` string at render time. Pros: - No changes to call sites. Cons: - Fragile: the format string is not stable and the outer wrapper makes clean JSON extraction unreliable. - Integer coercion would need to be re-applied. **Recommendation: D1.** --- ### E. Context menu / Copy JSON implementation **Option E1 — Custom right-click handler with `warpui` Menu (recommended)** Use `Hoverable::with_on_right_click` (already used in other inline actions) to show a `Menu` element containing "Copy" and "Copy JSON" items. Each row in the tree registers its own right-click handler, capturing the `JsonPath` of that row. **Note on toggle disambiguation:** The expansion-state API uses two separate `HashMap, bool>` maps — one for container node toggle state and one for long-string toggle state — along with two corresponding action variants (`ToggleJsonNode` and `ToggleJsonString`). This provides the independent persistence required between object/array expansion and long-string expansion. Implemented in Phase 2. Pros: - Consistent with existing right-click menus elsewhere in the app. - Per-row context (the path captured in the handler) allows "Copy JSON" to copy exactly the subtree at that row. Cons: - Each rendered row needs a right-click handler, adding a small amount of per-row boilerplate. **Option E2 — Single root right-click handler + hit-test** Attach one right-click handler to the whole tree container and determine which row was clicked by hit-testing the mouse position. Pros: - Fewer closures. Cons: - Hit-testing is non-trivial with the existing element model and would require storing row bounding boxes. **Recommendation: E1.** Per-row handlers are simpler and follow existing patterns. ## Proposed changes and phasing The implementation naturally divides into three phases. Each phase is independently reviewable and shippable. --- ### Phase 1 — Generic `JsonTreeView` component and unit tests **Goal:** A standalone, tested component that renders a `serde_json::Value` as an interactive tree. No changes to any agent or MCP code in this phase. **Files:** - `app/src/ui_components/json_tree.rs` (new) — the `JsonTreeView` component. Public surface: - `pub struct JsonTreeColors` — resolved `ColorU`s per value type, built from `WarpTheme` per the mapping in Design §A1. - `pub struct JsonTreeState` — two `HashMap, bool>` maps: one for node expansion, one for long-string expansion. `PathSegment = Key(String) | Index(usize)`. `Vec` derives `Hash + Eq` and is used directly as the key (no `Rc` indirection needed). Methods: `is_expanded(path, depth) -> bool` (default: `true` at depth 0, `false` deeper), `toggle(path)`. - `const LONG_STRING_THRESHOLD: usize = 120` — strings longer than this character count, or containing a `\n`, are elided by default. - `pub fn render_json_tree(root: &serde_json::Value, root_label: Option<&str>, state: &JsonTreeState, colors: &JsonTreeColors, on_toggle: impl Fn(Vec), on_copy_json: impl Fn(Vec, &serde_json::Value), appearance: &Appearance) -> Box` — builds a `Flex::column` of rows (Design §B1). Each row is a `Flex::row` of: indent spacer (depth × `INDENT_PX = 12.`), chevron (`Icon::ChevronRight` when collapsed, `Icon::ChevronDown` when expanded — standard `warpui` icons, no import from `inline_action`), `FormattedTextElement` of colored key/value spans, and a right-click `Hoverable` (Design §E1) that opens a `Menu` with Copy and Copy JSON items. - `app/src/ui_components/mod.rs` — declare `json_tree`. - `app/src/ui_components/json_tree_tests.rs` (new, `#[cfg(test)]`) — pure logic tests covering only Phase 1 functionality: - Annotation formatting: `{} 0/1/N keys`, `[] 0/1/N items` (Behavior 8, 12). - Long-string detection at/over `LONG_STRING_THRESHOLD` and multi-line strings (Behavior 21-24). - Integer rendering: whole-float → integer (Behavior 30); duplicate keys retained (Behavior 31). - `JsonTreeState::toggle` independence: toggling one path leaves other paths unchanged (Behavior 9, 15). - Empty container: no expansion possible (Behavior 12). **No changes to agent or MCP code. Reviewable alone.** --- ### Phase 2 — MCP data pipeline: structured value and result normalization **Goal:** Thread the structured `serde_json::Value` request through to `RequestedCommandView` and normalize `CallMCPToolResult` into a renderable form. Still no visible UI change (the old `Text` render path remains active). **Files:** - `app/src/ai/blocklist/inline_action/requested_command.rs`: - New fields on `RequestedCommandView`: `mcp_request: Option` where `McpRequest { name: String, args: serde_json::Value }`. - New fields: `mcp_tree_state: JsonTreeState` — covers both request and response trees; paths namespaced by a synthetic root segment (`PathSegment::Key("__request__")` / `PathSegment::Key("__response__")`) so the two trees do not collide. - New `RequestedCommandViewAction` variants: `ToggleJsonNode { path: JsonPath }`, `ToggleJsonString { path: JsonPath }`. Handled in `handle_action` by calling `mcp_tree_state.toggle(...)` + `ctx.notify()`. - `app/src/ai/blocklist/block.rs` (2059-2079) — extend `handle_mcp_tool_stream_update` to also pass `display_input: serde_json::Value` and `name: String` through to the view, populating `mcp_request` (Design §D1). Keep building `command_text` for the collapsed header. - New helper `fn mcp_result_to_renderable(result: &CallMCPToolResult) -> McpRenderable` where: ``` enum McpRenderable { Tree(serde_json::Value), Error(String), Cancelled } ``` Logic: `Success { result }` → prefer `result.structured_content`; else try `serde_json::from_str` on joined text content; else wrap in a JSON `String` value. `Error(e)` → `McpRenderable::Error(e)`. `Cancelled` → `McpRenderable::Cancelled`. **Unit tests for `mcp_result_to_renderable` added to `json_tree_tests.rs`** (Behavior 28, 29). **No user-visible UI change; the old `Text` render path remains active. New action enum variants and fields are code changes but produce no visible difference. Reviewable alone.** --- ### Phase 3 — Replace the render body + context menu **Goal:** Wire up the `JsonTreeView` component in place of the old `Text` + `serde_json::to_string_pretty`, add the context menu, and ship. **Files:** - `app/src/ai/blocklist/inline_action/requested_command.rs` (`should_render_mcp_content` block, lines 1430-1483): - Replace `content_text`/single-`Text` with two labeled sections (Request + Response divider, Behavior 4) each calling `render_json_tree(...)`. - Request section: `render_json_tree(&self.mcp_request.args, "Request", &self.mcp_tree_state, &colors, ...)` (or `null` indicator when `mcp_request` is absent, Behavior 29). - Response section: present only when `action_status.finished_result()` exists; dispatches to tree, error label (`Text` with `ui_error_color`), or cancelled label (Behavior 28). - Tree body is wrapped in a `ConstrainedBox::with_max_height(MAX_EDITOR_HEIGHT)` and a vertical `NewScrollable` so it scrolls rather than growing unbounded (Behavior 17). - The `SelectableArea` + `mcp_content_selection_handle` wraps the scrollable tree so text selection/copy still works (Behavior 25). See Risks re: `Hoverable` interaction. - Right-click "Copy JSON" in `on_copy_json` callback: walk the `serde_json::Value` at the received path, serialize with `serde_json::to_string_pretty`, write to clipboard (Behavior 27). - Remove the now-dead `.bak` intermediates (cleanup). **Manual validation checklist** (attached to the PR, to be checked before merging): - Configure a local MCP server (e.g. filesystem) and expand a tool call: root expanded, nested collapsed (Behavior 13), chevrons toggle independently (Behavior 9), indentation per level (Behavior 10). - Large/nested response: Request/Response labels + divider visible (Behavior 4), typed colors for all value types (Behavior 18-20), light↔dark theme switch recolors without restart (Behavior 19). - Long string (file contents): elision preview + chevron, expands/collapses in place without disturbing siblings (Behavior 21-22). - Very tall expanded tree: tree scrolls, does not push subsequent blocks off-screen (Behavior 17). - Response arrives while header is collapsed: expand header to confirm both request and response trees are shown (Behavior 16). - Error and cancelled tool calls show labeled messages (Behavior 28). - Right-click → Copy JSON on a collapsed container copies complete JSON (Behavior 27). - Right-click → Copy JSON on the Request label copies the full request JSON (Behavior 26). - Copy with no selection is a no-op; Copy menu item is greyed out (Behavior 25). - Text selection and copy across key/value rows works (Behavior 25). - Collapsed header, accept/reject, and a non-MCP action (shell command) are visually unchanged (Behavior 1, 33-35). - Screenshots of expanded tree in dark and light themes attached to the PR. ## Testing and validation summary | Invariant(s) | Test type | Where | |---|---|---| | Annotation labels (8, 12) | Unit | `json_tree_tests.rs` (Phase 1) | | Toggle independence (9, 15) | Unit | `json_tree_tests.rs` (Phase 1) | | Long string detection (21-24) | Unit | `json_tree_tests.rs` (Phase 1) | | Integer/unusual values (30-31) | Unit | `json_tree_tests.rs` (Phase 1) | | `mcp_result_to_renderable` (28) | Unit | `json_tree_tests.rs` (Phase 2) | | Null/absent request (29) | Unit | `json_tree_tests.rs` (Phase 2) | | Streaming expansion stability (32) | Unit | `json_tree_tests.rs` (Phase 2) | | All visual/interaction behaviors | Manual | PR checklist (Phase 3) | ## Risks and mitigations - **Performance on very large payloads.** Mitigated by rendering only expanded nodes (Design §B1); a "show first N / show more" cap can be added inside `JsonTreeView` as a follow-up without changing callers. - **`SelectableArea` + per-row `Hoverable` interaction.** Each tree row uses `Hoverable` for right-click. Wrapping those rows in the existing `SelectableArea` may cause mouse event conflicts (the `Hoverable` right-click handler consuming events before `SelectableArea` sees them, or vice versa). The implementor should verify event propagation and may need to use `DispatchEventResult::Consumed` appropriately on the right-click path to prevent double-handling. This is the highest-risk interaction in Phase 3 and should be tested explicitly with the context menu open over a text selection. - **Selection regression.** The current single-`Text` selection is well-understood; wrapping the tree in the same `SelectableArea`/`SelectionHandle` with `FormattedTextElement` keeps the selection model intact — called out explicitly in the Phase 3 PR for reviewer attention. - **Streaming flicker.** Path-keyed state (Design §C1) prevents losing expansion when request args stream in; covered by unit tests. - **Copy JSON clipboard access.** Clipboard writes already work in other right-click menus in the app; same mechanism applies here. ## Follow-ups - Auto-collapse of very large roots (Behavior 14 open question). - Reuse `JsonTreeView` for `ReadMCPResourceResult` and other JSON-bearing surfaces (natural next consumer after Phase 3 ships). - Potential virtualization for pathologically large expanded trees (Design §B2), if needed. - Confirm or update `LONG_STRING_THRESHOLD = 120` based on real-world MCP payloads seen in dogfooding.