Skip to content

fix(webui): stop the UI asserting states it was never told (v0.65.0 UX audit) - #1216

Merged
Dumbris merged 19 commits into
mainfrom
integration/webui-ux-fixes
Sep 7, 2026
Merged

Dumbris merged 19 commits into
mainfrom
integration/webui-ux-fixes

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 6, 2026

Copy link
Copy Markdown
Member

What this is

Fixes for the findings of a first-time-developer UX audit of MCPProxy v0.65.0. One theme runs through nearly all of them: the Web UI stated things it had not been told, or described a state with words that meant the opposite of the truth. A green "Healthy" on a server no client can reach. A "Round-trip verified" finish line stamped off a transport handshake. A "Setup ✓" that was really "we never fetched". "No tools available — server must be connected" on a server whose tools were deliberately withheld for security review.

Almost all of it is copy and render-gating in frontend/src/. There is exactly one backend change (7 lines in internal/server/server.go) and one Swift change that follows from it.

This is an integration of six focused branches, each pushed separately if anyone would rather review them one at a time: fix/webui-readiness-and-review-surface, fix/webui-search-expectations, fix/webui-add-server-handoff, fix/webui-setup-verify-honesty, fix/webui-auth-and-loading-state, fix/duplicate-endpoint-clarity.


The findings, and what changed

F08/F10/F12 — the server detail screen said "absent" where the truth was "withheld", "off", or "nothing written"

Health tile (F10). internal/health/calculator.go deliberately reports a quarantined or disabled server as level healthy — "quarantined is intentional, not broken". The tile printed that level, so it rendered a green Healthy on precisely the servers no client can reach, and could pair it with a "Sign-in required" sub-line, a combination only reachable on a quarantined/disabled server. The tile's single word now leads with the admin state (Blocked / Off); the observed health keeps its own vocabulary on the sub-line beneath, which already printed health.summary. Neither reads text-success any more. The hard-coded "set by you" caption under Admin state was false for the default quarantine-on-add path, so a quarantined server now reads "awaiting your review".

Tools tab empty state (F08). A server quarantined at admission is connected:false and its tools are kept out of both the state snapshot and the search index by design (internal/runtime/tool_quarantine.go). GET /servers/{id}/tools therefore returns [], and the tab rendered the same generic "No tools available / Server must be connected" a genuinely broken server gets — while the Security tab was asking the operator to review those very definitions. It is now quarantine-aware ("Tools withheld for review") with a button through to the Security tab. The sentence deliberately names no count: quarantine.pending_count counts tools awaiting review, which is not the count of tools withheld (quarantining a trusted server with 20 approved and 2 new tools reports 2 while all 22 are withheld), and no payload field carries the real total.

Missing per-server log file (F12) — the one backend change. GET /api/v1/servers/{id}/logs returned HTTP 500 "log file not found … (server may not have run yet)" for any server with no log file. That claim is false: the file is created lazily by lumberjack and is level-gated (internal/logs/logger.go), so a connected, healthy server that logged nothing at or above the configured level has no file at all — on any transport. The ENOENT branch now returns an empty (non-nil, so it marshals as []) result and the UI falls into its normal empty state. Every genuinely broken case — unknown server, path escape, unreadable file — still errors.

F11 — tool search blamed the query for what the filters had done

Typing "React documentation" returned nothing while "documentation" returned Context7: the filter matched the whole query as one contiguous substring of a single field. Terms are now matched independently (AND across terms, OR across fields) — a strict superset of the old behaviour, so no result that matched before can disappear. Still a substring filter, not BM25; wiring the box to /api/v1/index/search is a product decision and is reported, not built.

The empty state now names the query and the scope it actually searched — how many tools, across how many servers, whether disabled tools were in scope — and, only when there are any, how many quarantined servers are not listed here. Quarantined servers contribute zero tools to GET /api/v1/tools and quarantine is on by default, so a first-run developer was searching a silently truncated catalogue with no hint of it.

Reporting stats.total would have lied whenever another filter was also narrowing the list, so the filter chain is split into searchScope (every filter except the search box) and filteredTools. All filters are ANDed, so the result is identical and the numbers are now literally true. A separate branch handles the case where a status filter left nothing to search — no wording of the query can help there, so the empty state names the filter as the cause instead.

F07 — Add Server showed dead controls, then abandoned the user

The Docker isolation toggle rendered disabled for URL upstreams, where isolation is structurally impossible (internal/config/isolation_resolve.go resolves IsolationSourceNotStdio whenever Command is empty). It is now v-if="type === 'stdio'". Presentation only — both existing state guards are untouched, so isolation_json still cannot reach an http add. The permanently-disabled "Coming soon" Idle on Inactivity toggle is removed; handleSubmit never read it and no backend field of that name exists.

After a successful add the user was dropped back where they started while the activation work (connect, scan, review, approve) waited on a page they had to find. The added event now carries the server name and the three navigating consumers push to serverDetailPath(name). The navigation lives in the consumers, not in handleSubmit — OnboardingWizard.vue also listens to added, and a push from inside the modal would unmount the wizard mid-flow. The bulk-import path keeps its bare emit('added'): a bulk import has no single destination.

F13 — the setup wizard called a handshake a verified round-trip

The Verify tab showed a green "Round-trip verified" off a single boolean, first_mcp_client_ever, which is stamped from the MCP initialize hook — a transport handshake, before any tool is listed let alone called. All three suggested prompts targeted mcpproxy's own built-ins, which record as internal_tool_call and never stamp first_real_tool_call_ever. A user who had never seen an upstream tool run was told the product was verified.

It is now two separately-labelled milestones: "AI client connected" (same condition, honest words) and "First upstream tool call". One suggested prompt was added that actually dispatches to an upstream. The copy says "ran through mcpproxy and returned a result", not "succeeded", because recordRealToolCallSuccess() is called one statement before the isError classification.

And then the fix was corrected for the same defect in the opposite direction. first_real_tool_call_ever has exactly one call site (the call_tool_* handler), so under routing_mode=direct or code_execution the new row told a user who had made dozens of real upstream calls "No upstream tool call yet", forever. Shipping a green claim off a handshake and a red claim off a blind flag are the same defect wearing different colours. The state is now tri-state and routing-mode aware, read from the same GET /api/v1/status response that already carries activation — satisfied (a lifetime fact, true under any mode), pending (only under the mode that actually stamps it), unknown (the row and its caveat do not render at all). The backend gap is real and still open; it needs Go tests and its own diff.

F06 — the first load asserted state it had not been told

Behind the "Authentication Required" modal every surface still rendered, and three of them stated as fact things the UI had never been able to read. On one fresh install, unauthenticated vs authenticated at the same moment:

before, unauthenticated  Setup ✓ | MCPPROXY stopped | 0 connected | 0 tools
authenticated, same box  Setup 2 | MCPPROXY active  | 0 connected | 0 tools

The "Setup ✓" was the worst: incompleteTabCount reads state?.incomplete_tab_count ?? 0, so "never fetched" and "nothing left to do" both arrive as 0 — and since nothing moves state off null, this was permanent for any install whose fetch kept failing, not a pre-fetch flash. Three gates were added using flags that already existed: setupStateKnown, serversStore.loaded, and systemStore.status !== null. Unknown renders a plain label or an em-dash rather than a confident zero.

The auth screen's recovery instructions were also corrected, each verified against the code and by running it: mcpproxy doctor never prints the API key (doctor_cmd.go has zero matches for apikey/api_key) — replaced with mcpproxy status; the startup log carries the key in full only on the run that generated it; the tray labels are named without the OS claim they previously carried (the tray is a build split, //go:build !nogui && !headless && !linux, not an OS split — a macOS tarball or Homebrew user sees "Open Web Control Panel", only the DMG's Swift bundle says "Open Web UI in Browser").

F09 — a duplicate endpoint was accepted in silence, then the gate contradicted itself

Adding a second upstream at an already-configured endpoint is accepted silently, and the first the user hears of it is the scanner reporting the new server as dangerous: two entries on one endpoint expose byte-identical tool names and descriptions, so detect.shadowing.cross_server emits a hard-tier "possible impersonation" finding per tool. Reproduced end to end on a scratch instance: a single POST /api/v1/servers of the duplicate produced status: dangerous, finding_counts {dangerous: 2} unprompted, and POST …/security/approve without force returned 409 server has 2 dangerous (hard-tier) finding(s).

Two halves, both frontend-only:

  1. Add-time observation. The manual tab now names the configured server already holding the endpoint being typed, as a neutral note. Submit stays enabled; nothing is merged, reused or blocked. Matching is exact string equality after trim — url for http; command, the full args list and working_dir for stdio (node server.js in two directories is two different programs), with a negative transport guard so a stale url left on a converted stdio entry cannot be named as the occupant of a typed HTTPS endpoint. It can miss: the server list masks credential-shaped values, so a secret-bearing endpoint never matches. A hint, not a guarantee. Web-UI door only — the MCP upstream_servers add handler, the CLI and the import path still say nothing; covering them needs a warning field on the add response.

  2. Gate wording. One render carried both "Informational — these findings do not block the tool" and "Force-approving bypasses the scanner gate", about the findings behind that 409. The first sentence was false — its source read scan_informational.go's "drives NO gating whatsoever" as a claim about the findings, when it is a claim about the trigger. The panel receives neither quarantined nor the effective trust mode, so it now claims nothing about this server's state, only what is unconditionally true of the tier. The dialogs stop saying "the scanner gate", a phrase the UI never defined. And ScanReport's Force Approve confirmation counted the wrong number — raw summary.critical while its own gate predicate reads finding_counts.dangerous — so it offered to bypass the gate "despite 0 critical finding(s)" for the two findings the backend was refusing on.

Not changed: detect/checks/shadowing.go. Making the clone branch endpoint- or identity-aware is a maintainer decision. Nothing here touches isBlockingFinding, ApproveServer, the quarantine default, or the force path — security posture is unchanged. Also not fixed: after force-approving a duplicate, the original server flips to dangerous on its next scan and the two permanently accuse each other. The add-time note only reduces how often a user gets there.

Two cross-branch defects, visible only once the branches were merged together

  1. A live probe found a quarantined stdio server with a nonexistent command reporting status=error, connected=false, yet health.level came back healthy with action approve (the calculator returns early on Quarantined), and every fault alert on the page is deliberately suppressed while quarantined (Web UI: quarantined server shows a red "file a bug report" error panel above its own calm quarantine banner #1076). Net effect: the Tools tab said "approve the server to list them" and nothing contradicted it — approving would hand the user a second failure. The copy now names the connection blocker only when the server is actually disconnected; a connected quarantined server keeps the original sentence, pinned by a control test.

  2. The Go change above makes a missing log file an empty success. The macOS tray's loadLogs() read its on-disk fallback only from catch, so that empty success silently skipped the fallback and dropped historical local logs from the view. It now falls back on an empty result too.


Verification

  • go build ./..., go vet ./internal/... — clean
  • golangci-lint run --config .github/.golangci.yml ./internal/... (v2, the config CI uses) — 0 issues
  • go test ./internal/server/ -skip "E2E|Binary|MCPProtocol" — ok, 196s
  • cd frontend && ./node_modules/.bin/vitest run — 1,247 tests / 119 files pass
  • npm run type-check (vue-tsc --noEmit) — clean
  • Driven live on a built instance, including the exact audit shapes: a quarantined http upstream (page reads Blocked / Quarantined for review / Tools withheld for review), a connected fixture server with no log file (200 {"logs":[],"count":0} where it previously returned the 500), and the duplicate-endpoint add against a backend returning exactly the count the confirm now quotes.

Every behavioural change is backed by a test that was run and observed to fail first, and the new specs each carry assertions that passed before the change so they are not vacuous. Several were additionally bite-checked by neutering the fix in the working tree and confirming a red suite — that check is what caught two hunks (the toolsEmptyHeading change and the approve-dialog paragraph) that had no assertion behind them at all and were free to drift back.

No API payload field changed, so no contracts.ts regeneration and no make swagger (make build re-run: zero diff). No new dependencies.

🤖 Generated with Claude Code

Dumbris and others added 16 commits September 6, 2026 13:10
Audit F11: typing "React documentation" into the Web UI search returned
nothing, while "documentation" returned Context7. The page then said only
"No matching tools", which cannot be told apart from a typo, an empty
catalogue, or a server still sitting in quarantine.

Two causes, both in frontend/src/views/Tools.vue:

1. The filter matched the WHOLE query as one contiguous substring of a
   single field (name OR description OR server_name). "react documentation"
   is never a substring of any one field, so it matched nothing. Now each
   whitespace-separated term is matched independently: AND across terms, OR
   across fields. This is a strict superset of the old behaviour -- if the
   whole query was a substring of a field, so is every one of its terms --
   so no result that matched before can disappear. Still a substring filter,
   NOT BM25: see "deliberately left alone" below.

2. The empty state was generic. When a search ran it now names the query,
   the scope it actually covered (how many tools, across how many servers,
   and whether disabled tools were in scope), a concrete next step ("try
   fewer words"), and -- only when there are any -- how many quarantined
   servers are not listed here, with a link to Servers. Quarantined servers
   contribute zero tools to GET /api/v1/tools (internal/httpapi/server.go,
   #1064) and quarantine is on by default for new servers, so a first-run
   developer was searching a silently truncated catalogue with no hint of it.
   The non-search empty states are unchanged apart from dropping the words
   "or search query" from a branch that only renders when the search box is
   empty.

Larger than the one-line filter fix, and deliberately so: reporting
stats.total would have LIED whenever another filter was also narrowing the
list (click the "Disabled" stat card, then search, and it would claim to
have searched the whole catalogue). So the filter chain is split into
searchScope (every filter except the search box) and filteredTools (the
search applied to it). All the filters are ANDed, so moving the search to
the end leaves the result identical; the empty state's numbers are now
literally true. Same reason for suppressing the "disabled tools included"
clause when a status filter excludes them.

Assumption documented per zero-interruption: a whitespace-only query used
to match every tool whose description contains a space; it now matches
everything (terms is empty, [].every() is true). More sensible, and not a
case anyone types deliberately.

Verified:
- cd frontend && ./node_modules/.bin/vitest run tests/unit/tools-search-multi-term.spec.ts
  -> 8 passed. Run BEFORE the fix it was 4 failed / 2 passed, failing for the
  right reasons: the multi-term query rendered no tools table, and the
  tools-empty-search block did not exist. The two that passed pre-fix are the
  no-regression cases (single-word query still matches; a query with one
  unmatched term still excludes), so the spec is not vacuous.
- cd frontend && ./node_modules/.bin/vitest run -> 109 files, 1183 tests passed.
- cd frontend && npm run type-check -> exit 0, no output.
- cd frontend && npm run build -> exit 0 (the INEFFECTIVE_DYNAMIC_IMPORT notice
  is pre-existing and concerns stores/auth.ts, untouched here).
- go build ./... -> exit 0. No Go package changed, so no go vet / golangci-lint
  run was applicable.

Deliberately left alone:
- Wiring the UI to BM25. The UI does not call /api/v1/index/search at all --
  api.searchTools() in frontend/src/services/api.ts is dead code, orphaned when
  1ce385c folded the /search view into Tools. Pointing the box at the ranked
  index is a product decision (that endpoint returns a different population and
  none of the columns this table renders, and it would not compose with the
  server/status/risk/approval chips), so it is reported, not built.
- api.searchTools() is NOT deleted even though the frontend no longer uses it:
  the endpoint is still consumed by cmd/mcpproxy-tray and cmd/release-gate, and
  removing it is unrelated churn.
- The ranking algorithm (internal/index/bleve.go) and every backend gate. The
  quarantine exclusion is untouched -- this change only NAMES it.
- Header placeholder "Search tools, servers..." still only searches tools;
  recommended as a follow-up, not fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…w server

UX audit F07. Adding a remote HTTP server showed a disabled Docker isolation
toggle that can never apply, a permanently-disabled "Coming soon" placeholder,
and then dropped the user back where they started — while the activation work
(connect, scan, review, approve) waited on a page they had to find themselves.

WHAT CHANGED

1. AddServerModal: the Docker Isolation toggle is now `v-if="type === 'stdio'"`
   instead of rendered-with-`:disabled`. Isolation is structurally impossible
   for a URL upstream — internal/config/isolation_resolve.go resolves
   IsolationSourceNotStdio whenever Command is empty — so the disabled control
   was truthful but pure noise. This is PRESENTATION ONLY: both existing state
   guards stay untouched (the watcher on formData.type that clears `isolated`,
   and the submit-side write that lives inside the stdio branch), so
   isolation_json still cannot reach an http add. The URL/stdio field groups
   were already protocol-conditional; nothing else in the form is.

2. AddServerModal: removed the "Idle on Inactivity" toggle. JUDGMENT CALL, and
   the one place I went past "hide" to "delete" — this is not a feature being
   removed. It was permanently `disabled`, labelled "Coming soon", and the only
   three references in the repo (v-model, form-state field, reset) were all
   inside this one file; handleSubmit never read it and no backend field of
   that name exists anywhere in internal/. It is exactly the "options described
   as future behaviour" the audit flagged. Reverting this hunk alone is safe if
   the placeholder is wanted as a roadmap signal.

3. Post-add hand-off. The modal's `added` event now carries the server name
   (`emit('added', formData.name)`) and the three navigating consumers —
   Dashboard.vue, Servers.vue, TopHeader.vue — push to serverDetailPath(name),
   which is the page that already renders the quarantine banner, the scan
   summary and the Approve action.

   The navigation deliberately lives in the CONSUMERS, not in handleSubmit:
   OnboardingWizard.vue also listens to `added`, and a push from inside the
   modal would unmount the wizard (Dashboard.vue mounts it) mid-flow, possibly
   before its markServerCompleted() lands. The wizard's handler is unchanged
   and keeps its zero-arg signature and its own completion toast.

   The bulk/import path (handleImport) keeps its bare `emit('added')`. A bulk
   import has no single destination, so an absent name is what keeps those
   consumers refreshing the list in place, exactly as before.

   Dashboard.vue returns before its generic "Server Added" toast on the
   navigating path. That is not tidying-while-here: the modal already toasts
   "<name> has been added successfully", toasts are rendered globally, so the
   second one would have followed the user onto the detail page. The import
   path still gets that toast.

ASSUMPTIONS DOCUMENTED (zero-interruption policy)
- Navigating away from /servers after an add is a behaviour change on that
  surface too, not only on the dashboard: the user loses the list they were
  reading. That is the requested hand-off, applied consistently.
- serverDetailPath() is used rather than a hand-built path or params, because
  MCP-1112 (#598) records that an unencoded '/' in an official-registry name
  splits the route and falls through to the 404 catch-all.
- The emit is order-sensitive and commented as such: handleClose() blanks
  formData.name, so moving the emit below it would silently publish '' and push
  consumers at "/servers/". A test pins this.

VERIFIED
- frontend/tests/unit/add-server-modal-protocol-and-handoff.spec.ts (new, 7
  tests) and frontend/tests/unit/add-server-handoff-consumers.spec.ts (new, 3
  tests) were written first and RUN first: 5 of 7 and 2 of 3 failed against
  unmodified source with real assertion failures ("expected 'Enabled...Docker
  Isolation...' not to contain 'Docker Isolation'", "expected [] to deeply
  equal [ 'fs-server' ]", "expected 'servers' to be 'server-detail'"), not
  compile errors. The consumer spec was re-run against a stashed-source tree to
  confirm it bites on HEAD.
- cd frontend && ./node_modules/.bin/vitest run — 110 files, 1185 tests passed.
- cd frontend && npm run type-check — clean.
- cd frontend && npm run build — clean (vue-tsc + vite).
- go build ./... — clean. No Go, no API payload, no contracts.ts, no swagger
  regeneration: this change touches only Vue templates and handlers, so `go
  vet` and golangci-lint have nothing new to look at and were not run for
  Go changes that do not exist.

DELIBERATELY LEFT ALONE
- The trust-mode prose (TrustModeSelector.vue, utils/trustMode.ts). The audit
  called it verbose and it is, but spec 088 FR-002 deliberately requires each
  mode to state BOTH what it does to tool changes and what it does at
  admission. Shortening or collapsing it is a product decision with a
  security-relevant blast radius, not a UI patch.
- Form layout, field order, spacing, styling. The isolation block is hidden in
  place; nothing is reordered.
- The Import tab, its preview/validation, and the canonical-path quick import.
- OnboardingWizard.vue and its "Review and approve from the Servers page"
  toast — the wizard owns its own completion contract.
- Quarantine defaults, trust_mode derivation and the isolation_json payload
  shape. No security, auth or scope predicate is weakened or even read.
- The existing assertion at add-server-trust-mode.spec.ts:104 that the Docker
  toggle is present: it mounts with the stdio default, so it stays green and
  was not "fixed".
- The Playwright web-UI sweep was NOT run (it needs a live core; the e2e script
  blanket-kills other sessions' instances). Its Add-Server tab-trap test is
  unaffected by construction: it filters `input:not([disabled])`, so dropping a
  disabled input cannot change its count, and it never switches protocol or
  submits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ound-trip

UX audit F13. The wizard's Verify tab showed "Round-trip verified" — a green
finish-line state — off a single boolean, `first_mcp_client_ever`. That flag is
stamped from the MCP `initialize` hook (internal/server/mcp.go AddAfterInitialize
-> RecordMCPClientForActivation), i.e. from a transport handshake, before any
tool is listed let alone called. All three suggested prompts targeted mcpproxy's
own built-ins, which are recorded as `internal_tool_call` and never stamp
`first_real_tool_call_ever` — a backend test already pins that for
retrieve_tools. So a user who had never seen an upstream tool run was told the
product was verified.

WHAT CHANGED (frontend only, three files)

- OnboardingWizard.vue Verify tab now shows two separately-labelled milestones
  instead of one:
    1. "AI client connected" (same condition as before, honest words) — states
       the handshake proves the wiring, and says so explicitly while the second
       milestone is outstanding.
    2. "First upstream tool call" (new, `data-test="verify-first-upstream-call"`)
       — satisfied from `first_real_tool_call_ever`. Pending renders as the same
       neutral 📡 idiom the tab already used, never as an error.
- One suggested prompt added, leading the list, that actually dispatches to an
  upstream (retrieve_tools + call_tool_read), so at least one prompt exercises
  what milestone 2 measures. The other three are unchanged.
- The new state is read from `GET /api/v1/status`, which ALREADY serves the whole
  activation block to an admin caller (internal/httpapi/server.go: omitted only
  for scoped agent tokens, pinned by scope_round11_test.go). No backend change,
  no `make swagger`, no contracts.ts regeneration, no new config field.
  api.ts `getStatus()` is only widened, optionally, to admit what the endpoint
  already sends; its two existing callers are untouched.
- Fetched in the existing `onOpened()` Promise.all and refreshed on the existing
  5s Verify-tab poll. No new timer.

DELIBERATE WORDING: "ran through mcpproxy and returned a result", not
"succeeded". `recordRealToolCallSuccess()` (internal/server/mcp.go:2613) is
called one statement BEFORE the isError classification at :2621, so an upstream
answering isError:true still sets the flag. The copy must not overclaim twice.

DELIBERATE FAILURE DIRECTION: a missing/failed `activation` reads as "not yet".
`fetchActivation` swallows its own errors — load-bearing, because the five
pre-existing wizard specs mock `@/services/api` with an explicit method list
that has no `getStatus`, and an unguarded throw would abort `onOpened()`.

LEFT ALONE ON PURPOSE

- The Verify tab's green tick (`tabs[].complete`), `incomplete_tab_count` and the
  sidebar badge still key off the handshake only. Tying them to the upstream call
  would turn an informational state into a gate and leave headless / LAN-server
  installs permanently badged — the opposite of the "don't block a user who only
  wants the proxy running" constraint. Nothing here gates anything; the wizard
  still closes from every tab.
- No new wizard tab or step; no restyle of the panel, prompt cards or activity
  list; no SSE.
- Milestone 2 renders only once milestone 1 is satisfied. Assumption made without
  asking (zero-interruption): before any client has connected, "no upstream tool
  call yet" is trivially implied and only adds noise to the existing
  "Waiting for your first request" empty state.
- KNOWN BACKEND GAP, NOT FIXED HERE: `first_real_tool_call_ever` has exactly one
  call site — the call_tool_* variant handler. Real upstream calls made through
  the direct tool surface (mcp_routing.go emitActivityToolCallCompleted) or as
  code_execution sub-calls never stamp it, so users who changed routing mode will
  see milestone 2 stuck at "not yet". Bounded: the default routing mode is the
  stamping path, and the row gates nothing. It is a separate defect needing its
  own Go tests; folding it in would blow the diff budget. Filed as follow-up.
- Rejected: deriving the state from the already-loaded recent-activity list
  (`type === 'tool_call'`). It holds only the last 5 rows and the log is pruned
  at 7 days / 10 000 records, so the row would flicker and then silently regress.
  The lifetime BBolt flag is the only correct source for an "ever" state.

VERIFIED

- New spec at frontend/tests/unit/onboarding-wizard-verify-states.spec.ts (the
  only path the runner executes). Written first; all 7 cases failed against the
  unmodified component for the right reasons — the panel really rendered
  "Round-trip verified", neither milestone row existed, and the prompt list had
  no upstream-dispatching entry. Green after the change.
- Bite-checked the anti-regression assertion by neutering the conditional caveat:
  1 failed / 6 passed, then restored.
- Full frontend suite: 109 files, 1182 tests passed.
- `npm run type-check` (vue-tsc --noEmit) clean; `npm run build` clean;
  `go build ./...` exit 0. No Go files touched, so no go vet / golangci-lint
  package to run.
- Not run: the Playwright web-ui sweep. It only locates [data-test="close-wizard"]
  to dismiss the wizard and never opens the Verify tab, so it would prove nothing
  about this change. The vitest spec is the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-model review (opencode gpt-6-astra) findings on 74cfd74.

1. The new empty state gave advice for the wrong control whenever the other
   filters had already reduced the scope to nothing. Reproduced from a live
   mount: with an all-enabled catalogue, click the "Disabled" stat card and
   then search, and the page rendered

     No tools match "kubernetes" Searched 0 tools across 0 servers.
     Every word has to match — try fewer words.

   Nothing was searched, so no wording of the query can help; the status
   filter is the cause. The zero-scope case now says so instead. The
   reviewer independently enumerated the same boundary (searchScope.length
   === 0) across the filter/quarantine combinations.

2. The rationale comments in Tools.vue and the spec cited "react
   documentation" as the example the fix repairs. It does not: no field of
   the sample catalogue contains "react", so that query returns nothing
   before and after. Replaced with "context7 documentation", which is the
   case the test actually asserts -- terms spanning server_name and
   description, matched by neither field alone.

3. The multi-word zero-result test asserted only that the table was absent,
   which an implementation rendering nothing at all would also satisfy. It
   now asserts the explanatory empty state is reached and quotes the query.

New guard added: "does not blame the query when the other filters left
nothing to search". Verified to bite -- removing only the new v-if leaves it
the single failure in the file (1 failed / 8 passed).

Verified:
- cd frontend && ./node_modules/.bin/vitest run tests/unit/tools-search-multi-term.spec.ts
  -> 9 passed.
- cd frontend && ./node_modules/.bin/vitest run -> 109 files, 1184 tests passed.
- cd frontend && npm run type-check -> exit 0. npm run build -> exit 0.
- go build ./... -> exit 0. frontend "npm run lint" is a no-op stub in this repo.

Review findings rejected, with reasons:
- "A character-splitting predicate would pass the suite." Argument about a
  hypothetical implementation, not a defect in this one; no realistic
  regression writes that.
- "Tests 1/2 prove only that echo's DESCRIPTION is absent, not its row." The
  table renders {{ tool.description || '—' }} in a cell, so the absence of
  that string does establish the row is gone.
- "'2 tools' would also match '12 tools'." No such string is reachable from
  the fixture; test 7 already anchors the phrasing as "1 tool across 1 server".
- "Test 5 does not prove the count exceeds one." The number comes from
  serversStore.serverCount.quarantined, a store getter with its own coverage.
- "Both fixtures are enabled, so the 'disabled tools included' clause is
  asserted as a label, not as behaviour." True, but adding a disabled fixture
  shifts the scope counts asserted in two other tests; churn out of proportion
  to the risk on a claim that is about the filter, not the data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g is blind

Review round on 34f4047. That commit split the Verify tab's dishonest
"Round-trip verified" into two honest milestones — but the second milestone
reintroduced the same class of untruth in the opposite direction, and its own
commit message says so: `first_real_tool_call_ever` is stamped at exactly one
site (internal/server/mcp.go:2613, the call_tool_* handler), so under
routing_mode=direct or code_execution — neither of which stamps it — the new row
told a user who had made dozens of real upstream calls "No upstream tool call
yet", forever. Shipping a green claim off a handshake and shipping a red claim
off a blind flag are the same defect wearing different colours.

The backend gap stays open (it needs Go tests and a separate diff). What changes
here is that the UI stops making a claim it cannot substantiate.

WHAT CHANGED (same two frontend files; no Go, no swagger, no contracts.ts)

- `firstRealToolCallEver` becomes tri-state `boolean | null`. `null` is "we
  cannot tell": no activation block on the wire, or the fetch failed.
- New `routingMode` ref, read from the SAME `GET /api/v1/status` response that
  already carries `activation` — no extra request, no new timer. The Go handler
  resolves it (servedRoutingMode, internal/httpapi/server.go:1160), so it is
  always one of retrieve_tools | direct | code_execution.
- New `upstreamCallState` computed with three outcomes:
    satisfied — the flag latched. A truthful LIFETIME fact under any routing
                mode, so it is still shown after a mode switch.
    pending   — flag false AND routing_mode is the one that actually stamps it.
    unknown   — everything else. The row does not render at all, and the
                milestone-1 caveat ("It does not yet mean a tool has run") is
                withheld with it, since that phrasing asserts the same negative.
- `fetchActivation` no longer collapses a missing `activation` block to false.
  The flag is monotonic in the core, so once seen true it early-returns rather
  than letting a later poll that omits the block un-latch a reached milestone.
  routingMode is assigned BEFORE that return, so it never goes stale.

RENDERED AND READ, all five states:
  default mode / not yet   → pending row, caveat shown (truthful)
  default mode / done      → "First upstream tool call …", caveat retired
  direct mode / flag false → no row, no caveat  ← the fix
  direct mode / latched    → satisfied row (lifetime fact still true)
  no activation block      → no row, no caveat  ← the fix

VERIFIED

- Spec grows 7 → 11 cases, still at frontend/tests/unit/ (the only path the
  runner executes). The 4 new/rewritten cases cover direct + code_execution
  blindness, the latched-then-omitted poll, and a failed fetch.
- Bite-checked, not assumed: reverting the component to 34f4047 fails exactly
  those 5 cases; reverting to main fails all 11. Separately confirmed the
  absence-only assertion is not vacuous — neutering the milestone block so it
  renders nothing fails 6 of 7 on empty-DOMWrapper, not silently passes.
- Full frontend suite: 109 files, 1186 tests passed.
- `npm run type-check` (vue-tsc --noEmit) clean; `npm run build` clean;
  `go build ./...` exit 0. No Go touched.
- Cross-model review (opencode, gpt-6-astra) raised the blind-routing-mode false
  negative and the activation-block reset independently; re-review of this diff
  returns no blocking findings.

REJECTED FROM THAT REVIEW

- Making the suggested prompts mode-aware. The three pre-existing prompts are
  annotated with default-mode tool names too; that is a pre-existing issue and
  fixing the whole list is outside a minimal review round.
- Guarding fetchActivation's assignment with the onOpened() `seq` staleness
  check. Every other fetcher in this component (fetchClients,
  fetchRecentActivity) assigns without it; the flag is monotonic, so a reordered
  response cannot produce a wrong terminal state, and the 5s poll re-converges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-model review (opencode gpt-6-astra) caught a TOCTOU in the new
post-add hand-off: handleSubmit snapshotted the name into `serverData`,
awaited `serversStore.addServer(serverData)`, then re-read the reactive
`formData.name` to build the `added` payload.

The name input carries no `:disabled` binding and Cancel stays live while
`loading` is true, so the field is editable across that await. Typing in
it mid-flight made the modal emit a name the request never carried, and
the consumers push straight at it — landing the user on ServerDetail's
"Server not found" for a server that was never created, while the one
they did create sat unopened.

Emit the `serverData` snapshot instead. This also dissolves the fragile
ordering coupling to `handleClose()` (which blanks `formData.name`), so
the guard test is reworded to state what it actually protects now.

Test bites: 'emits the name that was SENT, not one edited while the add
was in flight' fails on the previous commit with
`- "alpha" / + "beta"` while asserting the request carried 'alpha'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s "absent"

UX audit F08/F10/F12, all on the server detail screen. One theme: the page
said "nothing here" in three places where the truth was "deliberately held
back", "intentionally off", or "nothing was written". Copy and one error
branch; no security predicate, endpoint or contract is touched.

F08 — Tools tab empty state (frontend/src/views/ServerDetail.vue)
A server quarantined at admission is connected:false and its tools are kept
out of both the state snapshot and the search index by design
(internal/runtime/tool_quarantine.go). `GET /servers/{id}/tools` therefore
returns [], and the tab rendered the generic "No tools available / Server
must be connected to view tools." — the same words a broken server gets —
while the Security tab was asking the operator to review those very
definitions. Blaming the disconnection is doubly misleading: the
disconnection is part of the quarantine. The empty state is now
quarantine-aware ("Tools withheld for review") with a button through to the
Security tab, reusing the existing openSecurityTab handler. Same markup and
classes; two strings and one button.

The sentence deliberately names NO count. `quarantine.pending_count` counts
tools awaiting review, which is not the count of tools being withheld:
QuarantineServer (internal/runtime/lifecycle.go) purges the index but keeps
the server's existing approval records, so quarantining a trusted server
with 20 approved tools and 2 newly discovered ones reports pending_count 2
while all 22 are withheld. No payload field carries the withheld total for a
quarantined server, and a sentence naming the wrong number is the exact
class of bug this commit exists to remove.

F10 — Health and Admin-state tiles (frontend/src/views/ServerDetail.vue)
internal/health/calculator.go deliberately reports a quarantined or disabled
server as level "healthy" ("quarantined is intentional, not broken"). The
Health tile prints the LEVEL, so it rendered a green "Healthy" on precisely
the servers no client can reach — and could pair it with a "Sign-in
required" sub-line, a combination only reachable on a quarantined/disabled
server. The tile's one word now leads with the admin state ("Blocked" /
"Off"); the observed health keeps its own vocabulary on the sub-line, which
already prints health.summary. Neither reads text-success any more (both
fall to the existing neutral default, so healthLevelTone needed no edit).
The hard-coded "set by you" caption under Admin state was false for the
DEFAULT quarantine-on-add path, so a quarantined server now reads "awaiting
your review".

F12 — missing per-server log file (internal/server/server.go)
GET /api/v1/servers/{id}/logs returned HTTP 500 "log file not found …
(server may not have run yet)" for any server with no log file. That claim
is false: the file is created lazily by lumberjack and level-gated
(internal/logs/logger.go), so a connected, healthy server that logged
nothing at or above the configured level has no file at all — on any
transport, not just remote ones (internal/upstream/core/client.go creates a
per-server logger for every upstream). The ENOENT branch now returns an
empty (non-nil, so it marshals as []) result and the UI falls into its
normal empty state, whose sentence now says what the file actually holds and
points at Activity for tool calls. Every genuinely broken case — unknown
server, path escape, unreadable file — still errors.

Assumption documented per the zero-interruption rule: F10's tile change
re-mixes the two axes the earlier F11 fix separated. The admin fact wins the
tile's single word because that word is the answer to "can my client use
it?", and the health fact is not lost — it stays on the sub-line directly
beneath. The narrower alternative (fix only the colour, keep the word
"Healthy") would have left the tile saying the opposite of the truth.

Verified
  go build ./...                                                  clean
  go vet ./internal/server/...                                    clean
  golangci-lint (v2, .github/.golangci.yml) ./internal/server/...  0 issues
  go test ./internal/server -run TestGetServerLogs_ -v            2 passed
      (and again under -race)
  go test ./internal/server -skip 'E2E|Binary|MCPProtocol|…'      ok 184s
  go test ./internal/httpapi/...                                  ok
  frontend vitest run (whole suite)                    110 files / 1188 passed
  frontend npm run type-check                                     clean
  scripts/run-web-smoke.sh (Playwright, with mcpfixture)  30 passed, incl.
      "server detail opens and exposes the security tab"
  Live, isolated core on :18099 with a quarantined http upstream (the exact
  audit shape: quarantined, connected:false, quarantine:null) — page reads
  "Blocked / Quarantined for review", "Quarantined / awaiting your review",
  "Tools withheld for review", and the CTA opens the Security tab; a
  connected fixture server with 2 tools and no log file returns
  200 {"logs":[],"count":0} where it previously returned the 500.

Cross-model review (opencode, github-copilot/gpt-6-astra) plus a local
bite-check of every new behaviour found three things, all fixed here:
  * the `toolsEmptyHeading` change had NO positive assertion anywhere —
    reverting it to a constant left the suite 13/13 green. Both quarantined
    cases and both non-quarantined controls now assert the heading text.
  * `pending_count` could understate the withheld set (above); the count is
    gone, and a test pins that the sentence contains no digit at all even
    when the payload carries pending/changed/blocked counts.
  * the "Degraded/Unhealthy" preservation control only exercised Unhealthy;
    it now covers both, so the new admin-state switch sitting in front of
    the level switch cannot mask either.
Rejected in review: keying the new copy off `adminStateLabel` rather than
`server.quarantined` (both derive from the same serverStatus.Quarantined —
runtime.go:2465 vs :2616, server.go:1510 vs :1452 — so they cannot
disagree), and dropping the Logs paragraph as out of scope (F12 IS the logs
finding).

Each fix was driven by a test that was run and observed to fail first: the
Go test failed with the literal "server may not have run yet" error, and the
two frontend specs failed on 'Healthy' != 'Blocked' / 'Healthy' != 'Off' and
on the missing data-test hooks. Every new behaviour was then re-checked by
neutering it in the working tree and confirming a red suite: heading,
uncounted sentence, CTA, admin-state caption, health-tile switch, and the Go
ENOENT branch each turn at least one test red when reverted. Both frontend
specs also carry assertions that PASSED before the change (the
enabled-healthy tile, the disconnected and connected non-quarantined copy),
so they are not vacuous.

Deliberately NOT done
- Not unhiding the held-tool banner/list for a server-quarantined upstream.
  frontend/src/utils/toolQuarantine.ts:32 returns [] on purpose, documented
  and covered by tests/unit/tool-quarantine-banner.spec.ts. Reversing it is
  a product decision (it would offer per-tool Approve buttons that leave the
  server blocked anyway), not a bug fix.
- Not adding an endpoint to surface the scanned definitions. For a server
  quarantined at admission and never approved they are genuinely
  unretrievable: approval records are never written (the only writer,
  checkToolApprovals, sits behind the serverEligibleForIndexing guard in
  internal/runtime/lifecycle.go), and the scanner's tools.json is a deleted
  temp dir. Populating that surface needs either persisted scan definitions
  or a REST twin of MCP inspect_quarantined — new security surface, so a
  spec, not this patch. The copy now tells the truth in the meantime.
- Not touching the Tools-overview Enabled/Pending-Approval counters
  (frontend/src/views/Tools.vue, internal/httpapi/server.go) — that is the
  overview page, not this screen.
- Not touching FlaggedToolsPanel's "Not in the current tool list" label; for
  a quarantined server it is literally accurate.
- Not changing the per-server log level gate or making upstreams log more.
  Growing every user's log directory to fix a wording bug is the wrong
  trade; the wording was the bug.
- No contracts.ts edit, no `make swagger`: no API payload field changed, and
  the handler's annotation already documented 200 + GetServerLogsResponse.
- No test for the Logs empty-state sentence: it is a static string on a
  pre-existing, unconditional branch with no logic to bite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… log fallback

Two findings from reviewing the four merged UX branches together — neither is
visible from any single branch, which is why the per-branch reviews missed them.

1. A live probe against a built instance: a quarantined stdio server whose
   command does not exist reports status="error", connected=false and
   last_error="Command ... not found on the spawn PATH", yet health.level
   comes back "healthy" with action "approve" — internal/health/calculator.go
   returns early on Quarantined before any connection check. Every fault alert
   on the server page is also deliberately suppressed while quarantined
   (issue #1076), because mcpproxy dials quarantined servers for scanning and
   that attempt reliably leaves a spurious error behind.

   The net effect was that the Tools tab said "approve the server to list
   them" and nothing else contradicted it. Approving hands the user a second
   failure. The copy now names the connection blocker and points at
   Configuration, only when the server is actually disconnected — a connected
   quarantined server keeps the original sentence, pinned by a control test.

   Deliberately NOT changed: calculator.go. Moving the connection checks ahead
   of the quarantine branch would return admin_state=enabled and replace the
   approve action, which is wrong; and the Configuration → Health card renders
   the raw payload on purpose (it is the same shape the macOS tray consumes),
   so it should keep showing what the backend actually said.

2. The merged Go change makes a missing per-server log file return 200 with an
   empty list instead of an error. The macOS tray's loadLogs() read its
   on-disk fallback only from `catch`, so that empty success silently skipped
   the fallback and dropped historical local logs from the view. It now falls
   back on an empty result too.

Verified: the two new frontend tests fail on the pre-fix tree (the target for
the right reason — the control passes both ways, and a first attempt failed on
a bad selector and was corrected rather than left green); full frontend suite
1221 passed / 116 files; vue-tsc clean; `swift build` in native/macos/MCPProxy
completes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(audit F06)

Behind the "Authentication Required" modal every surface still renders, and
three of them stated as fact things the UI had never been able to read. On one
fresh install, unauthenticated vs authenticated at the same moment:

  before, unauthenticated  Setup ✓ | MCPPROXY stopped | 0 connected | 0 tools
  authenticated, same box  Setup 2 | MCPPROXY active  | 0 connected | 0 tools

The "Setup ✓" was the worst of the three: `incompleteTabCount` reads
`state?.incomplete_tab_count ?? 0`, so "never fetched" and "nothing left to do"
both arrive as 0, and since nothing ever moves `state` off null this was not a
pre-fetch flash — it was permanent for any install whose fetch kept failing.
`onboarding.loading` cannot stand in for it: false before the first fetch and
false again after a failed one.

Three gates, all using flags that already existed:
- SidebarNav: `setupStateKnown` (`onboardingStore.state !== null`). Unknown gets
  the plain "Setup" label the incomplete branch already renders; pulse and badge
  were already gated on `setupIncomplete` and stay off.
- Dashboard overview counts: gated on `serversStore.loaded`, the flag added for
  exactly this distinction and set in the single `applyServerList` funnel that
  both the fetch and the SSE path go through. Unknown renders an em-dash.
- Dashboard run state: gated on `systemStore.status !== null`, the same shape as
  the `dockerStatus` / `quarantineEnabled` rows 40 lines below. `isRunning`
  falls back to false when status is null, so a demonstrably running proxy was
  labelled "stopped" in error red.

Also corrects the auth screen's recovery instructions, which are worse than
useless when stale. Verified each against the code and by running them:
- `mcpproxy doctor` never prints the API key -- `cmd/mcpproxy/doctor_cmd.go` has
  zero matches for apikey/api_key, and a live run confirms it. Replaced with
  `mcpproxy status`, whose "Web UI" line is a ready-to-open URL with the key
  embedded even without `--show-key`, plus `--web-url` for the URL alone.
- The startup log carries the key in full only on the run that generated it
  (`main.go` `if wasGenerated`); later starts log `api_key_prefix` masked.
- The tray item is "Open Web UI in Browser" (macOS) / "Open Web Control Panel"
  (Go tray, Windows), not "Open Web UI"; and the macOS status item has a menu
  assigned, so a plain click opens it -- "right-click" was misleading.

Documented assumptions (no human asked, per the zero-interruption rule):
- Unknown drops the tick rather than getting its own affordance (skeleton, muted
  dash, "locked" treatment). Dropping it is the honest minimum; inventing a new
  state for the sidebar is a product call, not a bug fix.
- The unknown run-state row is hidden entirely rather than showing a placeholder,
  matching the two sibling rows that already do this.
- The bullet about `~/.mcpproxy/mcp_config.json` does not add the caveat that a
  key from `MCPPROXY_API_KEY` is never written to the file: whoever set that env
  var already has the key, and the `mcpproxy status` half of the same bullet
  covers them.
- This is a user-visible change to a shipped Spec-046-v2 surface: the ✓ now
  appears slightly later (once the state is actually known) rather than
  immediately on mount.

Deliberately NOT changed: the Spec-046 rule that "Close for now" (engaged) shows
✓ while tabs are still outstanding -- that is the documented engagement design
for headless/LAN installs, not an F06 unknown-state bug.

Contrary to the investigation brief, the Go/Windows tray does NOT open an
unauthenticated Web UI: `OpenWebUI` takes the URL from `/api/v1/info`, socket
callers get `auth.AdminContext()` (non-anonymous), and
`buildWebUIURLWithAPIKey` therefore appends the key server-side. Verified live:
`/api/v1/info` returns `.../ui/?apikey=<key>`. The bullet is left promising an
authenticated window for both trays, because that is what both do.

Tests: new frontend/tests/unit/first-load-unknown-state.spec.ts asserts all
three states per surface (unknown / known-good / known-bad) so a harness that
never resolves the fetch cannot pass it vacuously. Proven red first against
unmodified code with behavioural failures, not missing selectors:
  expected 'Setup✓' not to contain '✓'
  expected 'AI Agents ...' not to match /0\s*connected/
  expected 'AI Agents ...' not to contain 'stopped'
auth-single-surface.spec.ts had frozen the stale `mcpproxy doctor` string; its
assertion now names `mcpproxy status` and forbids `doctor`.

Verified: full frontend suite 1228 passed (115 files), npm run type-check clean,
go build ./... and make build exit 0, plus a live isolated instance on a scratch
data-dir confirming both the unauthenticated and authenticated renders above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anner gate contradicting itself

UX audit F09. Adding a second upstream at an endpoint that is already
configured (a second `https://mcp.context7.com/mcp` under a new name) is
accepted in silence, and the first the user hears of it is the scanner
reporting the NEW server as `dangerous`: two entries on one endpoint expose
byte-identical tool names AND descriptions, so
detect.shadowing.cross_server emits one hard-tier "possible impersonation"
finding per tool. The resulting Server Detail screen then said two opposite
things about those same findings.

Reproduced end to end on a scratch instance (127.0.0.1:18521, own --data-dir
and --config, killed afterwards): a single POST /api/v1/servers of the
duplicate produced `status: dangerous, finding_counts {dangerous: 2}` from
the automatic admission baseline scan, unprompted, on a config that listed
only the original. POST .../security/approve without force returned
`409 server has 2 dangerous (hard-tier) finding(s)`.

Two halves, both frontend-only. No backend, no contract, no `make swagger`
(re-ran `make build`, which runs generate-types + swagger: zero diff).

1. Add-time observation. AddServerModal's manual tab now names the configured
   server that already holds the endpoint being typed, as a neutral note.
   Submit stays enabled; nothing is merged, reused or blocked. Matching is
   exact string equality after trim (url for http; command AND the full args
   list for stdio) — case folding, trailing-slash normalisation and query
   stripping are each a judgement call about what "the same endpoint" means
   and none has been made, so the rule catches the real case (a copy-pasted
   URL) and cannot accuse the wrong server. It can MISS: the server list masks
   credential-shaped url/command/args values (internal/oauth/serverfields.go),
   so a secret-bearing endpoint never matches. A hint, not a guarantee.

   ASSUMPTION, documented rather than asked: this covers the WEB-UI DOOR ONLY.
   The MCP `upstream_servers add` handler, the CLI and the import path still
   say nothing — covering them needs a warning field on the add response
   (generator + swagger + a second implementation), which is no longer a diff
   a reviewer reads in minutes.

2. Gate wording. One render carried both "Informational — these findings do
   not block the tool." (FlaggedToolsPanel, mirrored in FindingChip) and
   "Force-approving bypasses the scanner gate." (approve dialog) — about the
   findings behind the 409.

   The first sentence was false. Its source, non-goal 9 of
   docs/research/tpa-scanner-inline-ui-design.md, read scan_informational.go's
   "drives NO gating whatsoever" as a claim about the FINDINGS; it is a claim
   about the TRIGGER (the baseline scan neither quarantines nor auto-approves),
   and the same comment says the verdict is stored "through the normal
   scan-summary path" — exactly what ApproveServer blocks on. It is false again
   under `trust_mode: scan`, where a non-clean verdict holds the individual tool
   (tool_quarantine.go, held_reason `scan_findings`).

   The panel receives neither `quarantined` nor the effective trust mode, so it
   now claims nothing about this server's state — only what is unconditionally
   true of the tier: "Dangerous (hard-tier) findings block server approval."
   The soft-tier chip keeps its "review-only (soft-tier)" reassurance, which is
   where the reassurance was actually wanted. The richer variant — pass the two
   facts in as props and render one of three sentences — is a new component
   contract and a copy decision, so it was NOT taken; the withdrawn non-goal
   records it as the open option.

   The dialogs stop saying "the scanner gate", a phrase the UI never defined,
   and say what force approval does: "skips the scan-based approval gate and
   unquarantines this server". Worded without naming findings on purpose — the
   same paragraph renders in the no-scan mode, where there are none, and force
   skips that refusal too.

   ScanReport's Force Approve confirmation counted the wrong number: it read
   raw `summary.critical` while its own gate predicate reads
   `finding_counts.dangerous`, so it offered to bypass the gate "despite 0
   critical finding(s)" for the two findings the backend was refusing on. Both
   now share one computed, worded with the backend's own noun.

Deliberately NOT changed: detect/checks/shadowing.go. Making the clone branch
endpoint- or identity-aware is the identity-aware design the audit warned
about and a maintainer decision (the file header records the prior MCP-3520
decisions on this branch). Nothing here touches isBlockingFinding,
ApproveServer, the quarantine default, or the force path — security posture is
unchanged.

Also not addressed, and not fixed by this: after force-approving the
duplicate, the ORIGINAL server flips to `dangerous` on its next scan and the
two permanently accuse each other. The add-time note only reduces how often a
user gets there.

Verified: `cd frontend && ./node_modules/.bin/vitest run` 117 files / 1236
tests green; `npm run type-check` clean; `go build ./...` and `go vet
./internal/...` clean (no Go source touched); `cd website && ./prepare-docs.sh
&& DOCS_VERSION=v0.65.0 npm run build` succeeds and the corrected non-goal
renders in the published llms-full.txt (the research page itself is outside
the docusaurus include allowlist, so it has no HTML route). Live: the note
fires on the real modal with submit enabled, the panel and the dialog now
agree on one screen, and the confirm reads "2 dangerous finding(s)" against a
backend that returns exactly that count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…of F06)

Cross-model review (opencode gpt-6-astra) caught the one claim in the
rewritten auth modal that is still false: `(Windows: "Open Web Control
Panel")` attributes the Go tray's menu title to Windows.

Verified: internal/tray/tray.go carries `//go:build !nogui && !headless
&& !linux`, release.yml copies the built `mcpproxy-tray` into the darwin
tarballs (line 707), and the Homebrew formula installs it with
`bin.install "mcpproxy-tray" if OS.mac?`. So a macOS tarball or Homebrew
user sees "Open Web Control Panel", and only the DMG's Swift bundle says
"Open Web UI in Browser". Attributing one to Windows writes a fresh
false statement onto the screen this change exists to make honest — the
exact defect class of audit F06.

Both labels are now named without an OS claim, and the spec pins the
regression: both strings present, `Windows:` absent. Proven red against
the previous text (`expected … not to contain "Windows:"`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…swap

Review round 1 on 52cb1a6 (opencode gpt-6-astra + a reversion sweep over
every hunk). Three defects, each with a test that fails without the fix.

1. The stdio match ignored working_dir. handleSubmit SENDS working_dir on
   every stdio add, so it is part of the endpoint this form describes:
   `node server.js` in /projects/a and the same line in /projects/b are two
   different programs with two different toolsets. The note named one as
   "already configured at this endpoint" and claimed the scan would flag them
   as clones of each other — both false.

2. Neither branch checked the transport, so a populated-but-inactive field
   matched. Reproduced in the test: a stdio server carrying a stale `url`
   (an entry converted from http without deleting it) was named as the
   occupant of a typed HTTPS endpoint. The guard added is NEGATIVE — it
   excludes the opposite family only — because a positive
   `protocol === formData.type` filter would MISS the primary case: an
   existing remote entry is commonly recorded as `streamable-http`, `sse` or
   `auto` while this modal always sends `http`.

3. ScanReport's force-approve success toast still read "despite critical
   findings" — three lines below the confirm() the previous commit corrected,
   using the same severity bucket the gate never consults. Both sentences
   about one action now carry the gate's own count and noun.

Also: ServerDetail.vue's approve-dialog paragraph was the one changed hunk in
52cb1a6 with NO test behind it — reverting it to "Force-approving bypasses the
scanner gate." left all 1236 tests green, so the half of the fix that lands on
the screen F09 was reported against was free to drift back. Pinned at the
surface a user reaches (quarantined server, banner Approve, read the dialog).
The api mock in scan-report-force-approve-count.spec.ts stubbed
`securityApproveServer` (the STORE action) rather than `securityApprove` (the
api method), so the approve call was never exercised; corrected.

NOT taken, from the same review: renaming `hasUnresolvedCritical` (diff noise,
no behaviour), and wording the confirm's legacy fallback differently when
`finding_counts` is absent — that number is what the UI's own gate predicate
already treats as the blocking count, and no separate truth exists on a
payload that predates the field.

Verified: `cd frontend && ./node_modules/.bin/vitest run` 118 files / 1240
tests green; `npm run type-check` clean; `go build ./...` and `go vet
./internal/...` clean; `cd website && ./prepare-docs.sh && DOCS_VERSION=v0.65.0
npm run build` succeeds (175 documents) — docs/research has no HTML route
under the docusaurus include allowlist, and the corrected non-goal 9 reaches
build/llms-full.txt with the withdrawn wording inside its strikethrough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Cross-model review round 1 on the integrated branch (opencode
gpt-6-astra, six file-named briefs). Four of the six came back with
findings; all four are the SAME defect class this PR exists to close —
copy asserting something the code cannot substantiate — reintroduced by
the fixes themselves. Each is pinned by a test that was run against the
pre-fix tree and observed to fail with a behavioural assertion error.

1. ServerDetail — the withheld-tools caveat fired on every quarantined
   server. It was keyed on `!connected`, which is the DESIGNED state of a
   quarantined server, not evidence of a fault: the supervisor
   disconnects any quarantined server without an active inspection
   exemption and refuses to dial it (supervisor.go — ActionDisconnect on
   `Quarantined && !IsInspectionExempted`, ActionConnect gated on the
   same). So the default, healthy, quarantined-on-add case — the case the
   screen exists to serve — was told "approving it will not list tools on
   its own. Check the connection error under Configuration", and sent
   hunting for an error that does not exist, while approval genuinely was
   its whole remedy. Now keyed on `last_error`, and hedged to "may not be
   enough": a stale error from an earlier dial is not proof approval will
   fail. Configuration is still the right pointer because it renders
   `last_error` verbatim, which is where the #1076-suppressed fault is
   legible. This one is my own integration commit's defect, not one of
   the six branches'.

2. AddServerModal — the duplicate-endpoint note predicted a scanner
   verdict it cannot compute: "their tools will appear twice, and the
   security scan will flag each as a possible clone of the other".
   Endpoint equality establishes neither. A second entry may carry
   different credentials or a different OAuth identity — the legitimate
   reason to add one, as the note's own first line says — and expose a
   different toolset; and even on identical tools cloneDescriptions
   (detect/checks/shadowing.go) needs three tokens in BOTH descriptions
   plus 85%/70% overlap to fire, so short or empty descriptions never
   match. Both consequences are now conditional.

3. Tools — the zero-scope empty state had two causes and handled one. On
   an EMPTY catalogue (every server quarantined, or none connected: both
   return no tools from GET /api/v1/tools) it said "the other active
   filters excluded every tool ... Clear them to search the full list"
   with no filter set at all — advice that is both false and
   unfollowable. Split on `allTools.length`.

4. OnboardingWizard — "No upstream tool call yet" is a claim about the
   user that the flag cannot back even under retrieve_tools, the mode the
   `pending` state gates on. That mode still exposes code_execution
   (mcp_routing.go: "available but not the primary workflow"), whose
   sub-calls do not stamp the flag, and /mcp/all and /mcp/code are
   mounted unconditionally — "regardless of config" (server.go) — so a
   client aimed at the direct surface makes real upstream calls that
   never reach the stamping handler. Now "No upstream tool call RECORDED
   yet": a true statement about what mcpproxy measured. The same row also
   told the user "the prompts below search and inspect mcpproxy itself"
   while the very same change had added a first prompt that dispatches to
   an upstream — disclaiming the one prompt that satisfies the milestone.
   It now points at it.

Also corrected two assertions in onboarding-wizard-verify-states.spec.ts
that the reworded copy had made VACUOUS: they forbade the exact string
"No upstream tool call yet", which no longer exists in any state, so they
would have passed even with the pending row rendering. Re-pointed at the
live prefix. Both kept their `exists()` assertion throughout, which is
why the rename did not leave the file unguarded.

Comments-only, from the same review, no behaviour change: the tri-state
doc comment claimed a failed fetch yields `null`, which is true only of a
fetch that has never succeeded — a later failure deliberately keeps the
last value (the row would otherwise flicker off on every dropped poll,
and the 5s poll re-converges). Documented what the code does.

NOT taken: making that failed-fetch path actually revert to `null`. The
reviewer is right that the UI then shows a stale "not yet" during an
outage, but blinking the row off on every dropped poll is worse for a
state that gates nothing and self-corrects in 5s.

Bite-checked, not assumed: with the four source files reverted to the
pre-fix tree and the new assertions in place, exactly the 5 new tests
fail, each on a behavioural mismatch —
  expected 'ℹcontext7 is already configured at th…' to match /if they/i
  expected '📡No upstream tool call yet — the pro…' to contain 'recorded'
  expected 'Tools withheld for review…' to contain 'approve the server'
  expected 'Tools withheld for review…' to match /may not be enough/i
  expected 'No tools match "kubernetes" Nothing w…' to contain
    'There are no tools to search'
— not on missing selectors. Both ServerDetail cases also keep a control
that passed before and after (connected-quarantined, and the two
non-quarantined branches), so the new switch cannot mask either arm.

Verified: frontend vitest 119 files / 1252 tests pass (1247 + the 5 new);
npm run type-check clean. No Go, Swift, API payload or contracts.ts
touched by this commit.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0f508aa
Status: ✅  Deploy successful!
Preview URL: https://fcb92620.mcpproxy-docs.pages.dev
Branch Preview URL: https://integration-webui-ux-fixes.mcpproxy-docs.pages.dev

View logs

@Dumbris

Dumbris commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

The Windows build failure is pre-existing, not from this PR

Build Binaries (windows-latest, windows, amd64, …) failed in the Run tests step (the step summary points at "Build binary and create archives", which is misleading — the log ends FAIL → exit code 1 inside the test step). The four sibling matrix jobs that also show red were cancelled by fail-fast, not independently failing.

The single failing test:

--- FAIL: TestCompactLockReleaseDoesNotRemoveSomeoneElsesLock
FAIL	github.com/smart-mcp-proxy/mcpproxy-go/cmd/mcpproxy	14.968s

Not attributable to this branch: this PR changes no file under cmd/mcpproxy (git diff --name-only main...HEAD has zero matches there). The test arrived on 2026-09-05 in f95fc14 (#1214), and neither of the last two green main runs contained the file, so it has not yet run on Windows on main.

Root cause. acquireCompactLock stamps the lock with:

token := fmt.Sprintf("pid=%d\nstarted=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339Nano))

The test acquires A and B from the same process, microseconds apart. Windows' wall clock has ~15.6 ms granularity, so time.Now() returns the same value for both and token_A == token_B. releaseA() then finds a matching token and deletes B's lock, which is exactly what the test asserts must not happen. Linux and macOS have nanosecond-resolution clocks, so their tokens differ and the test passes — hence Windows-only.

A dedicated task is fixing this on main; it is deliberately not being fixed here to keep this PR scoped to the UX audit findings.

🤖 Addressed by Claude Code

Cross-model review round 2 (opencode gpt-6-astra), on the round-1 fixes
themselves. One finding, and it is the round-1 defect one level down: the
new empty-catalogue branch replaced a false claim about FILTERS with a
false claim about SERVERS.

"There are no tools to search — no connected server is currently exposing
any" is not what an empty list means. GET /api/v1/tools returns success
with `tools: []` and `partial: true` when a server's tool fetch FAILED —
internal/httpapi/server.go sets Partial/FailedServers on a genuine fetch
error and returns everything it could still gather. So on exactly that
payload the copy asserted "there are none" where the truth was "we could
not read them": the opposite diagnosis, and it contradicted the
partial-results warning already rendering at the top of the same page.

The sentence now states only the observed catalogue and adds the partial
case as its own clause instead of guessing a cause for it.

New test covers the shape the round-1 test did not: success + empty tools
+ partial + failed_servers. Bite-checked by deleting only the new
`v-if="partial"` clause — it is then the single failure in the file
(1 failed / 10 passed), on
  expected 'No tools match "kubernetes" There are…' to contain
    'could not be read'
The round-1 test grew an assertion that the plain empty catalogue does
NOT carry the partial clause, so the two branches pin each other.

Verified: frontend vitest 119 files / 1253 tests pass; npm run type-check
clean. Frontend-only.
@github-actions

github-actions Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: integration/webui-ux-fixes

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (21 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 34048133467 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Maintainer directive: a server whose command does not exist must show the
error. It did not.

CalculateHealth short-circuited on `input.Quarantined` and returned
level=healthy / summary="Quarantined for review" / action=approve, discarding
whatever the connection state said. The Web UI compounded it: issue #1076
suppressed EVERY fault alert on a quarantined server. Both were defensible in
isolation and wrong together — a quarantined stdio server pointed at a
nonexistent binary reported status="error" with a full spawn failure in
last_error, while the page showed a calm "approve me" and hid the diagnostic
panel. Approving such a server just fails again.

Being disconnected is NOT the signal, and an earlier attempt at this got that
wrong: the supervisor deliberately disconnects a quarantined server and
refuses to dial it (ActionDisconnect on `Quarantined && !IsInspectionExempted`,
supervisor.go:505), so connected=false is the designed state of every
quarantined server. A transport FAULT is different — the scanner dials them
under an inspection exemption, so a missing binary or dead host is genuinely
observable. The gate is therefore state=="error" && last_error != "".

The admin contract is deliberately unchanged: still admin_state=quarantined,
still action=approve, so the review flow, the tray's quarantine handling and
IsHealthy's ordinary-quarantine reading all keep working. Only the level and
the summary stop claiming the server is fine. This is why the connection
checks were NOT simply moved ahead of the quarantine branch — those arms
return admin_state=enabled and replace the approve action, which would be
wrong.

The UI change is the same narrowing: quarantineSuppressesFaultAlerts now
follows health.level instead of blanket-suppressing, so #1076's noise stays
hidden and a real fault surfaces.

Tests written first and confirmed failing for the right reason (level
"healthy" want "unhealthy"; empty detail; summary still "Quarantined for
review"). Both controls pass in both directions: an ordinary quarantined
server across states "", "disconnected" and "idle" stays healthy and calm, and
a disabled server with a stale error keeps its intentional reading.

Verified live on a built instance: broken-stdio now returns
level=unhealthy / admin=quarantined / action=approve with the spawn failure in
detail, while a working quarantined server is untouched at
level=healthy / "Quarantined for review". The page renders the full
MCPX_STDIO_EXIT_BEFORE_INITIALIZE panel with its cause, remediation, log-tail
fixer and docs link, and still shows the quarantine banner with Run scan /
Approve.

go test ./internal/health/ ok · ./internal/server/ ok 198s ·
golangci-lint v2 0 issues · vitest 1255 tests / 120 files · vue-tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Dumbris
Dumbris merged commit 4e304ba into main Sep 7, 2026
41 checks passed
Dumbris added a commit that referenced this pull request Sep 9, 2026
…Ready (#1228)

TestGetServerLogs_MissingFileReturnsEmptyNotError (#1216) flaked on a loaded
machine during the full `go test -race ./internal/server/...` run with
"server not found: never-logged", while passing every time in isolation.

CAUSE. newLogsTestServer registered the upstream client by calling
UpstreamManager().AddServerConfig at construction. Background initialization
then runs LoadConfiguredServers, which snapshots the manager's clients, diffs
them against cfg.Servers and removes the difference in goroutines. A client
added before that diff is on the removal list. On an idle machine the test
finished first; under load the removal landed in between.

The re-register-at-call-site pattern from diagnostics_fixers_test.go
(ensureFixerClient, #1218) is necessary but NOT sufficient here: it fixes
"server not found", but the removal's own "Disconnecting from server" line
creates the per-server log file this test asserts is absent, so the test
still failed 2/100 under -race with `Should be empty, but was [...]`. The
fixer tests never hit that because they seed the log file themselves.

FIX. Wait for the runtime to report PhaseReady before registering. The
phase flips only after LoadConfiguredServers has taken its snapshot, so a
client added afterwards is never on a removal list, and the supervisor's
periodic reconcile only removes names it found in a config snapshot. The
call-site re-registration (ensureLogsClient) is kept as well, mirroring the
fixer tests; re-registering an unchanged config keeps the existing client
and logs nothing. Assertions are unchanged.

REPRO. A 3s sleep between construction and the call fails on the old
fixture and passes with this change. Verified:
  go test -race -count=5 -run 'TestGetServerLogs_' ./internal/server  ok
  same with -count=50, twice, under 6 CPU hogs                       200/200
  golangci-lint (.github/.golangci.yml)                              0 issues
Dumbris added a commit that referenced this pull request Sep 24, 2026
…ecks (#1358)

Dashboard's usage-view first-run CTA used opacity-60 on inherited text
color instead of the themed base-content token, producing low-contrast
text against bg-base-200 in some themes. Switch to text-base-content/60.

The web-ui-sweep servers-list and Activity/Usage checks assumed a
non-empty fleet; a sweep run without a fixture upstream renders the
first-run empty states instead of the KPI tiles and usage tile
(#1049/#1216), which made those two checks fail. Anchor on whichever
state the instance should show and assert accordingly instead of
assuming a populated fleet.
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