Skip to content

fix(mcp): render notes and app resilience for MCP UI apps - #96640

Open
GeneralistDev wants to merge 28 commits into
posthog/desktop-mcp-apps-pi-sessionsfrom
posthog/mcp-ui-render-notes-app-resilience
Open

fix(mcp): render notes and app resilience for MCP UI apps#96640
GeneralistDev wants to merge 28 commits into
posthog/desktop-mcp-apps-pi-sessionsfrom
posthog/mcp-ui-render-notes-app-resilience

Conversation

@GeneralistDev

@GeneralistDev GeneralistDev commented Sep 8, 2026

Copy link
Copy Markdown
Member

Problem

A model that runs the exec tool from a supported UI host draws its own chart of data that an interactive view already shows. The second chart costs tokens and review time.

An analytics initialization error also stopped the UI app from rendering at all.

This layer builds on the two desktop layers below it in the stack (#96638, #96639).

Changes

  • Supported UI hosts get a render note: the interactive view already shows the result. CLI and JSON output do not change.
  • The render-note footer counts in output token estimates.
  • A UI app stays alive when posthog-js-lite cannot initialize.

Note

This layer sits on top of the desktop layers on purpose. If the render note landed first, it would tell models that a chart rendered while the desktop fix was not live yet.

How did you test this code?

  • Build-tool-result tests pin the render note for UI hosts, CLI clients, and JSON output.
  • Exec tests pin the token estimate with the footer included.
  • The MCP unit tests, lint, and format checks passed.

👉 Stay up-to-date with PostHog coding conventions for a smoother review.

Automatic notifications

  • Publish to changelog?

Docs update

None. This fix does not change a documented setting or workflow.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

This PR is the top layer of a 3-layer stack split from #95878. The user asked for the split and approved the grouping before the work started. The combined tree of the stack matches the original branch tree exactly, so no code changed in the split.

The code itself was authored and tested in #95878. This PR only reorganizes those commits. PostHog Desktop and command-line development tools supported the split.

Skills invoked: /stacking-prs, /writing-pr-descriptions, /unslop.


Created with PostHog Desktop

@GeneralistDev GeneralistDev changed the title posthog/mcp ui render notes app resilience fix(mcp): render notes and app resilience for MCP UI apps Sep 8, 2026
@GeneralistDev GeneralistDev added the feature/desktop Feature Tag: Desktop label Sep 8, 2026
@GeneralistDev GeneralistDev self-assigned this Sep 8, 2026
@GeneralistDev
GeneralistDev marked this pull request as ready for review September 8, 2026 13:04
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

🚨 Comment density — 30% of added code lines are comments (52 of 174)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
services/mcp/src/lib/build-tool-result.ts 29 57
services/mcp/src/ui-apps/analytics/posthog.ts 8 23
services/mcp/tests/unit/build-tool-result.test.ts 7 17
services/mcp/tests/unit/exec.test.ts 4 8
services/mcp/tests/unit/ui-apps-analytics.test.ts 4 68

This check does not block merging. It updates on every push and clears when the share drops.

ℹ️ MCP UI apps size — 33 app(s), 17665.2 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 600.0 KB 195.2 KB
action 458.2 KB 195.2 KB
action-list 564.9 KB 195.2 KB
cohort 457.2 KB 195.2 KB
cohort-list 563.8 KB 195.2 KB
email-template 457.0 KB 195.2 KB
error-details 472.9 KB 195.2 KB
error-issue 457.9 KB 195.2 KB
error-issue-list 564.7 KB 195.2 KB
experiment 562.0 KB 195.2 KB
experiment-list 565.6 KB 195.2 KB
experiment-results 567.1 KB 195.2 KB
feature-flag 567.6 KB 195.2 KB
feature-flag-list 571.4 KB 195.2 KB
feature-flag-testing 461.4 KB 195.2 KB
inline-scan 457.7 KB 195.2 KB
insight-actors 563.0 KB 195.2 KB
invite-email-preview 456.4 KB 195.2 KB
llm-costs 560.0 KB 195.2 KB
session-recording 459.0 KB 195.2 KB
survey 458.8 KB 195.2 KB
survey-global-stats 562.7 KB 195.2 KB
survey-list 565.5 KB 195.2 KB
survey-stats 562.7 KB 195.2 KB
trace-span 457.6 KB 195.2 KB
trace-span-list 564.7 KB 195.2 KB
vision-observation-list 563.9 KB 195.2 KB
workflow 457.5 KB 195.2 KB
workflow-list 564.2 KB 195.2 KB
loops-review 461.9 KB 195.2 KB
query-results 756.3 KB 195.2 KB
render-ui 839.5 KB 195.2 KB
visual-review-snapshots 462.0 KB 195.2 KB

@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
services/mcp/src/ui-apps/analytics/posthog.ts:40-52
**Recovery path lacks coverage**

The new initialization-failure path is not tested by making the PostHog constructor or `register` throw. Without that regression coverage, a later refactor could remove the recovery behavior or the `client = null` reset and reintroduce the UI-app crash without the MCP unit suite detecting it.

### Issue 2
services/mcp/src/lib/build-tool-result.ts:104-106
**Token test misses footer**

The token-estimation test only checks that the result exceeds the pointer's token count. That was already true when only `structuredContent` was counted, so the test would still pass if `estimateTokens(footer)` were removed. Assert the structured-content estimate plus the footer estimate so this new accounting behavior is actually protected.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "chore(mcp): trim comments in tool result..." | Re-trigger Greptile

Comment thread services/mcp/src/ui-apps/analytics/posthog.ts
Comment thread services/mcp/src/lib/build-tool-result.ts Outdated
@trunk-io

trunk-io Bot commented Sep 8, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

Codex sessions write tool-call metadata on `_meta.posthog`, but the
workspace-server only read the legacy `_meta.claudeCode` field when deciding
whether a tool call is an MCP call. That gate is what wires a completed MCP
tool call to the MCP Apps host, so Codex sessions never triggered it.

The Codex mapping layer also only forwarded human-readable text on
`tool_call_update`, dropping the tool-call `_meta` and the raw MCP result
(`content`/`structuredContent`/`_meta.ui`) that the MCP Apps host needs to
render an inline UI resource.

Fixes both: read the canonical `_meta.posthog` channel (falling back to the
legacy one) via the existing `readAgentToolName`/`readMcpToolName` helpers,
and have the Codex mapping carry `_meta` and `rawOutput` on the completed
update.

Generated-By: PostHog Desktop
Task-Id: 7a47c916-8e28-4157-9782-3ed394d68232
The MCP Apps iframe bridge is torn down and rebuilt whenever the underlying
UI resource is refetched (e.g. an MCP tool-discovery refresh). If a tool
result was already queued for delivery but the bridge had not yet finished
its handshake, teardown discarded the queue — and the "already sent" guard
lived in the calling component, so it survived the teardown and blocked any
later retry. The chart iframe would mount, then sit on its loading state
forever with no error anywhere.

Moves the "already sent" tracking into the bridge hook itself, resetting it
in the same cleanup that tears the bridge down, so a lost delivery can
legitimately retry against the next bridge instead of being silently
dropped for good.

Also stubs `document.fonts` in the Vitest jsdom setup — the app theme util
this area touches calls `document.fonts.check()`, which jsdom doesn't
implement, and nothing exercised that code path in tests before now.

Generated-By: PostHog Desktop
Task-Id: 7a47c916-8e28-4157-9782-3ed394d68232
…roup

groupToolRuns collapses a run of >=2 tool calls into a "Thinking..." marker, which buried an MCP tool's rendered chart behind the collapsed summary instead of showing it as its own row. Excludes MCP tool calls from grouping, mirroring the legacy buildThreadGroups' excludeMcpApps.

Generated-By: PostHog Desktop
Task-Id: 7a47c916-8e28-4157-9782-3ed394d68232
… call

The previous commit excluded any MCP-tagged tool call from grouping, but Codex routes every underlying tool through one inline-exec wrapper, so that pulled every tool call out of grouping and left the thread ungrouped and noisy. Narrows the check to whether the call's resolved result actually carries a UI-app resource, which is also the earliest point a chart could exist.

Generated-By: PostHog Desktop
Task-Id: 7a47c916-8e28-4157-9782-3ed394d68232
A row that mounts already-completed (a chart pulled out of a collapsed tool-call group, or a scroll-back remount) queues its result via the exec-replay effect, then oninitialized's own remount catch-up sent the same result again with no dedup against that queue. Routes the catch-up through sendResultOnce so both paths share the sentResultForCallRef guard.

Generated-By: PostHog Desktop
Task-Id: 7a47c916-8e28-4157-9782-3ed394d68232
…o apps

A Codex session never finished loading a query-runner chart in the chat,
while Claude-based sessions rendered it. The Codex app-server protocol
models an absent MCP result optional as a nullable field, so the raw
result it hands back carries an explicit structuredContent null where
the PostHog MCP server had omitted the key. The app-side zod schema
types these fields as optional, which rejects an explicit null, so the
app's tool-result notification failed validation and was dropped
silently. The app kept waiting for data and stayed on its loading state.

Normalize in toCallToolResult, the single boundary every delivery path
(exec replay, remount catch-up, subscription) funnels through: drop
structuredContent / isError / _meta when null, keep the payload
otherwise unchanged.

Generated-By: PostHog Desktop
Task-Id: c01c9ac0-55de-4805-92fe-8faef02b242a
The Codex app-server models an absent MCP result optional as nullable and
serializes it as an explicit JSON null, so the nulls now enter the
pipeline once and are removed where they enter, not only at the app
bridge. The codex adapter wraps `item.result` with the new shared
`omitNullCallToolResultFields` helper, and the app-bridge normalization
in `toCallToolResult` now uses the same helper. Stored transcripts and
McpAppsService events therefore carry clean payloads, so a future
delivery path that skips `toCallToolResult` cannot reintroduce the
stuck-chart failure.

Generated-By: PostHog Desktop
Task-Id: c01c9ac0-55de-4805-92fe-8faef02b242a
A reply to a PostHog MCP query now shows two charts: the interactive
chart the MCP app renders for the tool result, and the model's own
embedded hogql chart. The rich-output prompt tells the model to render
every numeric answer as a hogql chart, and it follows that instruction
even when the result already rendered as an interactive chart.

Add one rule to the shared prompt, which every harness receives with its
session prompt: a PostHog MCP query call already renders its result as
an interactive chart, so the reply states the conclusion in text and
does not embed the same data again.

Generated-By: PostHog Desktop
Task-Id: c01c9ac0-55de-4805-92fe-8faef02b242a
The rich-output prompt claimed every PostHog MCP query call already
renders its result as an interactive chart. Only tools with a UI app do:
query-trends and the other insight wrappers, not execute-sql or
query-logs. On those two paths the bullet told the model to skip the
hogql chart the bullet above it requires, so a user got a text-only
answer where they previously got a live chart.

The MCP server already emits the exact signal: UI_APP_RENDER_NOTE rides
the tool result only when the tool has a resourceUri and the client is a
UI-app host. Anchor the skip rule to that note instead of to the tool
call, so the model skips the chart only when a view will actually
render.

Generated-By: PostHog Desktop
Task-Id: ef6db808-523a-4319-b0a6-d4897e83f41d
Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
…teardown

Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
A query tool result reaches a Pi session with its UI payload on
`_meta`, but three gaps stop the chart from rendering: the harness
MCP tool keeps only the content blocks and drops `structuredContent`
and `_meta`, the proxy tool's details never classify the call as MCP,
and no Pi code path registers MCP server configs with the apps
service. The server already tells the model that the interactive view
shows the result, so the model stops drawing its own chart and the
user sees no chart at all.

- The harness MCP tool now carries `structuredContent` and `_meta` on
  the tool result's details, out of the model context, on both the
  proxy and direct call paths.
- The Pi translator classifies those calls as MCP and lands the full
  result on `rawOutput`, which the apps host resolves.
- The Pi client factory registers the session's MCP servers with the
  apps service and runs discovery, like AgentService does for Claude
  and Codex.
- Exec tool rows read the label from the arguments the `mcp` proxy
  tool wraps.

Generated-By: PostHog Desktop
Task-Id: 7d773290-e531-4e36-9806-50f3a5db6e3f
A model that passes a tool's arguments straight to the `mcp` proxy tool,
without the `tool` name, got the generic usage line. The line names no
tool, so the model burns retries guessing at the double wrapping. A
GLM session hit exactly this and fell back to a raw hogql chart.

Match the stray argument keys against each tool's required parameter
names, from live registrations first and then the on-disk tool cache,
and name the tool in the hint. One retry assembles the correct call.

Required parameter names now ride on the bridge's tool metadata and
the cache entries. Old cache entries lack the field and stay unmatched
until the next refresh rewrites them, so the hint degrades to the
generic line.

Generated-By: PostHog Desktop
Task-Id: 7d773290-e531-4e36-9806-50f3a5db6e3f
Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
…result

The shared rich-output prompt rule ("do not duplicate an MCP chart with a
hogql one") stopped GPT/Codex sessions from re-embedding the chart, but a
Claude Sonnet session still showed two charts: the interactive chart the
MCP app renders for query-trends, and the model's own <hogql> chart under
it. A rule in the session prompt proved too far from the data for Sonnet
to honor at reply time.

Append a note to the tool result text itself, at the point where the
reply gets written. It fires only on the exec path for clients that
render UI apps (PostHog Desktop, Claude Code inside it, Cowork) - a CLI
client sees no view, and its model may legitimately re-present the data,
so it gets no note. output_format=json keeps the text machine-parseable
with no footer.

estimateResponseTokens keyed off the exact STRUCTURED_CONTENT_ONLY_TEXT
pointer to decide the structured payload is what gets counted; the note
now follows the pointer, so the check matches the prefix instead.

Generated-By: PostHog Desktop
Task-Id: 2e57bf95-62bd-4353-adba-6a584f3267ce
A local MCP build with POSTHOG_UI_APPS_TOKEN set rendered a blank app
iframe in PostHog Desktop. posthog-js-lite 4.11.0 reads window.localStorage
in its constructor without a guard; MCP app iframes are sandboxed without
allow-same-origin, so the property read throws SecurityError, uncaught,
and kills the whole app. Production renders only because its build
leaves the token unset, so analytics never initializes.

Guard initPostHog with try/catch: analytics is optional, and a client
that cannot initialize must not take the app down with it. The capture
helpers already no-op on a null client, so a failed init disables
analytics and nothing else.

No new test: it would have to mock the posthog-js-lite constructor to
assert the catch runs, which tests that a try/catch exists rather than
any behavior. The unit suites still pass.

Generated-By: PostHog Desktop
Task-Id: 2e57bf95-62bd-4353-adba-6a584f3267ce
Generated-By: PostHog Desktop
Task-Id: 66100a80-baa2-4414-94ab-b96105ea756f
Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
Generated-By: PostHog Desktop
Task-Id: f08d733b-183a-4aca-9ac2-3be5a92cda3e
@GeneralistDev
GeneralistDev force-pushed the posthog/mcp-ui-render-notes-app-resilience branch from 8843cf6 to fd6d1c3 Compare September 8, 2026 13:46
@posthog

posthog Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Thermo-nuclear code quality review

Strict maintainability pass on this layer, run with the thermo-nuclear-code-quality-review rubric. This is the smallest layer of the three, and the try/catch is correct and well explained. One finding is structural.

1. estimateResponseTokens takes the text apart to find what the builder just joined

if (response.structuredContent && text.startsWith(STRUCTURED_CONTENT_ONLY_TEXT)) {
    const footer = text.slice(STRUCTURED_CONTENT_ONLY_TEXT.length)
    return estimateTokens(response.structuredContent) + estimateTokens(footer)
}

buildToolResultPayload joins the pointer and the note into one string. Twenty lines later, estimateResponseTokens cuts that string back apart by prefix length to count the two pieces separately. The two functions are now coupled through the exact text of a constant, and the comment you updated documents the coupling instead of removing it:

estimateResponseTokens keys off the pointer prefix

This breaks quietly. A second footer, a leading note, or any reordering gives a wrong count with no error. The equality check it replaces at least failed loudly.

Please keep the pieces separate until the payload is built. For example, collect the footers in a list:

const footers: string[] = []
if (discoveryHint) footers.push(discoveryHint)
if (includeUiResponseMeta && resourceUri && !useJson) footers.push(UI_APP_RENDER_NOTE)
const text = [body, ...footers].join('\n\n')

Then the token count is estimateTokens(structuredContent) + estimateTokens(footers.join('')) when the body is only the pointer. No string is cut apart, and the two functions stop depending on the wording of a constant. This also removes the duplicated text = \${text}\n\n...`` pattern that now appears twice.

2. includeUiResponseMeta now controls two unrelated things

The same flag gates both:

  • line 213 — append prose to the text channel for the model.
  • line 223 — move the app payload onto _meta.

The name describes the second one only. A caller who wants the payload move now also gets a sentence added to the model's context, and nothing in the name warns them. tool-executor.ts already documents the flag as the payload-move switch.

Please use a separate option for the note, for example includeRenderNote. The exec.ts call site sets both, so nothing changes in behavior, and each flag then says what it does.

3. The condition uses the raw value where a named one exists

const hasUiResource = !!resourceUri
...
if (includeUiResponseMeta && resourceUri && !useJson) {

hasUiResource is defined for this, and the sibling conditions use it. Please use it here too.

More generally, buildToolResultPayload now gates on isStringResult, useJson, callerWantsJson, hasUiResource, forceUiDataToMeta, includeUiResponseMeta, suppressStructuredContent and structuredContentOnly. Each one arrived for a good reason, and each is explained well. Together they are hard to hold in mind, and this PR adds a ninth gate. It is not this PR's job to fix, but the function is close to needing a typed decision object instead of eight independent booleans.

4. The try/catch also covers register

try {
    client = new PostHog(...)
    client.register({...})
} catch (error) {
    client = null
}

The comment names the constructor as the thing that throws. If register throws instead, this drops a client that works, and the app loses all analytics rather than two properties. Please put only the constructor inside the try, or catch register separately.

Also: is the localStorage read configurable? If posthog-js-lite has a persistence option, setting it is the direct fix, and the try/catch becomes a guard against something else. If it is not configurable, please pin the version in the comment, so a later upgrade that fixes it can also delete this block.

Verdict

The behavior is correct, and the sandbox comment is exactly the kind of note that saves the next reader. Please fix #1 before merge. #2, #3 and #4 are small and worth doing in the same pass.

Automated strict code-quality review, requested in Slack.

Code-structure pass from the review on PR #96638; no behavior
changes.

- Name the "renders alone" concept: `rendersStandalone` in ChatThread
  wraps the three predicates, and `hasUiAppResult` moves to the
  mcp-apps feature, matching where `isPlanItem` and `isShowActionsItem`
  live.
- Move `omitNullCallToolResultFields` from `tool-meta.ts` to its own
  module, `shared/src/call-tool-result.ts`, so tool-meta keeps one
  subject (_meta handling).
- Document which failure each null-strip call site stops: the adapter
  strip is source hygiene for stored transcripts and McpAppsService
  events; `toCallToolResult` owns the schema-valid result an app
  receives.
- State in a comment why `keepMounted` on the group body is not the
  fix for the chart-exclusion rule.
- Clarify the lifecycle effect's contract with `sendResultOnce` in its
  deps, and the remount send's path through `sendWhenReady`.

Generated-By: PostHog Desktop
Task-Id: c63c7dce-f6d5-4ddd-a5fb-20f458bfa03a
…/desktop-mcp-apps-pi-sessions

Generated-By: PostHog Desktop
Task-Id: c63c7dce-f6d5-4ddd-a5fb-20f458bfa03a
Review finding: `findUnwrappedCallTarget` and the `requiredParams`
plumbing it carries through ToolMeta, CachedToolInfo, and the tool
cache improve one usage-hint string and have no part in rendering
MCP UI apps in Pi sessions. Reverts the two commits that added it;
the hint returns in its own PR stacked above this one.

Generated-By: PostHog Desktop
Task-Id: c63c7dce-f6d5-4ddd-a5fb-20f458bfa03a
Review findings on PR #96639, no behavior changes:

- The `posthog.mcp` details envelope is now declared once, next to
  McpResultMeta in tool-bridge.ts (McpCallDetails). The proxy tool's
  inline type, the bridge's object literal, and the translator's schema
  all derive from it; the harness exposes tool-bridge as a subpath so
  the agent package can import the type.
- mcpCallDetails(server, tool, result) builds the posthog fragment from
  an invokeTool return, so the 12-line conditional spread exists in one
  place and both call sites are one line.
- readMcpResultMeta is gone: mcpToolDetailsSchema now parses the
  `result` field it previously stripped, so the translator reads
  mcp.result from the one parse instead of walking the object twice.
- The exec display comment states why the structured descriptor cannot
  replace PI_POSTHOG_EXEC_RE (it is written on the result; the display
  reads the input) and the unused capture group is removed.
- The reuse check drops the `existing.config` guard: the field is
  required, so the state it guarded is impossible.
- The Pi factory comment documents the discovery join: a resource fetch
  that arrives mid-discovery joins the in-flight promise, so
  fire-and-forget discovery has no race with the first render.

Generated-By: PostHog Desktop
Task-Id: c63c7dce-f6d5-4ddd-a5fb-20f458bfa03a
…ui-render-notes-app-resilience

Generated-By: PostHog Desktop
Task-Id: c63c7dce-f6d5-4ddd-a5fb-20f458bfa03a
Review findings on PR #96640:

- buildToolResultPayload now collects the text channel as a body plus a
  footers list and joins once. estimateResponseTokens reads the pieces
  the builder recorded (a WeakMap entry, carried across
  markExecPayload) instead of slicing the joined string by the pointer
  prefix, so the two functions no longer depend on the wording of a
  constant and the duplicated footer-concat pattern is gone.
- The render note moves to its own option, includeRenderNote. The exec
  call site sets it with includeUiResponseMeta; each flag now says what
  it does, and the _meta payload move no longer silently adds context
  text.
- The render-note gate uses hasUiResource like its sibling conditions.
- The analytics try/catch covers only the constructor: a register
  failure now keeps the working client, so the app loses two
  properties instead of all events. The comment pins posthog-js-lite
  4.12.0 and records that its persistence option cannot avoid the
  unguarded localStorage read the constructor performs.

Generated-By: PostHog Desktop
Task-Id: c63c7dce-f6d5-4ddd-a5fb-20f458bfa03a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/desktop Feature Tag: Desktop

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant