Skip to content

fix(tui,mcp): show MCP servers that failed to start in /mcp, bound to their server and with credentials redacted - #835

Open
Vasanthdev2004 wants to merge 35 commits into
mainfrom
fix/825-mcp-panel-shows-failures
Open

Vasanthdev2004 wants to merge 35 commits into
mainfrom
fix/825-mcp-panel-shows-failures

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #825. Companion to #822, which fixed the same blind spot in zero mcp check.

/mcp worked out each server's state from the config file: disabled if you turned it off, enabled otherwise. But MCP registration is best-effort: a server that can't be reached gets recorded and startup carries on. So a server that never connected showed up as enabled, its tools quietly missing, and nothing in the panel said why.

Startup does know: it prints a warning per skipped server to stderr. That's gone by the time you notice, and /mcp is exactly where you go afterwards to ask what's actually running.

So the skipped set now reaches the panel, and a server that failed renders as failed with the reason under it:

› docs · failed · stdio
  exec: "docs-mcp": executable file not found in $PATH

Two details worth calling out:

The reason comes from the server, so it goes through redaction.ErrorMessage before it's rendered. A handshake error that echoes the Authorization header back would otherwise print the bearer token straight into the transcript. There's a test for that.

Disabled wins over failed. If you turned a server off it was never expected to connect, and calling it failed would be misleading.

The stderr warning is unchanged. Non-interactive users still get it, and the panel is an addition rather than a replacement.

Still not fixed, and out of scope here: enabling a server from inside the TUI updates the config but doesn't reconnect anything, so it'll show as enabled while not actually running until you restart. That's pre-existing and a bigger change; happy to file it separately if you'd like.

Verified with mutation testing: eight mutations across the state builder, the renderer, and both wiring points, all killed. TestAltScreenTranscriptScrollKeepsFooterFixed and TestBuildServeScopeKeepsLexicalPaths fail on my Windows box on clean main too (the second needs symlink privilege).

What else this grew into

Review turned up more than the panel, and those fixes are all here too, so the original title undersold it (thanks @euxaristia). From the most code to the least:

  • The failure reason is treated as credential material. It comes from the server, so everything the server's config could have put into it is redacted before it renders: header and endpoint credentials, environment values, URL-valued stdio arguments, and stored OAuth tokens. The tokens are read through a new read-only TokenStore.SecretValues. Each is redacted in its plain, escaped and split forms, and the text has a fixed size bound. internal/redaction/overlapping_secrets_test.go and the MCP tests pin the forms.
  • A failure belongs to one server, and to the credentials it was seen with. It is recorded against the server's identity and a CredentialFingerprint of its credential values. So a renamed or colliding alias doesn't show another server's failure, and neither does a server whose credentials changed since startup.
  • Server names are unique across the whole config before startup splits servers into enabled and skipped, and the write-collision rule matches the disabled-server policy.
  • The manager's rows carry an identity that actions can address, and an MCP manager that is already open is told when background startup finishes.

Summary by CodeRabbit

  • New Features

    • MCP servers that fail to start now appear as failed in the /mcp panel.
    • Failure reasons are shown in the panel and server details when available.
    • Startup failures remain visible while the affected configuration is unchanged.
  • Bug Fixes

    • MCP status now reflects actual startup results, including disabled servers.
    • Failure messages and server targets redact credentials, remove unsafe formatting, and limit excessively long text.
    • Server names and status matching remain consistent across configuration updates.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

MCP startup failures now flow from the CLI runtime into TUI state. The /mcp panel marks skipped servers as failed, displays sanitized reasons, and preserves stderr warnings.

Changes

MCP failure visibility

Layer / File(s) Summary
Runtime failure handoff
internal/cli/app.go, internal/cli/app_mcp_skipped_test.go, internal/tui/options.go, internal/tui/model.go
The CLI passes mcpRuntime.Skipped() through tui.Options. The TUI stores it in model state. Tests verify propagation and stderr reporting.
MCP failure state, retention, and redaction
internal/tui/mcp_state.go, internal/tui/mcp_skipped_invalidation.go, internal/tui/command_views.go, internal/tui/mcp_add_wizard.go, internal/mcp/oauth_store.go
Skipped servers become failed. Disabled servers retain precedence. Skipped state remains only for unchanged configurations. Configured credentials and stored OAuth tokens are redacted.
MCP failure rendering and sanitization
internal/tui/mcp_view.go, internal/tui/mcp_manager.go, internal/tui/mcp_failed_state_test.go
Failure reasons are sanitized, bounded, and UTF-8 safe. The MCP panel and server detail view render the reasons.
Redaction regression coverage
internal/redaction/overlapping_secrets_test.go, internal/mcp/oauth_secret_values_test.go, internal/tui/mcp_failure_redaction_test.go, internal/tui/mcp_redaction_ignorable_test.go, internal/tui/mcp_header_flag_redaction_test.go, internal/tui/mcp_raw_bound_test.go, internal/tui/mcp_target_redaction_test.go, internal/tui/mcp_url_credential_test.go, internal/tui/mcp_candidate_bound_test.go, internal/tui/mcp_oversized_secret_test.go, internal/tui/mcp_path_credential_test.go, internal/tui/mcp_state_entrypoint_test.go
Tests cover overlapping secrets, sensitive arguments, OAuth tokens, invisible Unicode separators, header flags, URL credentials, raw bounds, path credentials, and preserved diagnostics.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPRuntime
  participant CLI
  participant TUIModel
  participant MCPState
  participant MCPView
  MCPRuntime->>CLI: Return skipped server failures
  CLI->>TUIModel: Pass MCPSkipped through tui.Options
  TUIModel->>MCPState: Build failed server state
  MCPState->>MCPView: Provide redacted failure reason
  MCPView->>MCPView: Sanitize and bound rendered output
Loading

Possibly related PRs

  • Gitlawb/zero#188: Both changes modify MCP TUI state propagation and failure rendering across the CLI and TUI.

Suggested reviewers: anandh8x, kevincodex1

Merge Risk: High · up to dc886

The change can display an MCP credential in the /mcp panel and hide the next argument instead, potentially persisting a secret in the transcript. This security issue makes the PR unsafe to merge until packed sensitive arguments are redacted correctly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR passes Runtime.Skipped() into the TUI and renders failed MCP servers with recorded, sanitized errors as required by issue #825.
Out of Scope Changes check ✅ Passed The changes support the linked issue by adding failure-state handling, redaction, sanitization, bounds, canonicalization, and regression tests.
Docstring Coverage ✅ Passed Docstring coverage is 81.37% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 102 functions across 27 files.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: it shows MCP servers that failed to start in /mcp and identifies the server-scoped, credential-redacted failure display.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/825-mcp-panel-shows-failures
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/cli/app_mcp_skipped_test.go (1)

63-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the failure reason is forwarded.

The test only verifies the server name. Also assert MCPSkipped[0].Err contains "connection refused" so a regression that drops the recorded error cannot pass.

Proposed test strengthening
 if len(launchedOptions.MCPSkipped) != 1 ||
-	launchedOptions.MCPSkipped[0].Name != "docs" {
+	launchedOptions.MCPSkipped[0].Name != "docs" ||
+	launchedOptions.MCPSkipped[0].Err == nil ||
+	launchedOptions.MCPSkipped[0].Err.Error() != "connection refused" {
 	t.Fatalf("MCPSkipped = %#v, want the failure startup recorded", launchedOptions.MCPSkipped)
 }

As per coding guidelines, add a regression test for behavior changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/app_mcp_skipped_test.go` around lines 63 - 66, Strengthen the
existing MCPSkipped assertion in the test by also verifying that
MCPSkipped[0].Err contains “connection refused,” while preserving the current
server-name check and failure-count validation.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/cli/app_mcp_skipped_test.go`:
- Around line 63-66: Strengthen the existing MCPSkipped assertion in the test by
also verifying that MCPSkipped[0].Err contains “connection refused,” while
preserving the current server-name check and failure-count validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6847c24c-e809-446d-80a2-a6db7325507d

📥 Commits

Reviewing files that changed from the base of the PR and between 097c265 and be58076.

📒 Files selected for processing (8)
  • internal/cli/app.go
  • internal/cli/app_mcp_skipped_test.go
  • internal/tui/command_views.go
  • internal/tui/mcp_failed_state_test.go
  • internal/tui/mcp_state.go
  • internal/tui/mcp_view.go
  • internal/tui/model.go
  • internal/tui/options.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 2c36c67ac8be
Changed files (52): internal/cli/app.go, internal/cli/app_mcp_skipped_test.go, internal/cli/mcp_config.go, internal/cli/mcp_identity_operations_test.go, internal/cli/mcp_server_identity_test.go, internal/cli/mcp_startup.go, internal/cli/mcp_write_collision_test.go, internal/mcp/config.go, internal/mcp/credential_fingerprint.go, internal/mcp/network_client.go, internal/mcp/oauth_secret_values_test.go, internal/mcp/oauth_store.go, and 40 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

kevincodex1
kevincodex1 previously approved these changes Jul 30, 2026

@anandh8x anandh8x 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.

The failed-state wiring and secret redaction look good, but the new failure-reason rendering needs terminal sanitization before merge.

BuildMCPViewState passes redaction.ErrorMessage(err, ...) into MCPServerView.Error, and mcpManagerServerLines inserts that value directly into the rendered lines. Redaction removes credentials but does not remove ANSI/OSC sequences, other control characters, or embedded newlines. I reproduced this with an MCP error containing connection refused\x1b[2J\n› forged · enabled; the resulting server line retained both the escape sequence and newline unchanged. A server-controlled handshake error can therefore manipulate the terminal or forge extra /mcp rows.

Please normalize the displayed reason to safe single-line terminal text: strip ANSI/OSC and control characters, flatten CR/LF, apply a reasonable length cap, and add a regression covering escape and newline injection.

Everything else in the change looks correct, and the focused tests and CI are green.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x fixed in c7421d9. You were right, and the reproduction was exact.

Before the fix the panel rendered your payload as:

› evil · failed · http
  connection refused\x1b[2J
› forged · enabled
  actions: zero mcp check evil | ...

Escape sequence intact, forged row on its own line.

sanitizeTerminalReason now consumes escape sequences whole rather than dropping ESC alone, since stripping just the ESC leaves "[2J" printing as visible junk and an abandoned OSC payload can still smuggle a title-set or hyperlink. CSI runs to its final byte, OSC to BEL or ST. Newlines and tabs collapse to spaces so the reason stays on the one row the panel counted for it, other control bytes go, and it caps at 400 runes. Truncation is by rune so a multi-byte character never gets cut in half.

Two regressions, both of which fail on the previous commit: one asserts no escape byte survives, no line carries its own newline, the forged text never starts a row, and the real reason still shows. The other pushes 5000 characters through and asserts the line stays bounded.

Re-requesting you and @kevincodex1, since the push dismissed his approval.

One thing worth flagging beyond this PR: the same class exists at sidebar.go:668, where a failed plan task's raw child error is rendered without sanitizing. I found it reviewing #829 and it is unrelated to this change, but it is the same fix.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/tui/mcp_view.go`:
- Around line 192-238: Bound MCPServerView.Error before processing in the
sanitizer around the visible rune conversion and strings.Builder accumulation.
Verify whether Runtime.Skipped() already imposes a strict size limit; if not,
limit the raw input before converting to []rune and stop accumulating once the
maxMCPReasonLen display budget is reached, while preserving ANSI stripping,
whitespace normalization, and truncation behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 63dc33b5-a906-45b6-b233-5fc22f09514c

📥 Commits

Reviewing files that changed from the base of the PR and between be58076 and c7421d9.

📒 Files selected for processing (2)
  • internal/tui/mcp_failed_state_test.go
  • internal/tui/mcp_view.go

Comment thread internal/tui/mcp_view.go
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Pushed one more commit for the bot's finding, which was real. The 400 rune cap ran at the end, so the sanitizer walked the whole server string first, and escape sequences get consumed without producing output. 64KB of \x1b[2J was walked in full and the text after it still rendered. Nothing upstream bounds the handshake error and the panel re-runs this every redraw, so the input is now capped at 16KB before the walk, with a trim so a split character never shows up as a replacement glyph.

The bot's other claim on #866 (duplicate alwaysPromptingTool declaration) is wrong, there is only one.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 5, 2026
gnanam1990
gnanam1990 previously approved these changes Aug 5, 2026

@gnanam1990 gnanam1990 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.

Verdict: Approve

Verified empirically on the branch (checked out, built).

What I checked

  • Gut-the-fix: forcing the failure branch off (mcp_state.go:71) so a skipped server renders as "enabled" turns the TUI MCP tests red. The tests exercise the behavior.
  • Precedence is right and tested: a server that is both disabled and recorded-failed shows as "disabled", not "failed" (mcp_state.go:66-69), and this is asserted directly (mcp_failed_state_test.go:45 — "disabled to win over a recorded failure"). Correct — a server you turned off was never expected to connect.
  • Reason is redacted before display (redaction.ErrorMessage, mcp_state.go:73) — invariant 6, so a path/credential in a startup error can't leak into the panel. Empty-error fallback ("server did not start") is handled too.
  • Skipped set reaches the panel via MCPSkipped: mcpRuntime.Skipped(); companion to #822 which did the same for zero mcp check.
  • Clean scope — every file is the MCP panel or its plumbing.

Worth a quick confirm (non-blocking)

  • On reconnect (zero mcp enable after a failure), the panel is rebuilt from a fresh Skipped set, so it should flip back to enabled — worth a sanity check that the runtime clears the entry on a successful re-register.

Good fix.

@jatmn jatmn 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.

I found issues that need to be addressed before this is ready.

Merge and review state

  • Review decision: GitHub still reports CHANGES_REQUESTED (anandh8x’s review on be580766 is not dismissed). Terminal sanitization and raw-input bounding from that review look addressed on the current head, but the merge gate still needs a fresh approval on 6521c367. gnanam1990 approved on 6521c367; CodeRabbit approved on the same head.
  • Mergeability: MERGEABLE, no conflict markers in the PR diff. mergeStateStatus is BLOCKED pending required reviews.
  • Checks: All CI / CodeQL / Zero Review / CodeRabbit checks passed on head.

Prior review alignment

gnanam1990’s approval is valid for what they checked: skipped servers map to failed, disabled wins over recorded failure, reasons are redacted at build (redaction.ErrorMessage), MCPSkipped reaches the TUI, and the focused tests go red when the failure branch is gutted. Those checks exercise buildMCPServerViews, renderMCPView, and m.mcpText() — not the bare /mcp manager overlay entry point. Their non-blocking note about enable clearing Skipped on reconnect is separate from this finding; live reconnect from the TUI is out of scope per the author, and mcpSkipped remains a startup snapshot.

That approval does not negate the remaining gap below: state and redaction work on the transcript/renderMCPView path, but the overlay users get from bare /mcp still omits the reason line.

Findings

  • [P2] Bare /mcp shows failed in the manager overlay but not the failure reason
    internal/tui/model.go (commandMCP → openMCPManager), internal/tui/mcp_manager.go (mcpManagerOverlay, mcpManagerServerMeta, mcpManagerSelectionDetail), internal/tui/mcp_view.go (mcpManagerServerLines, renderMCPView)
    Empty /mcp has long routed to openMCPManager() — this PR did not change that. What it did change is meaningful: buildMCPServerViews now marks skipped servers as failed, so the overlay meta and detail pane correctly say failed instead of the pre-PR enabled with missing tools. The recorded reason, however, is rendered only in mcpManagerServerLines inside renderMCPView(). The overlay never reads server.Error or calls sanitizeTerminalReason, so a user who types /mcp after a startup warning sees the right state but not the “why” issue #825 and this PR’s description target. The renderMCPView path does show the sanitized reason — on /mcp list and other transcript subcommands, and in transcript output appended after manager actions such as check or list — but the primary overlay surface is still incomplete relative to the stated fix. TestModelMCPPanelReportsStartupFailures exercises m.mcpText() / renderMCPView(), not the bare /mcp entry point. Please surface the sanitized reason in the manager overlay (list meta, selection detail, or both), reusing the same sanitizeTerminalReason path mcpManagerServerLines already uses.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 9, 2026
Vasanthdev2004 added a commit that referenced this pull request Aug 9, 2026
An empty /mcp opens the manager overlay, and it reported the state without the
reason. The recorded "why" was rendered only by mcpManagerServerLines inside
renderMCPView, which serves /mcp list and the transcript, so the panel said
"failed" and stopped exactly where someone goes to find out why after the
startup warning has scrolled away.

The reason now sits in the selection detail, directly under the header and above
the target, through the same sanitizeTerminalReason path the transcript uses.

TestModelMCPPanelReportsStartupFailures drives m.mcpText() and passes with or
without this, which is how the gap survived review. The new tests drive
openMCPManager().mcpManagerOverlay() instead. Mutation-verified: feeding the
sanitizer an empty reason fails the first one.

The sanitization test deliberately does not search for a bare escape byte. The
overlay is lipgloss-styled and therefore full of escape sequences it wrote
itself, so the assertion is that the SERVER's payload did not survive: no
clear-screen sequence, and no row carrying the forged text on its own. The
sanitizer collapses the newline, so the forged text stays inert on the reason
line rather than becoming an entry of its own.

Reported by jatmn on #835.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and fixed the overlay gap. Thanks @jatmn, that was exactly right and the reason it survived review is worth stating.

The finding. Bare /mcp opens the manager overlay, and the reason was rendered only by mcpManagerServerLines inside renderMCPView, which serves /mcp list and the transcript. So the overlay said failed and stopped, on the one surface a user reaches after the startup warning has scrolled away. The sanitized reason now sits in the selection detail, directly under the header and above the target, through the same sanitizeTerminalReason path.

Why it got through. TestModelMCPPanelReportsStartupFailures drives m.mcpText(), and it passes with or without the fix. The new tests drive openMCPManager().mcpManagerOverlay(). Mutation-verified: feeding the sanitizer an empty reason fails TestBareMCPOverlayShowsTheFailureReason.

One thing I got wrong while writing the sanitization test, worth recording. My first version asserted the overlay contained no \x1b. It failed immediately, and not because of the payload: the overlay is lipgloss-styled, so it is full of escape sequences this code wrote itself. The real assertion is that the SERVER's bytes did not survive, so it now checks for the clear-screen sequence specifically, and that no row carries the forged text on its own. The sanitizer collapses the newline, so › forged · enabled stays inert on the reason line instead of becoming an entry of its own, which is the property that actually matters.

The rebase. Two conflicts against #884: Options gained PeerService beside this PR's MCPSkipped, and in app.go main had moved SessionStore to a variable. Both unions.

go build, go vet, gofmt -l clean. The internal/tui failure is TestAltScreenTranscriptScrollKeepsFooterFixed, which reproduces on a clean tree here, and the internal/cli ones are the RunDoctor and BuildServeScope tests, same story. All pre-existing and local to Windows.

@anandh8x your changes-requested predates the terminal-sanitization work you asked for, which landed a while back; a re-look when you have a moment would unblock this.

@Vasanthdev2004
Vasanthdev2004 requested a review from jatmn August 9, 2026 14:07
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 9, 2026

@jatmn jatmn 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.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Redact the configured server credentials before retaining its error
    internal/tui/mcp_state.go:73
    ErrorMessage is called with empty options, even though remote MCP clients send every value in MCPServerConfig.Headers. A server can echo an arbitrary configured value (for example X-Workspace-Credential: <value>) in its failed startup response; that value is not covered by the generic redaction patterns, is saved in MCPServerView.Error, and is newly rendered in both /mcp surfaces and the session transcript. Pass the configured secret values into redaction (and cover an echoed custom header); this also avoids relying on the syntactic Authorization: matcher, which terminal control bytes can evade before the later sanitizer removes them.

gnanam1990 added a commit that referenced this pull request Sep 16, 2026
…compactions

Two blocking review findings from the draft review.

Control bytes reached the terminal. redact() scrubbed secrets but not control
characters, and the title and structural fields (name, toolCallId, role) skipped
it entirely, so an imported title or message carrying ESC or NUL forged a picker
row or corrupted a transcript line — the #835/#876 class, on strictly more
attacker-influenced input. redact() now composes a stripControl pass (C0 except
tab/newline, DEL, C1), and every rendered string — including the title at the
import chokepoint — routes through control-stripping.

The activity summary was emitted as EventCompaction, whose payload it did not
satisfy. RehydrateEvents restructures the transcript around the last
EventCompaction; a summary with no CompactableEvents/CompactedThroughSequence is
hoisted to the front of the transcript on resume. It is now an assistant
EventMessage, which still passes promptContextEvents (the resume digest) but
carries none of that replay-side contract. A payload marker keeps it
distinguishable from a translated turn, so filters and the digest can tell a
Zero-generated summary from the foreign transcript.

Also: the import-tag comment now matches ImportTag's actual output.

Tests: regression coverage for both fixes, mutation-checked (removing the
control strip surfaces the surviving byte; the summary type is asserted not to be
EventCompaction). Existing tests updated for the new summary shape via a shared
NoteEventIsSummary marker rather than the old EventCompaction type check.

Origin-Session: local-13d543 | Claude Code | 2 prompts
Origin-Snapshot: a939509c08a8
gnanam1990 pushed a commit that referenced this pull request Sep 16, 2026
redact() was stripControl(RedactString(x)). RedactString matches secrets
by SHAPE, and stripControl deletes a control byte without leaving a gap,
which makes it a reassembler as well as a sanitizer. Running it second
meant a foreign transcript could split a credential with a NUL, an ESC,
a backspace, a DEL or any C1 byte, sail past the shape patterns because
neither half looks like a key, and then have the halves rejoined on the
way out to a picker row or a transcript line.

Every recognized shape leaked that way: sk-ant-, ghp_, AKIA. The unsplit
value redacted correctly, which is why it survived review twice; every
existing test used unsplit values.

Swapping the order fixes it. The patterns now see the same text the
reader will see, which is the only text worth matching against.

This is the same defect as #835, where an MCP failure reason was redacted
before the terminal sanitizer rejoined its halves. The general rule is
worth stating where the next person will hit it: any normalizer that
removes bytes without leaving a gap has to run BEFORE whatever matches on
them.

The regression covers three key shapes against five splitters and fails
against the old order with the intact credential in the output. It also
asserts the splitter is non-empty, because a lost literal would make
every Contains check vacuously true, and keeps a newline case to pin that
separators which survive stripping are left alone rather than swept up
with the rest.

Origin-Session: local-13d543 | Claude Code | 2 prompts
Origin-Snapshot: a939509c08a8
gnanam1990 added a commit that referenced this pull request Sep 16, 2026
…oreign

title cannot repaint the picker

All four raised by CodeRabbit on ad57dd3. Two are real defects in this PR's own
feature, not test issues.

newSessionPicker gave up on an EMPTY local history:

	metas, err := m.sessionStore.ListResumable()
	if err != nil || len(metas) == 0 {
		return nil
	}

Foreign sessions are discovered independently of the store, so the person with
no Zero sessions at all — someone who just installed it and wants to carry on
work another agent started — got nil before discovery ran. The import path was
reachable only after they had already done by hand the thing it exists to save
them. A failed read is still a reason to give up; an empty one is not.

Emptiness is now decided after combining both sources, in pickerFromParts, split
out so that decision is testable without a session store on disk.

registry.go strips control bytes when a session is IMPORTED, and its comment
names this picker row as the reason (#835/#876). But the picker lists a session
BEFORE anything is imported, reading the title straight out of the other agent's
transcript — so the vector that comment describes was the one path the stripping
did not cover. An escape repaints the rows above, a carriage return hides the
rest of the label, a NUL can truncate the row.

sanitizePickerLabel drops control bytes and keeps the printable text: a title
that is merely unusual must stay readable, because the row is how the user
recognises their own work.

	t.Errorf("incomplete index entry: %+v", session)

That walks the REAL store, so a failure put the user's session titles, working
directories and file paths into the test output and into any log or pasted
report carrying it. It now names which fields are empty, which is the whole
diagnostic — the missing value is by definition not the interesting part.

NOT taken from the same comment: gating the live-store tests behind an explicit
opt-in. @Vasanthdev2004 asked for the opposite in the review this branch is
answering — a skip "would also stop it finding anything, so I would rather have
the fixture" — and the tests now only report. The data leak was the substantive
half and it is fixed.

It checked only that Discover and Read AGREE, which passes in two opposite
worlds: both correctly refusing a path reached through a symlink, and both
happily following it out of the store. Containment is now asserted directly —
"sneaky" must not be listed and must not be readable — and agreement is kept
afterwards, since that is what the original fast path broke.

Not taken, out of scope: three findings in internal/tui/model.go, which this
branch does not touch (CodeRabbit marks them "outside diff"). They look real —
particularly toolResultSessionPayload persisting displayPreview for a redacted
result — and deserve their own issue rather than a drive-by in a draft.

Mutations: restoring the len(metas) == 0 return makes the new-user test fail;
removing sanitizePickerLabel lets all four control bytes through.

Pre-existing here and unrelated: TestRunDoctorFormatsRedactedProviderDiagnostics
and TestRunDoctorConnectivityProbesProvider exit 3 in this environment.

Origin-Session: local-13d543 | Claude Code | 4 prompts
Origin-Snapshot: 8781ce78fdbe
…re path

Seven gaps from review, in two contracts.

Credential material through the failure display:

- One parser finds where a flag ends and its value begins, before anything
  inside the value is read, and the failure collector, the Target row and the
  URL collector all use it. A packed "--api-key <b64>==" or an attached
  "-HName: <b64>==" was cut at its own padding, so the collector kept "=" and
  the Target row printed the body.
- A positional endpoint is read as a URL. Cut at its first "=", the text in
  front of it named a credential, so one reader printed the userinfo and the
  other printed the whole element and masked the next one.
- The tail of a known authentication value keeps its provenance. "Bearer
  s3cr3t" lost its six-byte token to the floor meant for ambiguous values.
- The escaped userinfo password is known, like the decoded one.
- Fragment values are collected, classified the way the Target row does.
- The HTTP client reports when it cut a response body, and the display repairs
  a partial credential at a cut it did not make.

Configuration keys and runtime names:

- mcp check matches the registration outcome by the normalized runtime name, so
  a padded key no longer reports a failed server as reachable.
- mcp enable validates the prospective state with the shared active-name rule
  before writing. Disable and remove are never refused.

Regressions run the real producers (HTTP 500 through the network client, a
stdio child that echoes its argument) and check both /mcp surfaces.
A server that normalizes base64 before echoing drops the padding, so the
message carries the body while exact-value redaction only knows the padded
spelling. The whole value already had its unpadded form; the forms a known
value is taken apart into did not, and "Bearer <base64>==" is the usual shape.

Known forms get it at any length, ambiguous ones only when what is left still
passes the readability floor, and stored tokens are treated like the rest.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn all seven are fixed on 6cc1ec40. You were right about the pattern as well: each earlier fix covered one dimension and a later stage lost the fact it depended on, so this pass carries the facts through instead of adding examples.

1. Padding taken for the boundary. There is now one parser (splitMCPArgValue, in mcp_arg_parse.go) that finds where the flag ends before anything inside the value is read: whichever of the first = and the first whitespace comes earlier, with the header spellings recognised by prefix before that. The failure collector, the Target row and the URL collector all read that one result. Both of your examples keep the whole credential, Target prints --api-key [REDACTED], and --verbose and the header name stay readable. The test echoes the full value and the bare body.

Writing that regression turned up two more problems in the same area, both mine:

  • A positional endpoint (mcp-remote https://u:pw@host/mcp?token=x) was cut at its query =, so the "flag" was https://u:pw@host/mcp?token, which names a credential. The previous head printed the userinfo next to a masked query value. My first version of this fix made it worse: the element fell through to the bare-flag reader, was printed whole, and the next argument was masked in its place. The regression caught that before I pushed. An element that is a URL is now read as one before any = reader sees it.
  • Exact-value redaction knew YWJjZGVmZ2hpag==, and an echo of the body without its padding matched nothing. Every known form now also offers its unpadded spelling, bearer tails included. Ambiguous values only get it when what is left still passes the eight-byte floor.

2. Short bearer. knownCredentialTails keeps the tail of the supported shapes, <scheme> <credential> and <header>: [<scheme>] <credential>, without the floor: at most two name-like words, then one token. A multi-word known value is not taken apart, and the separator scan stays inside the existing 256-byte prefix, so the oversized-value and bounded-expansion tests still hold. mode=sse stays readable next to a redacted s3cr3t, for the configured header and for the three stdio header spellings.

3. The cut made by the HTTP reader. httpStatusError now returns an error that says whether it cut the body (mcp.ErrorDetailTruncated), and the display repairs the tail when either side made the cut. It reads one byte past the limit to tell "exactly 1024" from "there was more". The regression is your body, 250 erase-line sequences and then the 32-byte value, served as a real 500 and registered through mcp.RegisterTools with the real client. Controls: an ordinary error shown whole, and a sliced body that is not a credential keeping its tail. A second test in internal/mcp pins the other half of the contract for http and sse: the marker survives the wrapping, and the retained body stays at the end of the error, because the tail is the only place the repair looks.

4 and 5. Fragment and escaped password. Fragment pairs are collected with the classification Target already uses (known under a credential key, otherwise the floor), in raw and decoded form. The raw userinfo password is known, like the decoded one, and the username stays ambiguous. Both regressions start a real stdio child that reports what it was handed (the whole endpoint, the raw password alone, the decoded password alone, the username alone) and check the Error and Target rows separately. A bare #anchor with no = is left alone, which is what Target does with it.

6. Enable. setServerDisabled validates the prospective enabled entry with refuseColliding before anything is written. The test runs zero mcp enable ' docs' against a temp file and asserts failure, identical bytes and a clean reload. Then a non-conflicting enable, and disable and remove on a file that already collides.

7. Check. runMCPCheck keeps the exact key for lookup and for output, and matches the registration outcome by the name NormalizeConfig derives from it. The test drives check " docs " with a real failed registration (the binary does not exist): unreachable and a non-zero exit, text and JSON, with docs as the control, and a healthy padded key still reports ok. A tui test pins that Enter and Alt+c dispatch the same ["check", " docs "], so the one receiving path covers both.

I reverted each fix on its own and the named test fails with the output you described: ["="] from the collector, s3cr3t on both surfaces, Qw7ZmPr4aBcD9eFgH2jKlM6n, the fragment on the Error row, %73crt, enable reporting success, check returning ok.

One existing test changed. TestMCPManagerTargetsRedactSecretsFromURLsAndArgs renders at 320 columns instead of 260: the --endpoint URL in that fixture used to lose its fragment (it was masked by accident as part of the query value) and now keeps #token=[REDACTED], which pushed the env assertion past 260.

Not covered: the enable check validates the file being written, the same as add, so a collision across config layers is still only caught at load.

jatmn
jatmn previously approved these changes Sep 19, 2026

@jatmn jatmn 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.

LGTM

gnanam1990
gnanam1990 previously approved these changes Sep 21, 2026

@gnanam1990 gnanam1990 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.

Verdict

Approve — and this withdraws my CHANGES_REQUESTED from 072e0366, which is what has been holding this branch alongside jatmn's approval.

Reviewed at 6cc1ec40, base 99721c76.

Withdrawing my previous block

My 2026-09-12 review said explicitly "I found no new code-level blocker" and requested changes only because the branch was 29 commits behind main. That is done: 6cc1ec40 is 0 behind origin/main, so the reason no longer exists. Changes-requested survives a push where an approval does not, so it needed a new submission rather than an edit — hence this review.

What I verified on this head

  • Merged tree: git merge origin/main is already-up-to-date; go build ./..., go vet ./... and the full go test ./... are clean on macOS.
  • Gut-the-fix on four load-bearing behaviours — each reverted in turn, internal/tui rerun, all four caught:
    1. a recorded failure rendering as failed — revert and the panel shows a server that never started as enabled
    2. the trimmed-identity lookup (strings.TrimSpace(rawName)) — revert to the raw map key and " docs " misses its failure entry, which is the exact defect the comment at mcp_state.go:85-97 describes
    3. disabled winning over a recorded failure
    4. redactMCPFailureReason on the displayed reason — replacing it with a plain err.Error() is caught, so invariant #6 is held by a test and not just by the call site

That last one is the one I most wanted to see fail, since a startup error is exactly where a path or bearer leaks into a panel.

Follow-up from my earlier pass, now closed

I had asked (non-blocking) whether a successful re-register clears the entry so the panel flips back to enabled. The observation now carries its credential fingerprint (SkippedCredentials, mcpCredentialFingerprint) and buildMCPServerViews compares against it, so a stale failure is not rendered against refreshed material. That answers it.

Not covered

macOS only. I did not exercise a real failing MCP server end-to-end, nor the Windows paths; the evidence above is the merged tree plus targeted mutation of the display logic.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai the three open threads, checked against 6cc1ec40:

  • Token across the cut (mcp_raw_bound_test.go). That test puts the credential across the 16 KiB cap, inside the overlap window, which is the case the window is there for. The real cut at cap plus window, and the trailing-prefix repair that handles it, are covered by TestNoCredentialPrefixSurvivesTheRawBound in mcp_credential_boundary_test.go: a credential that starts before the tail window, and an eight-byte one split after seven.
  • Short configured values (mcp_error_redaction_test.go). Valid. The test passes just as well with the 1 and the true redacted: with the floor lowered to one, the message comes back as "connection refused after [REDACTED] attempt in [REDACTED] isolation" and the substring check still holds. Comparing the whole message catches that.
  • C1 fixture (mcp_failure_redaction_test.go). Valid. The literal U+0090 becomes the \u0090 escape, same input.

Both of those are test-only and I have them ready. Pushing now would dismiss both approvals, so I'm holding them. They go out with the next change this PR needs, or right after merge if there isn't one.

@coderabbitai

coderabbitai Bot commented Sep 24, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
git rev-parse --short HEAD
fd 'mcp_(raw_bound|credential_boundary|error_redaction|failure_redaction)_test\.go$|mcp_state\.go$|mcp_view\.go$' .
ast-grep outline internal/tui/mcp_raw_bound_test.go --match 'TestASecretStraddlingTheBound|Test' --view expanded
ast-grep outline internal/tui/mcp_credential_boundary_test.go --match 'TestNoCredentialPrefixSurvivesTheRawBound' --view expanded
rg -n -C 5 'TestASecretStraddlingTheBound|TestNoCredentialPrefixSurvivesTheRawBound|TestShortConfiguredValuesDoNotShredTheMessage|0090|longestPrefixSuffix|maxMCPReasonRawLen|maxMCPSecretMatchWindow' internal/tui/mcp_raw_bound_test.go internal/tui/mcp_credential_boundary_test.go internal/tui/mcp_error_redaction_test.go internal/tui/mcp_failure_redaction_test.go internal/tui/mcp_state.go internal/tui/mcp_view.go

Length of output: 13997


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '43,84p' internal/tui/mcp_raw_bound_test.go
sed -n '1,108p' internal/tui/mcp_credential_boundary_test.go
sed -n '65,93p' internal/tui/mcp_error_redaction_test.go
rg -n -C 7 'C1|U\+0090|\\u0090|\\x90|TestFailedServerReasonRedactsASecretSplitByControlBytes' internal/tui/mcp_failure_redaction_test.go
sed -n '195,233p' internal/tui/mcp_state.go
sed -n '265,291p' internal/tui/mcp_state.go
rg -n -C 3 'dropTrailingSecretPrefix|boundMCPFailureError|stripTerminalRejoiners' internal/tui/mcp_state.go

Length of output: 14035


@Vasanthdev2004, the additional inspection confirms the raw-bound distinction. TestASecretStraddlingTheBoundIsStillFullyRedacted crosses the 16 KiB cap. TestNoCredentialPrefixSurvivesTheRawBound crosses the actual cut at the cap plus the 4 KiB overlap and checks that no credential prefix survives. I withdraw the raw-bound test finding.

The other two test findings remain open. TestShortConfiguredValuesDoNotShredTheMessage checks only for "connection refused", so it would miss redaction of 1 and true. The C1 fixture still contains a literal U+0090. Your proposed test-only changes address those findings, but they have not landed.

Thanks for the precise boundary explanation.


✏️ Learnings added
Learnt from: Vasanthdev2004
URL: https://github.com/Gitlawb/zero/pull/835

Timestamp: 2026-09-24T10:00:02.135Z
Learning: In `internal/tui/mcp_raw_bound_test.go`, `TestASecretStraddlingTheBoundIsStillFullyRedacted` covers a credential crossing the 16 KiB display cap inside the overlap window. In `internal/tui/mcp_credential_boundary_test.go`, `TestNoCredentialPrefixSurvivesTheRawBound` covers a credential crossing the actual raw-error cut at `maxMCPReasonRawLen + maxMCPSecretMatchWindow`, including trailing-prefix repair. Do not treat the first test as the only coverage of the actual cut.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@euxaristia euxaristia 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.

The failure reasons come from the server and pass the redaction helper before rendering (with dedicated tests for authorization-header echo, oversized secrets, and path credentials) plus truncation bounds, and disabled wins over failed in the display. Scope note: this bundles credential fingerprints, an OAuth store, and identity work well beyond a fix(tui) title, though it arrives with about 34 test files.

@Vasanthdev2004 Vasanthdev2004 changed the title fix(tui): show MCP servers that failed to start in /mcp fix(tui,mcp): show MCP servers that failed to start in /mcp, bound to their server and with credentials redacted Sep 28, 2026
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x your 3 August change request is still the block here. The terminal-sanitization finding was fixed in c7421d9 two days later, and your payload is pinned by TestMCPFailureReasonCannotInjectTerminalControl on the current head 6cc1ec4. jatmn and gnanam approved that head. Could you take another look when you have a moment? @kevincodex1, for the owner's view: the request is against be58076.

…ws-failures

# Conflicts:
#	internal/tui/command_views.go
#	internal/tui/mcp_state.go
main moved the module to github.com/Twigpine/zero in #1105. Rename this
branch's imports to match, including the two in internal/workspaceindex
that main itself still carries until #1153 lands.
@Vasanthdev2004
Vasanthdev2004 dismissed stale reviews from gnanam1990 and jatmn via 1185361 October 17, 2026 07:37
@Vasanthdev2004
Vasanthdev2004 requested a review from jatmn October 17, 2026 07:38
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn @gnanam1990 this push dismissed your approvals, sorry. It merges main at 2ffe183 and renames this branch's imports to the Twigpine module path from #1105, which the branch needed before it could build on main. The only conflicts were the import blocks in internal/tui/command_views.go and internal/tui/mcp_state.go, where this branch adds the internalmcp and redaction imports; both keep this branch's set on the new path. Nothing else changed from 6cc1ec4. The rename also covers the two internal/workspaceindex imports that #1153 fixes on main, so that hunk drops out once #1153 lands. go vet for windows, linux and darwin, go test ./..., the release build and smoke, and govulncheck all pass here. Could you take another look when you have a minute?

…the C1 fixture

TestShortConfiguredValuesDoNotShredTheMessage checked only that
"connection refused" survived, so it passed just as well with the 1 and
the true redacted, which are the values it exists to protect. It now
compares the whole message.

The C1 control fixture held a raw U+0090, which staticcheck reports as
ST1018. It is written as an escape now; the input is the same.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

One more push on top of the rename: 761fbd3 carries the two CodeRabbit test fixes I had held since September so as not to dismiss approvals. The short-value test now compares the whole message, and the C1 splitter is written as an escape sequence instead of a raw invisible character. Test-only; go test ./..., vet for three platforms, the release build and smoke, and govulncheck pass here.

@jatmn jatmn 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.

I rechecked this PR on head 27218dc (full diff, focused tests, blind search). One code item remains before I’d approve; the rest of what I raised on 42a95b9 (redaction pipeline, arg boundaries, manager ConfigKey, optional completion, collision/enable paths, etc.) is addressed on current head or is explicitly out of scope for #825.

Merge readiness

  • Branch is not behind origin/main; required CI on 27218dc is green.
  • My CHANGES_REQUESTED on 42a95b9 is stale relative to this head—treat it as superseded by this note once the finding below is fixed (or tell me if you want any prior thread item re-opened with a specific repro on 27218dc).

🟡 P2 — Use each row’s ConfigKey in transcript MCP action hints

📍 Where: internal/tui/mcp_view.go (mcpServerActionLine, consumed from mcpManagerServerLines in the /mcp transcript text).

💥 What fails: The panel lists suggested CLI commands with the canonical runtime name, not the configuration map key:

actions := []string{"zero mcp check " + name} // name = strings.TrimSpace(server.Name)

For a server stored under a padded key such as " docs ", the row displays as docs but zero mcp check (and siblings) look up cfg.Servers[serverName] by exact key. The manager overlay already dispatches check/disable/enable/remove with ConfigKey; copying the transcript hint runs the wrong command or reports “not configured” while the panel still shows a failed server.

🔎 Root cause: This PR split display identity (Name, trimmed runtime) from action identity (ConfigKey, exact map key) and wired ConfigKey through the manager, but the transcript action line still builds commands from Name only—the last in-diff operator surface missing from the same identity contract you already applied in mcp_manager.go.

📜 Stated contract:

ConfigKey is the EXACT key in the configuration map, untrimmed. Actions address the config by that key, so it is the only identity that can select one row when a padded alias renders under the same canonical name.

🏷️ Attribution: Incomplete fix on this PR at 27218dc. Misleading hints for padded keys existed at merge-base too; what’s new is the documented ConfigKey rule and manager compliance—transcript hints need to match in the same pass.

📌 In this PR:

  • internal/tui/mcp_view.go — mcpServerActionLine / mcpManagerServerLines transcript actions (must use ConfigKey)
  • internal/tui/mcp_manager.go — already correct (do not regress)

🔒 Unchanged on main: CLI key lookup; stderr / mcp check redaction (still generic redaction.ErrorMessage as before this PR’s panel work).

🔧 Required correction: Build every zero mcp … hint in mcpServerActionLine from server.ConfigKey (shell-quote when the key contains whitespace or shell metacharacters). Add a regression test with a padded key that the action line includes the exact map key, not only the trimmed display name.

🛠️ Author fix: Fix transcript action hints and the test in one pass; leave manager dispatch, CLI, registration, stderr, and mcp check redaction alone unless you’re deliberately expanding scope.

🚫 Out of scope for this round: Panel-grade redaction on stderr / mcp check / ACP; optional MCP “enabled” while background registration runs; view cache keyed on late-failure count only; in-session re-register after config edits (per your PR note).

The commands printed under each server in the /mcp transcript were built
from the trimmed display name, while check, enable, disable and remove
look the server up by its exact config key. For a padded key such as
" docs " the hint reported "not configured", and for an alias pair it
addressed the other entry. The manager overlay already dispatches with
ConfigKey; the transcript hints now use it too.

A key with space padding is double-quoted, which keeps the spaces in
POSIX shells, PowerShell, cmd and /mcp. Padding that cannot be typed back
from a terminal line, such as a tab, gets no key-addressed hint. oauth
login keeps the trimmed name, because it matches the normalized server
list rather than the config map.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn fixed in 3a9dcb0, plus a merge of main.

The check, enable, disable and remove hints in the transcript now use ConfigKey. A key with space padding is double-quoted, so " docs " shows as zero mcp check " docs ". Double quotes keep the spaces the same way in bash, PowerShell, cmd and /mcp, and nothing else in a valid name is special inside them. I ran them against a throwaway config with a " docs " key: the quoted form reaches the entry from bash, PowerShell and cmd, while zero mcp check docs and zero mcp disable docs both report it as not configured.

Two cases don't use the key:

  • oauth login keeps the trimmed name. resolveOAuthServer matches server.Name from NormalizeConfig, so zero mcp oauth login " linear" reports "not configured". Switching it would have broken the one hint that already works.
  • A key padded with a tab, a newline or a Unicode space gets no check/enable/disable/remove hint. Printed raw, those either look like plain spaces, so a copied hint would address a different key, or they put a control byte in the transcript. The overlay still acts on that row by ConfigKey.

TestTranscriptActionHintsAddressTheExactConfigKey covers four cases: a padded single key; an alias pair, where the disabled " docs" row's old hint pointed at the enabled docs; oauth login; and the tab case. All four subtests fail on 27218dc and pass on this head. The full suite, build, smoke, vet for windows/linux/darwin and govulncheck for linux and windows are clean.

@jatmn jatmn 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.

I found four issues that need to be addressed before this is ready. The latest transcript action-hint fix is correct, including the author's distinction between exact configuration keys and OAuth's normalized names. I also verified the explanations about disabled aliases, the overlap-versus-cut tests, and the deliberately excluded reconnect/stderr behavior.

Reviewed head 2c36c67ac8be29ff13b59b18f580ac656b9cb6e0 against merge-base and live main 4247a07aba0f89c3c2597314a33edaede56be70c.

Merge readiness

The branch is mergeable and 0 commits behind main; no rebase or release-metadata conflict was found. All 12 executed checks succeeded; CodeRabbit was skipped. GitHub reports BLOCKED / CHANGES_REQUESTED, so fresh review approval is needed after the fixes. The closed #822 diagnostic issue is complementary, and merged #1113 supplies the OAuth fields involved below; neither supersedes #835.

Findings

🟠 P1 — Include supported runtime credentials in the new failure-redaction input

📍 Where: internal/mcp/oauth_store.go:286-294 (SecretValues) and internal/tui/mcp_state.go:915-921 (environment collection).

💥 What fails: Three real startup paths disclose an opaque credential in both the transcript panel text and manager detail:

  • With auth: oauth and no configured OAuth block, a stored dynamically registered ClientSecret is submitted on refresh. A token endpoint's error_description echo is displayed verbatim.
  • A credential in the discovered/stored TokenEndpoint is likewise absent from the new redaction input. A real failing token POST that echoes its workspace query value exposes it.
  • A stdio child can inherit COMPANY_TOKEN from the launching environment, as the documented secret-manager setup permits. When the child reports that value while failing initialization, the new reason displays it even though Target contains no credential.

I reproduced the last case with a real child through the existing execution adapter's supported degraded path; its environment scrubber leaves this unlisted variable available. All three corresponding display probes pass at the merge base, where startup error text is not rendered.

🔎 Root cause: The new safety input is reconstructed from configured fields and only part of stored OAuth material:

values = append(values, stored.AccessToken, stored.RefreshToken)
// The configured-environment collector separately visits only raw.Env.

It misses accessible credentials that the supported startup paths actually use. The token-context fingerprints also receive the incomplete stored set.

📜 Stated contract:

“Redact success and error paths (including stderr).” — AGENTS.md

“ClientSecret is sensitive.” — the existing StoredToken contract

“A secret manager that injects the env var your MCP server reads on its own (the command and args then run inside that environment).” — docs/EXTENDING.md

🏷️ Attribution: PR-introduced disclosure. Stored discovery fields and inherited environment already exist on base/main, but the newly added failure-reason path exposes them. This is not a request to repair the older stderr policy.

📌 In this PR:

Surface Current result
oauth_store.go:SecretValues and its new tests Access/refresh tokens covered; stored client secret and token-endpoint credential components missing
mcp_state.go:mcpServerSecretValues Configured headers/env/args/URL/OAuth covered; inherited sensitive stdio variables missing
registry.go, cli/app.go, tui/model.go, command_views.go Capture/current-context handling consumes the same incomplete credential inputs
mcp_view.go and mcp_manager.go Both consume the resulting unsafe reason; shared input correction should protect both

🔒 Unchanged on main: Login, refresh, environment merging/scrubbing, and token persistence already implement these startup sources. Public client IDs, scopes, expiry, token types and ordinary environment metadata are not credentials.

🔧 Required correction: Extend the PR's new safety-input collection to cover the stored client secret, credential-bearing stored token-endpoint components using the existing URL rules, and inherited credential-named stdio variables such as COMPANY_TOKEN. Keep capture/current context consistent for mutable stored material. Preserve read-only enumeration, existing bounds, ordinary metadata and current configured-value protection. Verify real refresh/child failures through both displays, including relevant credential-change suppression.

🛠️ Author fix: Close the missing-input rule across every row above in one pass, including source comments and regression fixtures. Do not fix only the OAuth example or only the environment example; do not require a new observation architecture or plaintext credential store.

🚫 Out of scope: OAuth protocol changes, sandbox environment policy, introspecting secrets created inside arbitrary wrapper scripts, unrelated logging and reconnect behavior.


🟠 P1 — Preserve query credential provenance across raw and decoded spellings

📍 Where: internal/tui/mcp_state.go:1103-1134 (mcpURLSecretValues).

💥 What fails: Real HTTP registration failures expose configured URL credentials in both displays:

  • ?api%5Fkey=%73crt followed by a value-only echo of %73crt: Target recognizes the decoded sensitive key and masks it, but Error prints the recoverable raw credential spelling.
  • ?workspace=opaque%2Dworkspace%2D9f3c2b7&filter=a;b followed by a decoded-value echo: the unrelated semicolon error causes the valid decoded workspace candidate to be discarded. An unrelated invalid escape has the same effect. A normal valid-query control is protected.

🔎 Root cause: The raw pass classifies an undecoded key, while the decoded pass is discarded wholesale on any query parse error. Raw and decoded representations therefore lose either their credential classification or their membership in the candidate set.

if isSensitiveMCPDisplayKey(rawKey) { /* ... */ }
query, err := url.ParseQuery(parsed.RawQuery)
if err != nil {
    return known, values
}

📜 Stated contract:

“Redact success and error paths (including stderr).” — AGENTS.md

The existing TestShortValuesUnderSensitiveKeysAreRedacted and TestPercentEncodedEndpointCredentialsAreRedacted assert the specific supported contracts: known credentials bypass the readability floor, and configured escaped/decoded URL values remain protected.

🏷️ Attribution: PR-introduced. The collector and rendered failure reason are new; identical real transport/display probes do not expose these values at base/live main.

📌 In this PR:

Surface Current result
Main server URL Both failures reproduced
Positional/separate/packed URL-valued stdio arguments Same mcpURLSecretValues collector
Configured OAuth token, authorization, registration and issuer URLs Same collector
Target query renderer, fragment collector and raw userinfo-password handling Already retain the relevant decoded-key/per-value provenance; preserve these
New URL/provenance/assembled-state tests Cover valid escaped values, but not escaped short-value keys or a valid pair beside a parse error

🔒 Unchanged on main: URL admission and generic redaction. The accepted URL loader does not reject these query strings; this finding does not require changing that policy.

🔧 Required correction: Classify each raw query value using its decoded key, retaining both supported value spellings with that classification. Preserve independently decodable pairs when another pair fails. Use the corrected shared helper for every listed caller, with regression controls for ordinary short metadata and all demonstrated combinations.

🛠️ Author fix: Fix the query extraction rule and all listed caller coverage together. Do not patch one parameter name or one URL source; do not rewrite core URL validation or generic redaction.

🚫 Out of scope: New encoding schemes, broad configuration rejection rules and unrelated CLI output.


🟠 P1 — Mark interrupted HTTP error details as incomplete

📍 Where: internal/mcp/network_client.go:643-646 (httpStatusError).

💥 What fails: An HTTP or SSE server returns 500 with Content-Length: 2048, sends only the first 24 bytes of a configured 32-byte opaque credential, then closes. The real client retains those bytes, but ErrorDetailTruncated is false. Both new failure displays show Qw7ZmPr4aBcD9eFgH2jKlM6n, 24 of the 32 secret bytes. Complete-body controls remain protected.

🔎 Root cause: The new incompleteness signal recognizes only the size cutoff and discards the body-read failure:

body, _ := io.ReadAll(io.LimitReader(response.Body, maxHTTPErrorDetail+1))
truncated := len(body) > maxHTTPErrorDetail

A short partial body cannot match the complete configured credential, and neither this false marker nor the local 20 KiB bound activates the existing tail repair.

📜 Stated contract:

“Redact success and error paths (including stderr).” — AGENTS.md

The existing TestCredentialPrefixCutByTheHTTPReaderIsNotDisplayed protects credential prefixes cut at the producer; this is an observable interrupted-read variant of that same boundary.

🏷️ Attribution: PR-activated disclosure. Base/main already discard the read error, but do not render this startup body in the MCP views. The PR adds both the reason consumers and the incomplete producer marker. Identical HTTP/SSE display probes pass at base.

📌 In this PR:

  • httpStatusError: shared by HTTP POST, SSE stream GET and SSE POST.
  • ErrorDetailTruncated / redactMCPFailureReason: existing new consumer correctly repairs marked partial text.
  • Both reason renderers: affected through shared state.
  • New producer/truncation tests: size-cut and complete-body cases covered; read-error case absent.

🔒 Unchanged on main: Network transport architecture and old stderr/check behavior.

🔧 Required correction: Treat a failed body read as incomplete, or withhold its partial detail while retaining useful status context. Preserve byte bounds and complete-response behavior. If reusing the flag, keep size slicing dependent on actual body length so a short failed read cannot panic. Add interrupted-response regressions through real HTTP/SSE registration and both consumers.

🛠️ Author fix: Close the shared producer incompleteness rule for every listed route in one pass. Reuse the existing marker/tail handling; do not replace the networking framework or expand this into arbitrary partial-string recognition.

🚫 Out of scope: Detecting prefixes deliberately authored as complete error messages or changing unrelated error-output policy.


🟡 P2 — Isolate the new startup-wiring test from user token stores

📍 Where: internal/cli/app_mcp_skipped_test.go:31-68.

💥 What fails: The test leaves newMCPTokenStore at its production default. Running this exact new test with simulated user ZERO_MCP_OAUTH_TOKENS_PATH / ZERO_OAUTH_TOKENS_PATH values migrated the seeded legacy token, renamed its original file and created the unified store. On Windows, the helper sets only APPDATA, while both OAuth paths fall back through HOME/USERPROFILE; an inherited keyring backend also bypasses a temporary config path.

The reproduction used only review-owned simulated user files.

🔎 Root cause: Redirecting the config root does not isolate the credential-store constructor or its migration/backend selection.

📜 Stated contract:

“Tests must not read or write the developer's real config, cache, or state directories.”

“Redirect every root the code under test resolves, not just config.” — AGENTS.md

🏷️ Attribution: PR-introduced test defect. This test file and constructor invocation path are absent at base/live main. The old shared helper's limitations do not justify adding another uncontained caller.

📌 In this PR:

  • New startup test: production token constructor and inherited backend/path settings remain active.
  • New direct token-store helpers in internal/mcp and internal/tui: already use explicit temporary paths and Env: map[string]string{}.
  • New CLI identity/check tests: non-OAuth stdio paths do not invoke this token constructor.

🔒 Unchanged on main: Credential-store and legacy-migration implementations; unrelated older tests.

🔧 Required correction: Supply an explicitly temporary file-backed token store with an isolated environment in this new test, or equivalent complete local isolation that also prevents legacy migration and host-keyring access. The existing dependency seam and store options already support this.

🛠️ Author fix: Contain every credential-store resolution reached by the new startup test in one pass, and verify seeded user paths remain untouched. Do not redesign the store or repair unrelated main tests.

🚫 Out of scope: Shared storage architecture and repository-wide test cleanup.

Needs maintainer decision

A child can interpret --api-key -Q... as a value, but the current collector intentionally treats a following dash-prefixed item as another flag; its negative test explicitly preserves --api-key --verbose. A real-child probe confirms the separated spelling can expose an opaque value, while equals/packed spellings redact it. There is no established child-independent grammar resolving that ambiguity. I am not requiring parser expansion or removal of the existing negative control in this PR; support for that separated form needs an explicit product choice.

Reviewed contract coverage

Lifecycle / ordering Changed entry points and siblings checked Operator claims
Configure -> prospectively validate -> existing locked atomic write -> reload -> adopt/invalidate Add, enable, disable, remove, manager actions and wizard; active/disabled aliases and exact/runtime identities No changed markdown/help/generated operator files; quoted transcript hints now address exact keys
Critical/optional capture -> retain -> completion -> consume -> config/token change -> close Connect/list/validation, snapshot/getter/channel, Init/handler/cache, both displays, replacement/disable/logout and runtime cancellation Reconnect and pending optional labels remain the documented scope exclusions
Error -> bound -> normalize/redact -> partial-tail repair -> display Configured/stored/inherited source matrix; HTTP/SSE body producer; real stdio capture In-memory transcript exposure verified; no claim that these command rows are directly persisted to session storage
Shared field/source Producer / load Capture / consumer Tests Docs / renderer
Exact key / runtime Name JSON, name validation, active uniqueness Exact actions vs canonical observations/tool counts/OAuth Padded keys, aliases, collisions, receiving CLI and hint tests Source comments consistent; latest hint fix verified
Configured credential fields and URL components Existing normalization and shared arg/URL parsing Reason and Target classifiers Short/escaped/split/padded/bounded fixtures plus query counterexamples Both displays traced; no changed operator-doc claim
Stored access/refresh/client-secret/endpoint and inherited sensitive env Store/login/refresh; documented env injection and launch merge/scrub Pre-connect/current context and both reasons Real refresh/child probes; credential-change controls inspected F1 covers omitted inputs; public identifiers/metadata preserved
Partial-body signal HTTP/SSE size/read boundary and error wrapping Existing tail suppression Complete, size-cut and interrupted-body probes F3 closes incomplete producer signaling

Malformed-input review covered empty/trailing/next flags, packed and equals boundaries, base64 padding, positional URLs, escaped query/fragment data, invalid URL fallback, independently invalid query pairs, wrong config shapes/types, missing transport requirements, invalid names/hosts/schemes, nil stores/errors, oversized values and UTF-8 cuts. The findings require local safety corrections, not new admission policy.

Validation

  • go build ./... and go vet ./internal/tui ./internal/mcp ./internal/redaction ./internal/cli passed.
  • Affected-package tests passed for MCP, redaction and CLI. The TUI package's only failure was TestRelativeAgeFormatsLastActivity (2025-05-31 versus 2025-06-01); the identical failure reproduces at base.
  • Focused MCP/credential tests with -race passed across TUI, MCP and CLI.
  • Review-owned overlay probes exercised real HTTP/SSE/stdio paths, both displays, immutable-base comparisons and simulated user-store migration. The reviewed checkout is unchanged.

This branch has not been deployed

No deployments
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.

MCP panel shows configuration state, not connection reality

6 participants