Files
galaxy/specs/APP-2527/TECH.md
T

21 KiB
Raw Blame History

MCP Tool Call JSON Tree Rendering — Tech Spec

Linear: APP-2527 Companion product spec: specs/APP-2527/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:

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 ColorUs 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). CancelledMcpRenderable::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.