Files

268 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<serde_json::Value>` and `content: Vec<Content>` (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<dyn Element>`. 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<TreeRow>`, 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<JsonPath, bool>` (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<McpRequest { name, args }>` 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 (<value>)"` 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<Vec<PathSegment>, 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<Vec<PathSegment>, bool>` maps: one for node expansion, one for long-string expansion. `PathSegment = Key(String) | Index(usize)`. `Vec<PathSegment>` 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<PathSegment>), on_copy_json: impl Fn(Vec<PathSegment>, &serde_json::Value), appearance: &Appearance) -> Box<dyn Element>` — 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<McpRequest>` 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.