Skip to content

fix(mcp): tell the user when the MCP server or plugin is already installed - #1075

Merged
edwinyjlim merged 4 commits into
mainfrom
posthog-code/mcp-add-already-installed-copy
Aug 11, 2026
Merged

fix(mcp): tell the user when the MCP server or plugin is already installed#1075
edwinyjlim merged 4 commits into
mainfrom
posthog-code/mcp-add-already-installed-copy

Conversation

@edwinyjlim

@edwinyjlim edwinyjlim commented Aug 10, 2026

Copy link
Copy Markdown
Member

Problem

Running wizard mcp add and picking an editor that already has PostHog set up printed a bare "Installation skipped." and exited immediately — no reason given, and the follow-on steps (Slack, suggested prompts) were skipped because the outcome was recorded as Failed.

Codex hit this reliably: codex plugin marketplace add exits non-zero once the marketplace is registered, so the install looked like a failure with an empty result set. Any client whose install genuinely failed produced the same message, so "skipped", "already there" and "broke" were indistinguishable.

Changes

  • Install results are per-client now — installed / already-installed / failed, with a short reason on failures — instead of a list of names that silently dropped everything else.
  • The Done screen renders each group with copy that explains what happened ("Plugin was already installed for: …", "MCP server was already installed for: … Left as-is. To change which PostHog areas it can reach, run wizard mcp remove first", "Couldn't install for: … "). The non-interactive path reports the same three outcomes separately.
  • Already-installed counts as a working install, so the flow continues rather than quitting early.
  • Codex checks config.toml (and Claude Code checks plugin list) before installing, rather than inferring the no-op from CLI error text; the file-based clients compare the existing entry and skip the write when it's identical.

Two things found while tracing the same flow: CLI error text is redacted before it reaches the log/screen/exception reports (the failing command echoes back the Authorization header), and the chosen install mode is passed to the installer instead of read from state set in the same tick — which made a single-editor machine take the MCP-only path after choosing "Install with all features".

Analytics keep clients meaning "ended up with it", with already_installed / failed / attempted as new breakdowns.

Test plan

pnpm build && pnpm test && pnpm fix — 1771 tests pass, no lint errors. New coverage for the already-installed and failure paths in Codex, Claude Code, the shared config-file client, the installer service, and the result helpers (including redaction).

Follow-up: the rest of the failure states

An audit of every failure path in both flows found the same class of bug elsewhere, fixed in the second commit:

  • mcp remove claimed success unconditionallyremoveMCPServer discarded each client's result, so every client detected before the attempt was reported as removed even if the removal threw. Removals now carry per-client results and reasons.
  • The non-interactive mcp remove printed nothing at all — no success, no failure, not even "nothing to remove".
  • Crashes read as user choices — a detection crash showed "No supported MCP clients detected"; a crash mid-install showed "no editor was selected". Both now name the error and point at --debug.
  • mcp remove --local never targeted posthog-local — the flag was dropped in McpInstaller.remove() and ignored by Codex's removeServer.
  • mcp remove fell back to the non-TTY path on any TUI error, masking real bugs; it now uses the same isTUIUnavailable guard as mcp add.

Known gap, not addressed here: a client that's gated off by platform (e.g. Cursor on Linux) is silently absent from the detected list rather than explained — isClientSupported() returns a bare boolean with no channel for a reason.


Created with PostHog from a Slack thread

…alled

`mcp add` collapsed "already installed" into an empty result set, so a re-run
printed "Installation skipped." with no reason and, because the outcome was
Failed, skipped the follow-on steps and exited immediately. Codex hit this every
time: `codex plugin marketplace add` exits non-zero once the marketplace is
registered.

Install results are now per-client (installed / already-installed / failed) with
a failure reason, and the Done screen renders each group with copy that says
what happened. Already-installed counts as a working install, so the flow
continues instead of quitting.

Also in here, found while tracing the same flow:
- Codex asks config.toml (and Claude Code asks `plugin list`) before installing,
  instead of inferring the no-op from CLI error text.
- CLI error text is redacted before it reaches the log, the screen or an
  exception report — the failing command echoes back the Authorization header.
- The tri-state install mode is passed to the installer rather than read from
  state set in the same tick, which made a single-editor machine take the
  MCP-only path after choosing "Install with all features".

Generated-By: PostHog Code
Task-Id: ae3d3411-9e66-468b-aa6c-35ee0a410249
@github-actions

Copy link
Copy Markdown

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci ai-observability
  • /wizard-ci basic-integration
  • /wizard-ci mcp-analytics
  • /wizard-ci revenue
  • /wizard-ci self-driving

Test an individual app:

  • /wizard-ci ai-observability/anthropic
  • /wizard-ci ai-observability/groq
  • /wizard-ci ai-observability/manual-capture
Show more apps
  • /wizard-ci ai-observability/openai
  • /wizard-ci ai-observability/openai-agents
  • /wizard-ci ai-observability/vercel-ai
  • /wizard-ci basic-integration/android
  • /wizard-ci basic-integration/angular
  • /wizard-ci basic-integration/astro
  • /wizard-ci basic-integration/django
  • /wizard-ci basic-integration/fastapi
  • /wizard-ci basic-integration/flask
  • /wizard-ci basic-integration/javascript-node
  • /wizard-ci basic-integration/javascript-web
  • /wizard-ci basic-integration/laravel
  • /wizard-ci basic-integration/next-js
  • /wizard-ci basic-integration/nuxt
  • /wizard-ci basic-integration/python
  • /wizard-ci basic-integration/rails
  • /wizard-ci basic-integration/react-native
  • /wizard-ci basic-integration/react-router
  • /wizard-ci basic-integration/sveltekit
  • /wizard-ci basic-integration/swift
  • /wizard-ci basic-integration/tanstack-router
  • /wizard-ci basic-integration/tanstack-start
  • /wizard-ci basic-integration/vue
  • /wizard-ci mcp-analytics/custom-dispatcher
  • /wizard-ci mcp-analytics/typescript-sdk
  • /wizard-ci revenue/stripe
  • /wizard-ci self-driving/astro
  • /wizard-ci self-driving/fastapi
  • /wizard-ci self-driving/nuxt
  • /wizard-ci self-driving/react-router
  • /wizard-ci self-driving/sveltekit

Results will be posted here when complete.

…aiming success

Auditing the rest of the flow for the same class of bug turned up four more
places where a failure produced no copy, or the wrong copy:

- `mcp remove` reported every client detected before the attempt as removed,
  because removeMCPServer discarded each removeServer result. Removals now
  return per-client results, so a failed removal says so with a reason.
- The non-interactive `mcp remove` printed nothing at all — no success, no
  failure, not even "nothing to remove".
- A crash in client detection rendered as "No supported MCP clients detected",
  identical to genuinely having none; a crash during install/remove rendered as
  "no editor was selected". Both now name the error and point at --debug.
- `mcp remove --local` never targeted posthog-local: the flag was dropped by
  McpInstaller.remove() and ignored by Codex's removeServer.

Also: `mcp remove` fell back to LoggingUI on any TUI error, masking real bugs as
a missing TTY — it now uses the same isTUIUnavailable guard as `mcp add`, moved
to a shared module. Codex failure reasons are redacted like Claude Code's.

McpClientStatus members are renamed Changed/Unchanged since both flows report
through them now.

Generated-By: PostHog Code
Task-Id: ae3d3411-9e66-468b-aa6c-35ee0a410249
@edwinyjlim
edwinyjlim marked this pull request as ready for review August 10, 2026 19:40
@edwinyjlim
edwinyjlim requested a review from a team as a code owner August 10, 2026 19:40
@posthog

posthog Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 1 should fix, 3 consider.

Published 4 findings (view the review).

@gewenyu99 gewenyu99 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screen.Recording.2026-08-11.at.1.15.23.PM.mov
Screen.Recording.2026-08-11.at.1.16.11.PM.mov

2 requests:

  • Make this require a click to dismiss or give me like 20 seconds to read it. It flashes by with too much text
  • More padding between items

Otherwise, this works great!

Sending Code review to review the actual code, it looks good on vibes but I'm not super familiar with the MCP code touched here

@posthog

posthog Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Bugfix

Issues: 3 issues

Files (5)
  • src/steps/add-mcp-server-to-clients/MCPClient.ts
  • src/steps/add-mcp-server-to-clients/clients/claude-code.ts
  • src/steps/add-mcp-server-to-clients/clients/claude-web.ts
  • src/steps/add-mcp-server-to-clients/clients/codex.ts
  • src/steps/add-mcp-server-to-clients/plugin-client.ts
What were the main changes
  • File-based MCPClient now compares the existing config entry and skips the write when identical, returning alreadyInstalled instead of silently rewriting or failing
  • MCPClient.removeServer reports alreadyInstalled (not a bare failure) when there is no config file or no PostHog entry left to delete
  • Codex checks config.toml via isPluginInstalled before running plugin marketplace add, avoiding reliance on CLI stderr text for the no-op case; distinguishes real 'already installed' wording from the stale-marketplace-cache case
  • Codex removeServer now honors the local flag (previously always targeted 'posthog', ignoring 'posthog-local')
  • Claude Code checks plugin list before installing and redacts secrets from captured error/reason text; addServer/removeServer/installPlugin return structured InstallResult with reasons
  • claude-web removeServer now returns an explanatory reason pointing at claude.ai settings instead of a bare failure
  • PluginInstallResult now aliases the shared InstallResult type instead of its own interface

Frontend

Issues: 1 issue

Files (6)
  • src/ui/tui/screens/McpScreen.tsx
  • src/ui/tui/services/mcp-installer.ts
  • src/ui/tui/playground/demos/McpDemo.tsx
  • src/commands/mcp/add.ts
  • src/commands/mcp/remove.ts
  • src/commands/mcp/tui-availability.ts
What were the main changes
  • McpScreen Done phase now renders separate result groups (installed / already-installed / failed, with reasons) instead of a bare 'Installation skipped.' message
  • Detection and install/remove crashes now surface the actual error text and point at --debug, instead of reading as 'no clients detected' or 'no editor selected'
  • Already-installed now counts as a successful outcome so the flow continues to Slack/prompt steps instead of stopping early
  • Fixed installMode closure bug: doInstall now takes the chosen mode explicitly instead of reading stale state, which previously sent single-editor machines down the MCP-only path after choosing 'install with all features'
  • mcp-installer service now returns McpClientResult[] from install/remove/installPlugins, forwards the local flag on remove (previously dropped), and redacts secrets before logging
  • Extracted isTUIUnavailable into a shared tui-availability.ts module; mcp remove now uses the same guard as mcp add instead of falling back to LoggingUI on any TUI error
  • McpDemo playground updated to produce mixed installed/already-installed/failed results for manual testing

Comment on lines 232 to +239
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('already installed') || msg.includes('already exists')) {
return Promise.resolve({ success: true, alreadyInstalled: true });
return { success: true, alreadyInstalled: true };
}
analytics.captureException(
new Error(`Claude Code plugin install failed: ${msg}`),
);
return Promise.resolve({ success: false });
return { success: false, reason: msg };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude plugin failures bypass secret redaction

consider security

Why we think it's a valid issue
  • Checked: installPlugin catch block vs. its siblings at PR head cbf31a7, plus how the returned reason is consumed (installPluginstoClientResultsummarizeFailure) and what redactSecrets masks.
  • Found: The inconsistency is real — installPlugin uses raw msg at claude-code.ts:232 for both captureException (236-238) and return { success:false, reason: msg } (239), while addServer (150-161) and removeServer (180-191) both wrap error text in redactSecrets.
  • Found: The reviewer's UI claim is factually wrong. The returned reason is redacted downstream: toClientResult sets detail: summarizeFailure(result?.reason) (results.ts:66) and summarizeFailure calls redactSecrets (results.ts:49-50) before the text ever reaches a screen. The only genuinely-unredacted sink is analytics.captureException.
  • Found: No concrete secret-leak trigger exists on this path. installPlugin() takes no args and runs claude plugin install posthog — no apiKey, no Authorization: Bearer, no phx_/phc_ key on the command line. This is unlike addServer, whose command embeds --header "Authorization: Bearer <apiKey>" (a real, nameable leak the redaction there fixes). For a secret to enter this error text the Claude CLI would have to spontaneously echo stored credentials on a plugin-install failure — speculative, not a realistic failure mode.
  • Impact: Because removeServer's command (claude mcp remove --scope user posthog) also carries no command-line secret yet is still redacted, the author clearly intends to redact all claude-CLI error output defensively; installPlugin is a genuine gap in that pattern and the one-line fix is trivial and zero-risk. But the practical security impact is negligible — the UI sink is already masked and no secret reaches the exception sink — so it does not meet the bar for a reachable secret leak.
  • Priority: must_fix is overstated (no reachable secret exposure, and the UI-exposure half of the claim is incorrect). Down-ranked to consider: a legitimate defense-in-depth consistency fix that matches the author's own redaction pattern, but not a merge-blocking security bug.
Issue description

The plugin-install failure path uses the raw child-process error for analytics and the returned reason. Unlike the MCP add/remove paths, this can expose sensitive CLI output to exception reporting and the UI.

Suggested fix

Pass the error through redactSecrets before checking it, capturing it, or returning it: const msg = redactSecrets(error instanceof Error ? error.message : String(error));.

Prompt to fix with AI (copy-paste)
## Context
@src/steps/add-mcp-server-to-clients/clients/claude-code.ts#L232-239

<issue_description>
The plugin-install failure path uses the raw child-process error for analytics and the returned `reason`. Unlike the MCP add/remove paths, this can expose sensitive CLI output to exception reporting and the UI.
</issue_description>

<issue_validation>
- **Checked:** `installPlugin` catch block vs. its siblings at PR head `cbf31a7`, plus how the returned `reason` is consumed (`installPlugins` → `toClientResult` → `summarizeFailure`) and what `redactSecrets` masks.
- **Found:** The inconsistency is real — `installPlugin` uses raw `msg` at claude-code.ts:232 for both `captureException` (236-238) and `return { success:false, reason: msg }` (239), while `addServer` (150-161) and `removeServer` (180-191) both wrap error text in `redactSecrets`.
- **Found:** The reviewer's *UI* claim is factually wrong. The returned `reason` is redacted downstream: `toClientResult` sets `detail: summarizeFailure(result?.reason)` (results.ts:66) and `summarizeFailure` calls `redactSecrets` (results.ts:49-50) before the text ever reaches a screen. The only genuinely-unredacted sink is `analytics.captureException`.
- **Found:** No concrete secret-leak trigger exists on this path. `installPlugin()` takes no args and runs `claude plugin install posthog` — no apiKey, no `Authorization: Bearer`, no `phx_`/`phc_` key on the command line. This is unlike `addServer`, whose command embeds `--header "Authorization: Bearer <apiKey>"` (a real, nameable leak the redaction there fixes). For a secret to enter this error text the Claude CLI would have to spontaneously echo stored credentials on a plugin-install failure — speculative, not a realistic failure mode.
- **Impact:** Because `removeServer`'s command (`claude mcp remove --scope user posthog`) also carries no command-line secret yet is still redacted, the author clearly intends to redact *all* claude-CLI error output defensively; `installPlugin` is a genuine gap in that pattern and the one-line fix is trivial and zero-risk. But the practical security impact is negligible — the UI sink is already masked and no secret reaches the exception sink — so it does not meet the bar for a reachable secret leak.
- **Priority:** `must_fix` is overstated (no reachable secret exposure, and the UI-exposure half of the claim is incorrect). Down-ranked to `consider`: a legitimate defense-in-depth consistency fix that matches the author's own redaction pattern, but not a merge-blocking security bug.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Pass the error through `redactSecrets` before checking it, capturing it, or returning it: `const msg = redactSecrets(error instanceof Error ? error.message : String(error));`.
</potential_solution>

Comment on lines +166 to +168
// `mcp add` looked like a failure and reported nothing at all.
if (await this.isPluginInstalled()) {
return { success: true, alreadyInstalled: true };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented TOML can falsely mark the Codex plugin as installed

consider bug

Why we think it's a valid issue
  • Checked: isPluginInstalled (codex.ts:143-154), the pre-install gate that consumes it (codex.ts:167-168), and the install's downstream self-correction path (codex.ts:198-210).
  • Found: The check is a raw substring match — contents.toLowerCase().includes('[marketplaces.posthog]') (codex.ts:149) — so a commented line like # [marketplaces.posthog] matches, and the gate returns { success: true, alreadyInstalled: true } at codex.ts:167-168 without running the install.
  • Found: The false-positive direction is uncorrected. The ALREADY_INSTALLED_PATTERN stderr check (codex.ts:205-210) only self-corrects the reverse miss (pre-check false-negative → install still runs → stderr caught). A false positive skips the plugin marketplace add entirely, so nothing downstream fixes it.
  • Impact: When [marketplaces.posthog] exists only in commented/inactive form, the wizard reports 'Plugin already installed for: Codex' while Codex has no active marketplace entry — a silently broken install reported as success. This is a genuine functional defect (unlike a benign rewrite), which is why it clears the bar.
  • Impact (why not higher): Both normal states behave correctly — never-installed configs lack the string, actively-installed configs contain the live table. The false positive needs a commented-only header (hand-disabled entry or a #-prefixed example), an uncommon state; and substring detection is the accepted pattern across these CLI clients (e.g. claude-code's mcp list .includes(serverName)), so this isn't uniquely fragile.
  • Priority: Downgraded to consider — the harmful outcome is real but the commented-only-header trigger is low-frequency, so should_fix overstates how often it bites; the reviewer's minimal fix (ignore comment lines / anchor the header) is proportionate and worth keeping on record.
Issue description

The new pre-install check relies on isPluginInstalled(), which performs a raw substring search for [marketplaces.posthog]. A commented-out example or stale commented configuration containing that header therefore makes installPlugin skip installation and report alreadyInstalled even though Codex has no active marketplace entry.

Suggested fix

Parse config.toml with a TOML parser and check the active marketplaces.posthog table. At minimum, inspect only non-comment lines and require an exact table-header match.

Prompt to fix with AI (copy-paste)
## Context
@src/steps/add-mcp-server-to-clients/clients/codex.ts#L166-168

<issue_description>
The new pre-install check relies on isPluginInstalled(), which performs a raw substring search for `[marketplaces.posthog]`. A commented-out example or stale commented configuration containing that header therefore makes installPlugin skip installation and report alreadyInstalled even though Codex has no active marketplace entry.
</issue_description>

<issue_validation>
- **Checked:** `isPluginInstalled` (codex.ts:143-154), the pre-install gate that consumes it (codex.ts:167-168), and the install's downstream self-correction path (codex.ts:198-210).
- **Found:** The check is a raw substring match — `contents.toLowerCase().includes('[marketplaces.posthog]')` (codex.ts:149) — so a commented line like `# [marketplaces.posthog]` matches, and the gate returns `{ success: true, alreadyInstalled: true }` at codex.ts:167-168 without running the install.
- **Found:** The false-positive direction is uncorrected. The `ALREADY_INSTALLED_PATTERN` stderr check (codex.ts:205-210) only self-corrects the *reverse* miss (pre-check false-negative → install still runs → stderr caught). A false positive skips the `plugin marketplace add` entirely, so nothing downstream fixes it.
- **Impact:** When `[marketplaces.posthog]` exists only in commented/inactive form, the wizard reports 'Plugin already installed for: Codex' while Codex has no active marketplace entry — a silently broken install reported as success. This is a genuine functional defect (unlike a benign rewrite), which is why it clears the bar.
- **Impact (why not higher):** Both normal states behave correctly — never-installed configs lack the string, actively-installed configs contain the live table. The false positive needs a commented-only header (hand-disabled entry or a `#`-prefixed example), an uncommon state; and substring detection is the accepted pattern across these CLI clients (e.g. claude-code's `mcp list` `.includes(serverName)`), so this isn't uniquely fragile.
- **Priority:** Downgraded to `consider` — the harmful outcome is real but the commented-only-header trigger is low-frequency, so `should_fix` overstates how often it bites; the reviewer's minimal fix (ignore comment lines / anchor the header) is proportionate and worth keeping on record.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Parse config.toml with a TOML parser and check the active `marketplaces.posthog` table. At minimum, inspect only non-comment lines and require an exact table-header match.
</potential_solution>

Comment on lines +104 to +106
const reason = redactSecrets(stderr);
analytics.captureException(new Error(`Codex MCP add failed: ${reason}`));
return Promise.resolve({ success: false, reason });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex spawn failures produce empty failure reasons

should_fix bug

Why we think it's a valid issue
  • Checked: The addServer (codex.ts:99-106) and installPlugin (codex.ts:198-215) failure branches vs. the sibling removeServer (codex.ts:126-133), plus spawnSync launch-failure semantics and findCodexBinary (codex.ts:41-55).
  • Found: Both flagged paths gate on if (result.status !== 0) and derive the reason solely from stderr: const stderr = result.stderr ?? ''redactSecrets(stderr). On a launch failure spawnSync returns { error, status: null, stderr: null }, so the guard still fires (null !== 0), but stderr ?? '' is '', ALREADY_INSTALLED_PATTERN.test('') is false, and the reason is empty — the real diagnostic in result.error is dropped from both the returned reason and analytics.captureException(new Error('Codex ... failed: ')).
  • Found: removeServer already does it right at codex.ts:126-128 — result.error || result.status !== 0 and redactSecrets(result.error?.message ?? result.stderr ?? 'codex mcp remove failed') — so this is a gap in the author's own pattern across the same file, not reviewer paranoia.
  • Impact: On spawn launch failures (EACCES on a non-executable resolved path, ENOENT on a dangling symlink, or the cached findCodexBinary path going stale between detection and install — a real TOCTOU window since the path is resolved once and reused), the user sees 'Couldn't install for: Codex — ' with no reason and telemetry gets an empty diagnostic. That is a swallowed error that hides a failure and directly undermines the PR's central goal of meaningful failure reasons.
  • Impact (scope): It only bites launch failures, not normal non-zero exits (where stderr is populated), so it is a subset of failures — but that is precisely the case where the diagnostic matters most and is lost.
  • Priority: Left at should_fix — real reliability/observability defect, trivial proportionate fix (mirror removeServer), the author already established the pattern in-file; not a crash/data-loss so not must_fix.
Issue description

The add and plugin-install failure paths derive the reason only from stderr. When spawnSync fails to launch the cached binary, status is null and the diagnostic is in result.error, leaving users and exception telemetry with an empty reason. The remove path already handles this correctly.

Suggested fix

Build the reason consistently as result.error?.message ?? result.stderr ?? '<operation> failed', then redact it before returning or reporting it. Consider a shared helper for all Codex subprocess results.

Prompt to fix with AI (copy-paste)
## Context
@src/steps/add-mcp-server-to-clients/clients/codex.ts#L104-106
@src/steps/add-mcp-server-to-clients/clients/codex.ts#L199-215

<issue_description>
The add and plugin-install failure paths derive the reason only from `stderr`. When `spawnSync` fails to launch the cached binary, `status` is null and the diagnostic is in `result.error`, leaving users and exception telemetry with an empty reason. The remove path already handles this correctly.
</issue_description>

<issue_validation>
- **Checked:** The `addServer` (codex.ts:99-106) and `installPlugin` (codex.ts:198-215) failure branches vs. the sibling `removeServer` (codex.ts:126-133), plus `spawnSync` launch-failure semantics and `findCodexBinary` (codex.ts:41-55).
- **Found:** Both flagged paths gate on `if (result.status !== 0)` and derive the reason solely from stderr: `const stderr = result.stderr ?? ''` → `redactSecrets(stderr)`. On a launch failure `spawnSync` returns `{ error, status: null, stderr: null }`, so the guard still fires (`null !== 0`), but `stderr ?? ''` is `''`, `ALREADY_INSTALLED_PATTERN.test('')` is false, and the reason is empty — the real diagnostic in `result.error` is dropped from both the returned `reason` and `analytics.captureException(new Error('Codex ... failed: '))`.
- **Found:** `removeServer` already does it right at codex.ts:126-128 — `result.error || result.status !== 0` and `redactSecrets(result.error?.message ?? result.stderr ?? 'codex mcp remove failed')` — so this is a gap in the author's own pattern across the same file, not reviewer paranoia.
- **Impact:** On spawn launch failures (EACCES on a non-executable resolved path, ENOENT on a dangling symlink, or the cached `findCodexBinary` path going stale between detection and install — a real TOCTOU window since the path is resolved once and reused), the user sees 'Couldn't install for: Codex — ' with no reason and telemetry gets an empty diagnostic. That is a swallowed error that hides a failure and directly undermines the PR's central goal of meaningful failure reasons.
- **Impact (scope):** It only bites launch failures, not normal non-zero exits (where stderr is populated), so it is a subset of failures — but that is precisely the case where the diagnostic matters most and is lost.
- **Priority:** Left at `should_fix` — real reliability/observability defect, trivial proportionate fix (mirror `removeServer`), the author already established the pattern in-file; not a crash/data-loss so not must_fix.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Build the reason consistently as `result.error?.message ?? result.stderr ?? '<operation> failed'`, then redact it before returning or reporting it. Consider a shared helper for all Codex subprocess results.
</potential_solution>

Comment on lines +155 to +164
const already = namesWithStatus(results, McpClientStatus.Unchanged);
analytics.wizardCapture('mcp plugins installed', {
clients: installed,
// `clients` keeps its original meaning — every client that ended up with
// the plugin — so existing insights don't dip when a re-run reports
// already-installed instead of a fresh write.
clients: [
...namesWithStatus(results, McpClientStatus.Changed),
...already,
],
already_installed: already,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin failure analytics omit the new failed breakdown

consider best_practice

Why we think it's a valid issue
  • Checked: All three MCP analytics events added/modified in this PR, plus whether plugin installs can actually yield a Failed status and whether the failed set is otherwise recoverable.
  • Found: The two sibling events both carry an explicit failed breakdown — mcp servers added has failed_clients (src/steps/add-mcp-server-to-clients/index.ts:130) and mcp servers removed has failed_clients (index.ts:190) — but mcp plugins installed (src/ui/tui/services/mcp-installer.ts:156-166) emits only clients, already_installed, attempted, with no failed. The PR body's own stated contract lists already_installed / failed / attempted as the new breakdowns, so this is a genuine omission in changed code, not a misread.
  • Found: The omission is meaningful — installPlugins (index.ts:246-265) catches each client's error and pushes a Failed-status result, so the results array feeding this event really can contain failures (e.g. codex.ts:213, claude-code.ts:237 plugin-install failures).
  • Impact: Real but low-impact observability/parity gap: the team can't read plugin-install failure rate directly off this event the way it can for MCP add/remove. However the data isn't lost — attempted contains only genuinely-attempted (supported) plugin clients and clients = Changed ∪ Unchanged, so failed is derivable as attempted \ clients; failures are also separately captured via analytics.captureException, and surfaced to the user in the Done screen. The finding's claim that this 'prevents distinguishing genuine failures from unsupported or skipped clients' is overstated, since unsupported/skipped clients never enter attempted.
  • Priority: Downgrade to consider — a genuine, cheap, in-scope inconsistency the author likely intended to include, but telemetry-only with a derivable/duplicated fallback and no user-facing effect, which is below the should_fix bar.
Issue description

The updated analytics event reports successful and already-installed clients plus all attempts, but omits clients with McpClientStatus.Failed. This contradicts the PR's stated analytics contract and prevents distinguishing genuine failures from unsupported or skipped clients.

Suggested fix

Add failed: namesWithStatus(results, McpClientStatus.Failed) to the event properties and cover it with an analytics test containing mixed results.

Prompt to fix with AI (copy-paste)
## Context
@src/ui/tui/services/mcp-installer.ts#L155-164

<issue_description>
The updated analytics event reports successful and already-installed clients plus all attempts, but omits clients with McpClientStatus.Failed. This contradicts the PR's stated analytics contract and prevents distinguishing genuine failures from unsupported or skipped clients.
</issue_description>

<issue_validation>
- **Checked:** All three MCP analytics events added/modified in this PR, plus whether plugin installs can actually yield a `Failed` status and whether the failed set is otherwise recoverable.
- **Found:** The two sibling events both carry an explicit failed breakdown — `mcp servers added` has `failed_clients` (`src/steps/add-mcp-server-to-clients/index.ts:130`) and `mcp servers removed` has `failed_clients` (`index.ts:190`) — but `mcp plugins installed` (`src/ui/tui/services/mcp-installer.ts:156-166`) emits only `clients`, `already_installed`, `attempted`, with no `failed`. The PR body's own stated contract lists `already_installed` / `failed` / `attempted` as the new breakdowns, so this is a genuine omission in changed code, not a misread.
- **Found:** The omission is meaningful — `installPlugins` (`index.ts:246-265`) catches each client's error and pushes a `Failed`-status result, so the `results` array feeding this event really can contain failures (e.g. `codex.ts:213`, `claude-code.ts:237` plugin-install failures).
- **Impact:** Real but low-impact observability/parity gap: the team can't read plugin-install failure rate directly off this event the way it can for MCP add/remove. However the data isn't lost — `attempted` contains only genuinely-attempted (supported) plugin clients and `clients` = Changed ∪ Unchanged, so `failed` is derivable as `attempted \ clients`; failures are also separately captured via `analytics.captureException`, and surfaced to the user in the Done screen. The finding's claim that this 'prevents distinguishing genuine failures from unsupported or skipped clients' is overstated, since unsupported/skipped clients never enter `attempted`.
- **Priority:** Downgrade to `consider` — a genuine, cheap, in-scope inconsistency the author likely intended to include, but telemetry-only with a derivable/duplicated fallback and no user-facing effect, which is below the should_fix bar.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Add `failed: namesWithStatus(results, McpClientStatus.Failed)` to the event properties and cover it with an analytics test containing mixed results.
</potential_solution>

edwinyjlim and others added 2 commits August 11, 2026 14:38
…roups

Reviewer feedback on #1075: the Done screen auto-dismissed after 2s and
whipped past too fast to read when several result groups were stacked. Replace
the timeout with an explicit "Press enter to continue" prompt so the user
controls when the flow moves on, and add a blank line between result groups
so items don't run together. Same treatment for the long detect-error copy,
which had a 3s timeout for the same reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@edwinyjlim
edwinyjlim merged commit 4b7982a into main Aug 11, 2026
16 checks passed
@edwinyjlim
edwinyjlim deleted the posthog-code/mcp-add-already-installed-copy branch August 11, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants