fix(telemetry): stop four diagnostics instruments measuring themselves - #1218
Merged
Conversation
GetToolCount feeds the heartbeat's tool_count, which the activation funnel and every retention cut are keyed on. It read the upstream manager's per-client tool-count cache — but both indexing paths call InvalidateAllToolCountCaches() as their LAST step (lifecycle.go:466, :644), and the only writers that refill that cache without re-zeroing it are UI/API-triggered ListTools calls. So tool_count was largely reporting "has the owner opened the dashboard recently", not "has this install indexed any tools". That confound sits underneath two field headlines: the 1221-connected -> 914-with-tools funnel step, and the day-8-14 retention split (27.3% with 100+ tools vs 12.1% with none) — dashboard engagement would independently drive both. The Web UI and REST API were never affected: they sum the durable StateView.ToolCount, which reconcile preserves with a carry-forward guard (supervisor.go:681-683). Only telemetry read the zeroed value, which is why no user ever filed a "0 tools" bug. Now reads index.Manager.GetDocumentCount() — one Bleve document per tool, durable across restarts, and the only source that means what the field's name says. Per-profile indexes are derived from this shared index (RebuildProfileFromShared), so there is no undercount for profile users. The old cache path stays as a fallback when no index manager is wired. Test written first and confirmed failing on HEAD with got=0 for all three assertions: internal/runtime/tool_count_index_test.go indexes 3 tools and asserts 3, adds a 4th and asserts 4, deletes one server's tools and asserts 2. Verified: go test ./internal/runtime/ (112s, ok), ./internal/telemetry/ (ok), go vet, and golangci-lint v2 with .github/.golangci.yml — 0 issues. Note for whoever reads the dashboard: this changes what tool_count measures, so pre- and post-release values are not comparable. Gate the funnel query on the release that ships this and re-baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WHAT WAS WRONG
internal/diagnostics/builtin_fixers.go:7 says the advanced fixers "are
registered by higher layers at startup". Nothing ever did — the only
diagnostics.Register call outside the package was in
internal/httpapi/diagnostics_fix_test.go. So all four registered fixers
were the package's own placeholders:
- stdio_show_last_logs (builtin_fixers.go:17) returned outcome=SUCCESS
with the canned string "log tail unavailable in this build".
- config_migrate_deprecated (:33), server_disable_scanner (:47) and
oauth_reauth (:63) returned outcome=BLOCKED under execute.
Two of those are the only action their error codes offer, and they are
wide in the field:
MCPX_STDIO_EXIT_BEFORE_INITIALIZE 270 installs -> "Show last server log lines"
MCPX_STDIO_HANDSHAKE_TIMEOUT 315 installs -> "Show last server log lines"
MCPX_OAUTH_LOGIN_REQUIRED 208 installs -> "Sign in"
The stdio codes are exactly the ones whose captured stderr usually names
the missing binary or env var outright, so the button promised the single
most useful thing and delivered a fixed string. And because the
placeholder reported SUCCESS, telemetry's diagnostics.fix_succeeded_24h
was counting placeholder no-ops: it was measuring the instrument, not the
product.
WHAT CHANGED
New internal/server/diagnostics_fixers.go registers two runtime-backed
fixers, installed from NewServerWithConfigPath where both dependencies are
already in scope:
- stdio_show_last_logs -> (*Server).GetServerLogs(name, 50), the same
call backing GET /api/v1/servers/{id}/logs. The FixResult crosses the
REST API, so the text is masked by the identical scrubber that
endpoint uses: GetServerLogs runs every line through parseLogLine ->
scrubUpstreamText (issue #1148), which is why reusing that call rather
than reading the file directly is the whole point. A missing or
unreadable log now returns outcome=FAILED instead of a fake success.
- oauth_reauth -> Runtime.TriggerOAuthLogin, the same call
POST /api/v1/servers/{name}/login makes. dry_run still previews only.
The two config-mutating placeholders are deliberately untouched: writing
the user's config file from a one-click button is a larger design
question than wiring an existing read path.
GATING IS UNCHANGED (verified, not assumed)
- Destructive flags: every catalog entry that offers oauth_reauth marks
the step Destructive:true except MCPX_OAUTH_LOGIN_REQUIRED's
first-time "Sign in", where there is no stored credential to lose.
handleInvokeFix still returns 409 for a destructive fixer with no
explicit mode (internal/httpapi/diagnostics_fix.go:98) — covered by
the pre-existing TestDiagnosticsFix_ModeGuard, which still passes.
- Web UI: ErrorPanel.vue still renders Preview + Execute for a
destructive step and routes Execute through confirmAndExecute()'s
window.confirm(). Untouched by this commit.
- Duplicate sign-in: not re-implemented here, because the guard already
exists upstream. Client.handleOAuthAuthorization stands down when a
manual flow is in flight (internal/upstream/core/connection_oauth.go:922,
issue #975) and isOAuthInProgress refuses a duplicate, so a second
click surfaces as an error string, not a second browser tab.
- No security check or quarantine behaviour is touched.
THIS DOES NOT MAKE THE COUNTERS FIRE AUTOMATICALLY, ON PURPOSE
RecordFixAttempt has exactly ONE non-test call site — the fire-and-forget
block at internal/httpapi/diagnostics_fix.go:140, gated on mode==execute.
diagnostics.fix_attempted_24h / fix_succeeded_24h are therefore
human-click-only BY DESIGN. There is no broken auto-heal loop to repair;
an earlier analysis assumed one. Nobody should set a target on that ratio
or read a low fix_attempted_24h as a defect — it is a count of button
presses. What this commit changes is that a press now does something and
that a success now means something.
ASSUMPTIONS MADE (no human was asked)
- Rendering: the preview emits each entry's scrubbed Message only.
parseLogLine SYNTHESIZES Timestamp (time.Now()) and Level ("INFO") for
any line it cannot parse — which is every raw stderr line piped from
the child, i.e. the lines this button exists to show — so echoing them
back would fabricate data. For an unparsed line Message is the whole
original line, so nothing is lost.
- No truncation of the tail. scrubUpstreamText deliberately dropped the
activity-store cap for live reads (a long line is often precisely what
an operator opened the log for); the 50-line window is the bound.
- diagnostics.Register is process-global/last-write-wins. Production
builds one Server per process; in a test binary that constructs
several, the newest owns the fixers. That is the same contract the
package's own init() already had, and it is documented at the
registration site.
VERIFIED
go test ./internal/server/ -run 'TestDiagnosticFixer_' -count=1 -v
BEFORE the fix: 3 of 4 FAIL, for the right reason (package compiles;
failures quote the placeholder strings) —
"...log tail unavailable in this build..." should not contain
"unavailable in this build"
Not equal: expected "failed", actual "success" (missing log file)
"oauth re-auth fixer has not been wired to the OAuth coordinator in
this build" should not contain "has not been wired to the OAuth
coordinator"
(the 4th, the oauth dry-run guard, passes on HEAD by design)
AFTER the fix: 4/4 PASS (1.566s)
go test ./internal/server/ -race -run 'TestDiagnosticFixer_' -count=1 ok 3.575s
go test ./internal/server/ -count=1 -skip "E2E|Binary|MCPProtocol" ok 207.539s
go test ./internal/httpapi/ -run TestDiagnosticsFix -count=1 -v PASS (mode-guard intact)
go test ./internal/diagnostics/... -count=1 ok
go build ./... OK
go vet ./internal/server/... ./internal/diagnostics/... OK
gofmt -l <the three touched files> clean
/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml \
./internal/server/... ./internal/diagnostics/... 0 issues
git diff --stat: only the three intended files.
NOT RUN: ./scripts/test-api-e2e.sh. It blanket-pkills mcpproxy cores
belonging to other sessions on this machine, and this change adds no
route and alters no HTTP handler.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…_codes
diagnostics.error_code_counts_24h was measuring the instrument, not the
product. It was level-triggered:
supervisor.go:275 reconcile ticker, 30s
reconcile() -> updateSnapshot() -> updateStateView() for EVERY configured
server -> classifyAndAttach(...) -> notifyErrorCode(code), unconditionally
(supervisor.go:769/:811, and an identical pair on the event path :1098/:1117)
-> diagnostics_counters.go RecordErrorCode, which dedupes nothing
ConnectionInfo.LastError is sticky — the state machine clears it only on a
transition to Ready (upstream/types/types.go:301-303). So a server PARKED
awaiting an OAuth login, making zero connection attempts, emitted 86400/30 =
2880 "events" a day, and an install's number tracked failing-server count
times uptime rather than anything a user did. Field data corroborates:
MCPX_OAUTH_LOGIN_REQUIRED averaged 5,212 per install-day when non-zero, 484
install-days exceeded 2,880, and days above the tick cadence averaged 3.89x
it on installs with a mean of 18.8 configured servers.
WHAT CHANGED
1. Both stateview writers in supervisor.go now capture the previous
diagnostic code and RetryCount at the TOP of the UpdateServer closure,
before any mutation. View.UpdateServer (stateview.go:103-143) deep-clones
the snapshot and passes the previous status into the callback, so those
reads are last-pass values — verified by reading the clone loop, not
assumed. shouldNotifyErrorCode then propagates to notifyErrorCode only on
an edge: a different classified code, or a changed RetryCount. It compares
!= rather than > deliberately: RetryCount advances on each failed attempt
AND resets to 0 on a transition to Ready, so a decrease marks a new
failure episode after a recovery no stateview pass happened to observe.
Both writers share the same basis, which also collapses the duplicate
notification an event pass and a reconcile pass used to produce for one
failure.
2. classifyAndAttach is untouched and still runs on every pass, so the
STANDING diagnostic keeps rendering in the UI, REST API and CLI. Only the
telemetry notification became edge-triggered. Pinned by
TestUpdateStateView_StandingDiagnosticStillRendered.
3. notifyErrorCode also refreshes last_error_code via
prechurnStore.RecordLastErrorCode (runtime.go:3138-3145). I read that
implementation (telemetry/prechurn.go:80-84, :148): it "persists code as
the most recent diagnostic code, overwriting any prior value" — a
last-value field, not a counter. Edge-triggering preserves it correctly:
the last edge is still the most recent distinct code. The only behavioural
difference is that with several failing servers it no longer oscillates
between their codes every 30s, which is an improvement.
THE COMPANION (schema v11) — not optional
Edge-triggering alone DELETES the "installs currently affected" signal.
error_code_counts_24h decays (readCounterWithDecay) and carries omitempty,
and the whole Diagnostics object is dropped via isZero() + omitempty
(diagnostics_counters.go, telemetry.go:316). A permanently parked install
would emit once and then vanish from the payload entirely — trading an
inflated number for a MISSING one, which downstream reads as zero. That is
the same absent-counted-as-zero trap that has already produced one wrong
headline here.
So this ships diagnostics.current_error_codes in the same change: MCPX_ code
-> number of configured servers currently in that state, recomputed at
heartbeat time from the supervisor's live stateview
(Supervisor.CurrentErrorCodes), wired through a nil-safe provider like the
existing onboarding/IDE-count providers. It is joined into the snapshot
BEFORE the isZero() check, so an install whose only signal is "N servers are
broken right now" still emits a diagnostics object.
Anonymity: low cardinality (the ~30-code MCPX_ catalog), counts only, no
server names, URLs, commands or free text. Three independent guards, since
the map now originates outside internal/telemetry: the supervisor filters on
the MCPX_ prefix, sanitizeMCPXCodeMap re-filters shape/length/positivity at
assembly, and the anonymity scanner gained a structural rule (the spec-095
error_code_counts_24h check, generalised over both maps, catalog-registered
keys and non-negative ints, never echoing the offending key). MarshalJSON
applies the same top-20 cap. Only enabled, non-quarantined servers are
counted — a server the user disabled is not a problem the install has.
SchemaVersion 10 -> 11. v10-and-earlier error-code volumes are NOT comparable
with v11 ones and must not be graphed as one series; the const comment and
docs/features/telemetry.md both say so, and the docs page (which still said
v9) now documents both maps and the events-vs-standing-state distinction.
NOT COVERED, DELIBERATELY: RecordErrorCode has a second producer at
internal/server/update_failure.go:78 for the four MCPX_UPDATE_*_FAILED codes.
The gate added here lives in the supervisor and does not touch that path.
That is correct — those are genuine one-shot events (one failed update
attempt, one increment), not a standing condition being re-observed.
ASSUMPTION DOCUMENTED: "currently affected" excludes disabled and quarantined
servers. A quarantined server is deliberately not connected and a disabled
one is not wanted, so counting either would re-inflate the number in a
different way. Pinned by
TestCurrentErrorCodes_ExcludesDisabledAndQuarantined.
TESTS — written first, run first, confirmed failing for the right reason.
The tests drive sup.updateStateView(...) directly rather than reconcile():
MockUpstreamAdapter.AddServer builds a ServerState with a nil ConnectionInfo,
so a reconcile-driven test never reaches classifyAndAttach and would have
passed vacuously against HEAD. They also use typed errors
(fmt.Errorf("...%w", syscall.ECONNREFUSED), *net.DNSError) because plain
strings classify to MCPX_UNKNOWN_UNCLASSIFIED and the code-change assertion
would not have distinguished anything.
Before the fix:
$ go test ./internal/runtime/supervisor/ -run TestUpdateStateView_ -v
--- FAIL: TestUpdateStateView_ErrorCodeIsEdgeTriggered
unchanging error over 10 reconcile passes produced 10 notifications
([MCPX_HTTP_CONN_REFUSED x10]), want exactly 1
--- FAIL: TestUpdateStateView_RetryCountAdvanceReNotifies (got 3, want 2)
--- FAIL: TestUpdateStateView_CodeChangeReNotifies (got 3, want 2)
--- PASS: TestUpdateStateView_StandingDiagnosticStillRendered
$ go test ./internal/telemetry/ -run CurrentErrorCodes
--- FAIL: TestScanForPII_CurrentErrorCodesShapeViolations (7/7 subtests:
scanner accepted a server name / URL / uncataloged code / negative /
fractional / string / non-object)
After:
go build ./... ok
go build -tags server -o /dev/null ./cmd/mcpproxy ok
go vet ./internal/runtime/... ./internal/telemetry/... 0
go test ./internal/runtime/... ./internal/telemetry/... -count=1 all ok
go test ./internal/runtime/ -race -count=1 ok (170s)
go test ./internal/runtime/supervisor/... ./internal/telemetry/... -race
ok
go test ./internal/httpapi/... -count=1 ok
go test -tags server ./internal/serveredition/... -count=1 ok
golangci-lint run --config .github/.golangci.yml \
./internal/runtime/... ./internal/telemetry/... 0 issues
gofmt -l internal/runtime internal/telemetry clean
NOT RUN: ./scripts/test-api-e2e.sh — it blanket-pkills every running mcpproxy
core, which would kill sibling agents' instances on this machine. The change
is backend-internal (supervisor notification gating + heartbeat assembly)
with unit, race and assembly coverage above; nothing in the HTTP surface
changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ansport
The classifier's HTTP arms were gated on `hints.Transport == "http"`, an exact
string a large share of servers never carry. `transport.DetermineTransportType`
returns `config.Protocol` VERBATIM when it is set, and "streamable-http" when it
is not, so one transport reaches the classifier under four names that config
validation all accepts (internal/config/config.go:2465):
- "streamable-http" — a URL server added with no explicit protocol
- "http" — registry add path, Claude Code / Gemini importers,
the Add-Server modal
- "sse" — explicit legacy SSE
- "auto" — accepted by config, passed through by hints.For
Three of those four had every status, timeout and status-text arm switched off,
so their failures fell through to MCPX_UNKNOWN_UNCLASSIFIED and its "please file
a bug report" CTA. No test in internal/diagnostics used "streamable-http" or
"sse" at all, which is how it shipped.
I am deliberately NOT claiming a share of the UNKNOWN bucket for this. The
heartbeat carries no per-server protocol string, so the split between spellings
cannot be measured in-repo. The change stands on correctness — the same failure
must not classify differently because of how a server happened to be added — and
on cost: it adds no dependency, no allocation on the hot path, and no new retry
behaviour.
WHAT CHANGED
- diagnostics.CanonicalTransport folds the HTTP family ("http", "https", "sse",
"streamable-http", "streamable_http", "auto", "") to one value and passes
everything else through lower-cased. hints.For calls it, so every production
hint is normalized at the ONE place hints are built; classifyHTTP calls it
once per invocation so a hand-built ClassifierHints cannot reintroduce the bug.
classifyStdio's two gates now read the TransportStdio constant.
- matchHTTPStatusText also accepts the marker "status code: ". mcp-go's SSE
CONNECT path words its failure `unexpected status code: %d`
(client/transport/sse.go:293, verified in the pinned v1.0.0), where the digits
do not follow "status ". An SSE server's 401 or 502 at connect time therefore
matched nothing at all — on EVERY transport spelling, including "http".
- DiagnoseHTTPStatus maps 429 to a new MCPX_HTTP_RATE_LIMITED, and 400/408/409/
410/451 to a new generic MCPX_HTTP_4XX. The set is enumerated, not ranged: a
blanket 4xx range would claim the statuses that a higher layer resolves better
than a status number can (a 401 that is really a deferred OAuth sign-in, a 4xx
on the initialize POST that is really MCPX_HTTP_LEGACY_SSE). The exact status
reaches the user in DiagnosticError.Cause, which is the raw error text.
- Three typed branches ahead of the string matching:
errors.Is(ECONNRESET) -> new MCPX_HTTP_CONN_RESET (ungated, like the
ECONNREFUSED arm above it: a socket errno is evidence of a socket);
net.Error with Timeout() -> the existing MCPX_HTTP_TIMEOUT, which covers
`i/o timeout` and http.Client deadlines — NEITHER is context.DeadlineExceeded,
so the two existing arms could not see them;
errors.Is(context.Canceled) -> new MCPX_HTTP_CANCELED at severity info.
- No EOF/EPIPE branch. On stdio those are already rewritten into the "server
process exited before completing the MCP initialize handshake" shape by
internal/upstream/core/connection_lifecycle.go and classified as
MCPX_STDIO_EXIT_BEFORE_INITIALIZE; a second reading here would double-classify
the same failure.
RETRY BEHAVIOUR IS UNCHANGED. None of the four new codes is RetryPermanent. A
429 clears, a reset re-dials, a cancellation was ours, and the generic 4xx bucket
holds 408. They keep the zero RetryClass that MCPX_UNKNOWN_UNCLASSIFIED carries
today, so naming these failures changes the message a user reads and nothing
about whether mcpproxy keeps retrying them (GH #1145).
ASSUMPTIONS MADE (no clarification requested, per CLAUDE.md)
1. The cancellation code is named MCPX_HTTP_CANCELED, not a bare MCPX_CANCELED.
Both catalog_test.go's regex and the PUBLISHED contract
specs/044-diagnostics-taxonomy/contracts/catalog-schema.json require
MCPX_<DOMAIN>_<SPECIFIC> against a closed domain list; a domainless code fails
both. Inventing a new domain would edit a published contract for one code, so
I kept it in the HTTP domain and gated the branch on the HTTP family, which
keeps the name honest.
2. The empty transport joins the HTTP family. It reaches hints.For only when the
server config is unavailable (supervisor.go, Config == nil), i.e. when nothing
is known. That is safe because every arm this unlocks needs positive evidence
of an HTTP exchange — a status number in the text, a typed net error — so a
stdio-shaped failure still matches none of them.
3. My MCPX_HTTP_4XX fix step uses `mcpproxy upstream list -o json`. The obvious
copy source, MCPX_CONFIG_MISSING_SECRET, suggests `mcpproxy upstream get`,
which does not exist (the subcommands are list/logs/enable/disable/restart/
add/remove/add-json/patch/inspect/approve/tools/import). That pre-existing
wrong command is left alone as unrelated.
4. scripts/test-api-e2e.sh was NOT run: it blanket-pkills every mcpproxy core on
the machine, and sibling agent worktrees are live. This change touches no
route, payload or schema; the published-catalog surface it does touch is
covered by catalog_contract_test.go, which is in the run below.
VERIFIED
RED first, with the helper present so the failure was an assertion and not a
build error (a package that does not compile proves nothing):
$ go test ./internal/diagnostics/ -run TestClassify_HTTPFamilyTransportsAgree
--- FAIL: .../401_in_status_text/sse
--- FAIL: .../401_in_status_text/streamable-http
--- FAIL: .../401_in_status_text/auto
--- FAIL: .../wrapped_context_deadline/{sse,streamable-http,auto}
--- FAIL: .../sse_unexpected_status_code_marker/{http,sse,streamable-http,auto}
--- FAIL: .../429_rate_limited/{http,sse,streamable-http,auto}
--- FAIL: .../connection_reset_by_peer/{http,sse,streamable-http,auto}
i.e. the two rows the "http" column could already classify passed only in that
one column, and the three rows nothing could classify failed in all four.
Bite check on the source-level fix: reverting hints.For to pass the transport
through unchanged fails TestFor_CanonicalizesTransport with
`DetermineTransportType="streamable-http" ... want "http"` on exactly the rows
that were broken, while "explicit http" and every stdio row keep passing.
$ gofmt -l internal/diagnostics/ (no output)
$ go build ./... exit 0
$ go vet ./internal/diagnostics/... exit 0
$ go test -race ./internal/diagnostics/... -count=1
ok internal/diagnostics 1.600s / ok internal/diagnostics/hints 1.247s
97 top-level tests pass, 0 fail
$ golangci-lint run --config .github/.golangci.yml ./internal/diagnostics/...
0 issues.
$ go test ./internal/runtime/... ./internal/upstream/... ./internal/httpapi/...
./internal/health/... ./internal/telemetry/... -count=1
all ok. (One flake seen once under parallel load:
internal/upstream manager_docker_recovery_test.go "docker unavailable:
signal: killed"; passes on its own, `docker info` exit 0, and the change
touches no Docker path.)
Guards added beyond the required table, because widening the family moves the
HTTP arms AHEAD of classifyOAuth for the newly-included spellings:
TestClassify_OAuthKeepsPrecedenceOverHTTPStatus pins that a typed
ErrOAuthPending (Code() fast path, wrapped with %w through "all authentication
strategies failed") and a stringified OAuth message still classify as
MCPX_OAUTH_LOGIN_REQUIRED on all four transports — a sign-in prompt misreported
as MCPX_HTTP_401 would send the user hunting for a credential bug instead of
clicking "log in". TestClassify_LegacySSEKeepsPrecedence and
TestClassify_StdioTransportUnaffected pin the other two precedences.
Docs: the four new codes get real docs/errors pages plus website/sidebars.js
entries, so their fix-step links resolve. scripts/check-errors-docs-links.sh
still fails on the SAME 8 pre-existing gaps as on HEAD (MCPX_HTTP_TIMEOUT,
MCPX_DOCKER_CLI_NOT_FOUND, MCPX_DOCKER_OCI_RUNTIME, MCPX_STDIO_SPAWN_EXEC_FORMAT
and the four MCPX_UPDATE_*) and on none of the new codes; closing those is out of
scope here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to faefdd0, from cross-model review (opencode gpt-6-astra). Two findings were genuine and are fixed here; both are about the same thing — faefdd0 changed stdio_show_last_logs from returning a one-line placeholder to returning a ~50-line log tail, and nothing downstream was sized for that payload. 1. The tail was undeliverable to the user it was written for. ErrorPanel.vue puts a fix's `preview` into a toast, and ToastContainer.vue rendered the message in a plain div: default HTML whitespace handling folded all 50 newlines into one run-on paragraph, and the toast auto-dismissed after 5s. The button's entire product is text to read, so it shipped unreadable. - ToastContainer: whitespace-pre-wrap + break-words on the message, with a bounded scrollable height so a long payload cannot run off the viewport. Single-line toasts render identically to before. - ErrorPanel: a multi-line fix message gets a 60s dwell instead of the 5s default. The toast already has a close button, so the longer dwell is dismissible. One-line outcomes keep the 5s default. 2. The preview had no byte bound. diagnosticsLogTailLines caps the line COUNT only; a child MCP server may print a ~60KB blob per line (the only ceiling is bufio.Scanner's 64KB token limit inside GetServerLogs), so 50 such lines would be a multi-megabyte notification. New diagnosticsPreviewMaxBytes (8KB) drops the OLDEST lines — a tail is read bottom-up, the newest line is the one that explains the failure — and states how many it dropped and where to get the rest. The newest line always survives whole, however long. Individual lines are still never truncated, which is what #1148's scrubUpstreamText decision was actually about. Also closes a vacuous-pass gap the reviewer found in faefdd0's redaction test: asserting only that two secrets are ABSENT from the preview would pass for an implementation that never rendered the two secret-bearing lines at all. The test now pins the non-secret remainder of both lines first, so "the secret is gone" can only mean the scrubber removed it. Verified each new assertion bites: reverting the cap fails BoundsThePayload; reverting either frontend change fails two of the three new frontend specs. Reviewer findings deliberately NOT acted on, with reasons: - "GetServerLogs may exceed the caller's 15s deadline; the context is discarded." Per-server logs are lumberjack-rotated at 10MB (internal/logs/logger.go:33), so the full-file read is tens of ms. TriggerOAuthLogin returns as soon as StartManualOAuth launches its goroutine. Neither fixer blocks. - "FailureMsg discloses an absolute log path." Exact parity with the existing GET /api/v1/servers/{id}/logs handler (internal/httpapi/server.go:3741), on the same API-key-gated surface. Not a new disclosure class. - "diagnostics.Register is process-global; a second Server rebinds the fixers." True and already documented in the file. Production builds one Server per process, no test in internal/server calls t.Parallel(), and each fixer test constructs its server immediately before invoking. Pre-existing, untouched, worth knowing: a single log line over 64KB makes GetServerLogs fail outright (bufio.Scanner token limit), which the REST logs endpoint shares. The end-of-file-fixer pre-commit hook added a trailing newline to ToastContainer.vue, which had been missing one before this change. Not run: ./scripts/test-api-e2e.sh (blanket-pkills other sessions' cores; no route or handler changed) and the Playwright web-UI sweep. Frontend is covered by type-check, build, and 1178 passing unit tests including the 3 new ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e wire gate Cross-model review (opencode gpt-6-astra) on cfb06ef found that sanitizeMCPXCodeMap enforced a strictly WEAKER predicate than the ScanForPII gate that runs downstream on the same bytes: producer: len<=64 AND ^MCPX_[A-Z0-9_]+$ (shape only) wire: len<=64 AND ^MCPX_[A-Z0-9_]+$ AND diagnostics.Has(code) ScanForPII does not redact the offending key — it drops the ENTIRE heartbeat, logs an error, and bumps the anonymity-violation counter. So an MCPX_-shaped but uncataloged code reaching the provider would have silently destroyed every heartbeat for as long as that standing state persisted. The provider (supervisor.CurrentErrorCodes) prefix-matches "MCPX_" only, so the catalog term was the one that mattered and it was the one missing. Not reachable today — all 44 declared diagnostics codes are registered — but the function exists precisely to be the backstop for a future caller, and as written the backstop was the outage. Switch it to isValidMCPXCode, which is the same predicate scanDiagCodeMap applies, so producer and wire form can no longer disagree. Tests: TestSanitizeMCPXCodeMap gains the uncataloged-but-well-shaped case, plus TestSanitizeMCPXCodeMap_MatchesScannerPredicate, which pins the invariant directly — anything sanitize keeps must be something the wire scanner accepts. Both verified to fail against the old predicate (and the package still builds when neutered, so the failure is the guard biting, not a broken build). Also documents, at shouldNotifyErrorCode, the three sampling imprecisions the review surfaced and that this change accepts: coalesced failures and recovery-ABA under-count, stale reconcile observations over-count. All are bounded by the observation cadence and are orders of magnitude below the ~2880/day/server level-triggering they replace; closing them needs failure identity at the source, which is out of scope. Fixes the stale "current version is 10" comment in payload_v7_test.go left by the v11 bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-2 cross-model review finding on the previous commit, verified and
accepted.
Giving a multi-line fix preview a 60s dwell created an accumulation
problem the 5s default hid. The toast stack is `position: fixed` anchored
to the bottom of the viewport with no height bound, so it grows upward:
three or four tall previews raised inside one dwell window push the
earliest ones past the top of the screen, close button included, and a
scrollbar inside a message cannot bring that button back.
Fixed at the source rather than on the shared container: ErrorPanel keeps
the id of the long-lived preview it last raised and removes it before
raising the next, so at most one tall preview per panel is alive at a
time and a new log tail supersedes the one it replaces. Ordinary
single-line outcome toasts are untouched and still expire on their own.
Bounding the shared stack instead (max-height + overflow on the daisyUI
`.toast` container) was rejected: that container is the transition-group
root, and clipping it would break the slide-out leave animation for every
toast in the app to fix a problem this feature created.
New spec asserts three clicks inside the dwell window leave exactly one
toast; verified it fails when the supersede is removed.
Also confirmed in review, no change needed: the message renders through
Vue's `{{ }}` interpolation, so backend-controlled log text is escaped —
no injection path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s own status Cross-model review (opencode, gpt-6-astra) of the transport-family change found three defects. All three reproduce; each fix has a bite check. 1. CanonicalTransport folded the EMPTY transport into the HTTP family. The empty string is reachable in production — internal/runtime/supervisor passes transport=="" whenever state.Config is nil — and the claim that every arm it unlocks needs "positive evidence of an HTTP exchange" was simply untrue for three of the four: a wrapped context.DeadlineExceeded, its stringified form and context.Canceled are transport-agnostic, and the status-text arm reads mcpproxy's stdio exit wrapper, whose attached stderr TAIL routinely quotes a status the CHILD process saw. Measured, with "" in the family: a stdio handshake timeout returned MCPX_HTTP_TIMEOUT, a canceled stdio connect returned MCPX_HTTP_CANCELED, and "exited before completing the MCP initialize handshake; recent stderr: request failed with status 503" returned MCPX_HTTP_5XX. "" now passes through, which is exactly the pre-change behaviour for that path; the four HTTP spellings still fold together, which was the point. 2. matchHTTPStatusText scanned one whole MARKER across the string before trying the next, and "status " is a strict prefix of "status code: ". A status quoted later therefore outranked the one the transport reported: "unexpected status code: 503; body: upstream request failed with status 401" answered MCPX_HTTP_401. The scan is now positional, left to right, longest marker first at each position. 3. The scan also continued past an UNRECOGNISED status, which is the same bug one level down: "request failed with status 402: status 401" answered MCPX_HTTP_401 — "fix your credentials" for a billing failure. It now stops at the first well-formed status TOKEN and reports that token's code, empty included. A run of four digits is not a token, so "status 4011" no longer reads as 401 and keeps scanning. Both phrasings can share one message because mcp-go interpolates the raw response BODY into `request failed with status %d: %s` (streamable_http.go:641, sse.go:630), so this is the shape the parser must survive, not a contrivance. Also, not from the reviewer: - docs/errors/README.md is the sidebar's category landing page and had not been given the four new codes, so they appeared in the sidebar but not in the index. - The MCPX_HTTP_4XX comment described the bucket as everything EXCEPT 400/408/409/410/451; those five ARE the bucket. - The 429 message promised "it will retry after the wait the server asked for". RetryAfterTransport parks only when a parseable Retry-After header is present (internal/transport/retry_after.go:181); without one the normal backoff ladder applies. Reworded to say that. Rejected: an ECONNRESET/ETIMEDOUT wrapped in an OAuth-phase message losing its OAUTH code — no production path emits that shape, and the ungated ECONNREFUSED arm it mirrors predates this change. Rejected: MCPX_HTTP_CANCELED at info severity hiding a repeating fault — no consumer branches on diagnostic severity for a health verdict (internal/health/calculator.go reads connection state), the frontend already renders info, and both the message and the docs page flag the repeat case. Verification: gofmt clean; go build ./... OK; go vet OK; golangci-lint v2 (.github/.golangci.yml) 0 issues; go test -race ./internal/diagnostics/... 453 pass / 0 fail; supervisor, managed, health, httpapi and transport all ok. check-errors-docs-links.sh fails on the same 8 pre-existing pages as main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…decline-research-d382b2
…ecline-research-d382b2
…ecline-research-d382b2
Maintainer directive 2026-09-06: use GPT-6 Astra for both code and spec/plan review; gpt-5.6-sol drops to fallback. Verified reachable the same day via `opencode run --model github-copilot/gpt-6-astra`. Also records the two invocation rules that cost time before: stdin must be closed and the call wrapped in gtimeout, and an empty result is a FAILED review rather than a clean one — opencode exits 0 with no verdict when a read is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 34261332753 --repo smart-mcp-proxy/mcpproxy-go
|
… zero
Cross-model review (opencode, gpt-6-astra) of the edge-triggering change
returned six findings. Two are genuine and fixed here; four are rejected
below with reasons.
1. UNBOUNDED COALESCING, not the bounded kind the comment promised.
The edge basis was (classified code, ConnectionInfo.RetryCount). But
types.StateManager.SetOAuthError increments oauthRetryCount and NOT
retryCount (upstream/types/types.go:726 — types.go:183 already carries a
comment about that split for a different reason). A server failing OAuth
over and over therefore holds the same code AND the same RetryCount across
every attempt, so it edged exactly once and then went silent forever.
MCPX_OAUTH_LOGIN_REQUIRED is the widest code in the field, so this was not
a corner.
The comment claimed all three imprecisions were "bounded by the observation
cadence". For this one that was false, which is the same class of defect as
the level-triggering being fixed: an instrument describing itself wrongly.
Fixed by adding ConnectionInfo.LastRetryTime to the basis. Every failure
setter stamps it with time.Now() (SetError:347, SetTerminalError:400,
SetPendingAuth:465, SetOAuthError:726); nothing else advances it, and
re-observing a sticky error does not touch it — so a fresh value means a
fresh ATTEMPT and reconcile's re-reads stay silent. It uses a field the
stateview already carries (status.LastErrorTime), so no new state, no new
payload field.
Two ABA routes the reviewer found also close as a side effect, both of
which needed no dropped event: a TryReconnectSync Reset()+Connect() that
lands back on the same (code, RetryCount), and a recover-then-refail read
through a state fetched after the queued connected event.
Tests: TestUpdateStateView_OAuthRetriesReNotifyDespiteFrozenRetryCount
(three attempts, RetryCount frozen at 0, want 3 — fails with got=1 when
the time term is neutered, package still building) and
TestUpdateStateView_StickyAttemptTimeDoesNotReNotify (10 reconcile passes
over one unchanged failure, want 1) which pins that this did not re-open
the level-triggering. The doc comment now lists the real imprecisions,
including a new one this term introduces: one attempt routed through two
failure setters can stamp twice, bounded at a small constant per attempt.
2. v10 COUNTS WERE ABOUT TO BE RELABELLED AS v11 ONES.
The per-code counters live in a 24h sliding window persisted in BBolt, and
nothing about the schema bump touched the key namespace. An install
upgrading mid-window would have kept its v10 LEVEL-triggered counts — the
~2880/day/server this change exists to remove — and gone on adding v11 edge
increments to them, under the v11 schema label, for up to a day. The bump
says v10 and v11 are not comparable; that is worthless if v11's first day
IS v10 data.
diagKeyCodePrefix moves from "code_count_24h_" to "code_edge_count_24h_".
Neither is a prefix of the other, so the cursor scan cannot pick up a
legacy key. The orphans are inert, bounded by the catalog (~44 entries),
and decay-stale within a day.
Test: TestDiagnosticsCounterStore_LegacyLevelTriggeredCountsAreNotAdopted
seeds a live v10 record of 2880 and asserts the v11 snapshot is empty, then
that one RecordErrorCode gives exactly 1. Reverting the constant fails it
with "v10 level-triggered count leaked into the v11 payload:
MCPX_OAUTH_LOGIN_REQUIRED=2880". docs/features/telemetry.md says the
counters restart at zero on upgrade and why.
REJECTED, with reasons:
- "Producer and scanner predicates disagree on zero counts, and MarshalJSON
caps at 20 while scanDiagCodeMap does not." Correct as stated, and the
reviewer says so itself: both are producer-side RESTRICTIONS. The invariant
that matters is one-directional — anything sanitize keeps, the wire scanner
must accept — and it holds. A producer that emits strictly less than the
gate allows cannot cause the heartbeat drop this guards against.
- Three further ABA/staleness variants (stale reconcile replay; event-path
refail before a queued connected event; Reset/recreate). The last two are
closed by the LastRetryTime term above. The first is an over-count already
documented at shouldNotifyErrorCode and inherent to diffing two sampled
projections; closing it means giving failures a monotonic identity at the
source, which is a supervisor-wide change and out of scope. The comment now
states it without the false boundedness claim.
Verified:
go build ./... and -tags server OK
gofmt on the four touched files clean
golangci-lint v2 (.github/.golangci.yml) telemetry + supervisor 0 issues
go test ./internal/... -skip "E2E|Binary|MCPProtocol" 64 packages
ok, one failure: TestResolveDockerStatusResolvableAndWorking, which
passes -count=3 in isolation and on origin/main — `docker info` timing out
under full-suite parallel load, the same shape as the known
manager_docker_recovery_test flake. Not related to this change.
Deploying mcpproxy-docs with
|
| Latest commit: |
035599d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a543e50a.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://claude-new-user-decline-rese.mcpproxy-docs.pages.dev |
Round-2 cross-model review (opencode, gpt-6-astra) on the classifier change. One finding, verified and accepted; it also falsifies a claim I made when rejecting a related finding in 43fdd1c ("the frontend already renders info" — it does not). MCPX_HTTP_CANCELED is the first and so far only severity=info entry in the spec-044 catalog (registry.go:304). ServerDetail's gate accepted only warn/error, with the comment "Info-level diagnostics are ignored (shown only in verbose/admin views, per spec)". Two things are wrong with that: - Spec 044 says no such thing. FR-002 only requires a severity; FR-011 is about the macOS TRAY indicator, not the detail view. I grepped the spec for the rule and it is not there. - The exclusion hid nothing. `showDiagnosticPanel=false` falls through to the `v-else-if="server.last_error"` branch — the generic red "Server Error" box that prints the raw error string with no user message, no fix steps and no docs link. So a cancellation (a shutdown, a config reload, a manual disconnect) was rendered LOUDER than a warn-level fault and lost its explanation on the way. ErrorPanel already handles info correctly and always has — alert-info, badge-info, the neutral "Diagnostic" header (ErrorPanel.vue:163-182) — so the defect is in the gate, not the component. Widened to accept info. The generic box is a v-else-if, so the red duplicate disappears with it. Blast radius is exactly one code: nothing else in the catalog is info. The issue #1076 quarantine guard is untouched and still runs first. New spec frontend/tests/unit/server-detail-info-diagnostic.spec.ts mounts ServerDetail with a non-quarantined server carrying last_error AND the info diagnostic, and asserts the panel renders with severity "info" and the user message, and that the generic red box does NOT. Second case re-pins the quarantine suppression with the same payload. Reverting the gate fails the first with "expected false to be true"; the second keeps passing, which is what tells you the guard is independent. Verified: npx vitest run 110 files, 1181 tests, 0 fail npx vue-tsc --noEmit exit 0 the three neighbouring specs (error-panel, server-detail-quarantine-error- panel, diagnostics-fix-log-tail-toast) pass alongside the new one.
…ol_count alive at shutdown
Cross-model review (opencode, gpt-6-astra) of the fixer and tool_count
changes returned nine findings. Four are genuine and fixed; five are
rejected below.
FIXED
1. The Sign in button skipped the write gates. fixOAuthReauth called
Runtime.TriggerOAuthLogin directly. POST /api/v1/servers/{id}/login does
NOT — it goes through the management service, whose checkWriteGates
refuses when read_only_mode or disable_management is set
(internal/management/service.go). The diagnostics route's own middleware
checks caller authorization, not those config gates, so an authorized
diagnostics request could start an OAuth flow on an install whose owner
had turned management off. Now calls the same
TriggerOAuthLoginQuick the REST handler does, and FAILS CLOSED if that
service is unavailable rather than falling back to the ungated call.
Test: TestDiagnosticFixer_OAuthReauth_RespectsWriteGates, both gates,
asserting the refusal names the gate. Reverting to the Runtime call fails
both subtests with "the fixer started a sign-in while X was set".
2. It claimed a browser window had opened whether or not one had.
StartManualOAuth returns as soon as its goroutine launches, and the
launch can fail afterwards. TriggerOAuthLoginQuick returns an
OAuthStartResult carrying BrowserOpened/BrowserError, so the message now
says what happened and prints the URL to finish manually. Still a
success — the flow did start — but not a fabricated one. Given the
placeholder this file replaced reported success for doing nothing, a
success that means something is the whole point.
3. The 8KB preview cap did not bound the preview. The line loop measured
only log lines; the rendered payload also carries a header and an "older
lines omitted" notice, both interpolating the server name. A tail whose
lines filled the cap shipped ~8.25KB. The budget now subtracts that
framing (computed with len(entries) for both counts, so the reservation
is always >= the actual). The one documented way past the cap remains the
single newest line, which is never dropped or truncated.
Test: TestDiagnosticFixer_StdioShowLastLogs_CapCountsItsOwnFraming sizes
the fixture so an unbudgeted loop keeps exactly one line more than a
budgeted one; it fails at 8253 bytes without the fix. The pre-existing
BoundsThePayload assertion also tightened from "< 3x the cap" to "<= the
cap" — a slack multiple was not asserting the cap at all.
4. tool_count would have reported 0 in the shutdown heartbeat. Close()
closed the index BEFORE telemetryService.Stop(), and Stop performs the
graceful final heartbeat flush. GetDocumentCount on a closed index fails,
which sent the count down the upstream-cache fallback — and the upstream
clients are disconnected by then, so an install with a fully populated
durable index would have signed off with tool_count=0. That is the exact
structural zero this branch changed the field to remove, reappearing on
the last heartbeat.
Two changes: Close() now closes the index AFTER the flush (nothing in
between touches it — cacheManager.Close, activityService.Stop and
telemetryService.Stop are BBolt/HTTP work), and GetToolCount memoises the
last SUCCESSFUL index count and reports that when the index cannot
answer, so a transient error mid-rebuild cannot produce a zero either.
The memo only ever holds a value the index actually returned, and a
legitimate empty index stores 0 and falls through as before.
Test: TestGetToolCount_ClosedIndexKeepsLastKnownCount. Neutering the memo
fails it with the cache-fallback zero.
REJECTED
- "GetServerLogs ignores the handler's 15s deadline." Per-server logs are
lumberjack-rotated at 10MB (internal/logs/logger.go), so the full-file
read is tens of milliseconds. Same rejection as the first round, same
reason.
- "diagnostics.Register is process-global; constructing a second Server
rebinds the fixers." True, already documented at the registration site.
Production builds one Server per process.
- "Duplicate clicks can launch two OAuth flows." Possibly — Manager.
StartManualOAuth builds a fresh core client per invocation, so
isOAuthInProgress is per-client. But the fixer now makes the SAME call as
the REST login button, so whatever that behaviour is, it is a pre-existing
property of the login route and not introduced here. The comment that
claimed a stronger guarantee has been corrected rather than left standing.
- "Multiple long-lived previews can still stack across ErrorPanels." Only
one ErrorPanel is ever mounted: ServerDetail.vue:194 is the sole use site
in frontend/src, under a v-if on a single server's diagnostic. The
cross-panel case cannot arise. Bounding the shared toast container was
already rejected in f34ca96 (it is the transition-group root).
- "Producer/scanner predicate mismatch on zero counts." Producer-side
restrictions, one-directional invariant holds — see the previous commit.
Verified:
go build ./... and -tags server OK
gofmt on the four touched files clean
golangci-lint v2 (.github/.golangci.yml) server + runtime 0 issues
go test ./internal/... -skip "E2E|Binary|MCPProtocol" 65/65 packages
ok, 0 failures (the earlier run's docker-info flake did not recur)
Round-3 cross-model review (opencode, gpt-6-astra) on the previous commit.
Three findings, all genuine, all about the same mistake: routing the fixer
through the management service's TriggerOAuthLoginQuick to inherit
checkWriteGates ALSO swapped in a materially different OAuth implementation.
What TriggerOAuthLoginQuick does that TriggerOAuthLogin does not:
- StartManualOAuthQuick runs startup SYNCHRONOUSLY under its own 30-minute
background context (manager.go), so the fix endpoint's 15s deadline stops
bounding the call — resource discovery alone can sit through three 30s
rate-limit waits, and the work continues after the caller disconnects.
The old path launched a goroutine and returned.
- Its watcher polls 60 times at 2s and then runs `defer cancel()` on the
callback context, unregistering the pending callback. A user who takes
more than ~120s over 2FA loses the flow they were told had started.
- HasRecentOAuthCompletion accepts a completion from the last five minutes,
and nothing (logout included) clears that map — so signing out and back
in makes the watcher exit on its FIRST poll.
- It calls CreateHTTPClient unconditionally, dropping the SSE dispatch that
ForceOAuthFlowWithResult performs. An OAuth-protected legacy SSE endpoint
that challenges GET but rejects POST now fails initialization instead of
opening its authorization flow.
The REST login route accepts all four; it has no 15s budget and is not a
one-click self-heal button. This fixer must not.
So the async Runtime.TriggerOAuthLogin call comes back, and the gate — the
part of the previous commit that was actually right — is applied explicitly
in the fixer: read config, refuse on disable_management or read_only_mode
with the SAME message text management.checkWriteGates uses, so an operator
gets one answer whichever surface they tried. The predicate is duplicated
rather than called because checkWriteGates is unexported and the only
exported entry point behind it is the wrong implementation. Fail closed if
the config cannot be read.
TestDiagnosticFixer_OAuthReauth_RespectsWriteGates is unchanged and still
bites: neutering the two checks fails both subtests with "the fixer started
a sign-in while X was set".
Also reverted with it: the BrowserOpened/BrowserError message, since the
async path reports neither. The wording no longer asserts that a window
opened — it says where to find the URL if none did. Claiming an unverified
success is precisely the placeholder behaviour this file exists to remove,
and the previous commit's version of that claim was only true because it
had the quick path's return value to lean on.
Verified:
go build ./... / go vet ./internal/server/ OK
gofmt clean
golangci-lint v2 (.github/.golangci.yml) server 0 issues
go test ./internal/server/ ./internal/httpapi/ -skip "E2E|Binary|MCPProtocol"
both ok
go test -race ./internal/server/ (full package) ok 252s
Two of the fixer tests failed on CI while passing on every developer
machine: "server not found: chatty-stdio", outcome=failed where success was
required. Reproduced locally and fixed.
CAUSE. newFixerTestServer registered the upstream client GetServerLogs
resolves by calling UpstreamManager().AddServerConfig once, at construction.
That adds a client the supervisor never asked for, and reconcile diffs
DESIRED (cfg.Servers) against ACTUAL (the manager) and deletes the
difference — so the client survives only until the next reconcile pass. On a
developer machine the test finished first; on CI under -race the pass landed
in between.
REPRO. Adding `time.Sleep(3s)` between construction and the fixer call fails
locally with the exact CI message, and passes with this change.
FIX. Re-register at the call site (ensureFixerClient), which closes the
window to the microseconds before InvokeFixer.
Two tidier-looking alternatives were tried and rejected, both verified:
- Putting the server in cfg.Servers as DISABLED does not help: reconcile
removes the client for a disabled entry too. The 3s repro still fails.
- Putting it there ENABLED does keep the client, and the assertions pass —
but it spawns a child and a per-server log writer that outlive
srv.Shutdown() and then race t.TempDir()'s RemoveAll, so four tests fail
cleanup with "directory not empty". Disabled + re-register is the only
combination with neither a background goroutine nor a race.
Verified: go test ./internal/server/ -run TestDiagnosticFixer_ ok; the same
with -race -count=2 ok; golangci-lint 0 issues; the 3s repro passes.
UNRELATED, REPORTED NOT FIXED: the same CI runs show a nil-pointer panic in
internal/httpapi.handleAddFromRegistry (server.go:5828) from the
internal/server cross-surface consistency test. That handler dereferences
cfg after AddServerFromRegistryRef returns (cfg, rerr, err) without guarding
cfg==nil on the err==nil branch. The code is byte-identical to main, this
branch touches neither the handler nor the registry path, and it does not
reproduce locally on this branch OR on origin/main (full package, with and
without -race). It needs its own issue and its own nil guard; it is not
smuggled into this diff.
…ne-research-d382b2
…, dispose previews with their panel Cross-model review (gpt-6-astra) of the four-instrument fix found four things; three are changed here and one is answered in a comment. - oauth_reauth's success message sent a browserless user to "Show last server log lines". That button reads the per-server log file, but the authorization URL and the browser-launch failure are written by the client's main logger (connection_oauth.go, c.logger) and never reach that file — and the OAuth catalog entries do not offer the log-tail button anyway. The message now names `mcpproxy auth login --server=<name>`, which drives the same flow from a terminal and prints the URL when the browser cannot be opened. The text is extracted to oauthReauthStartedMessage and pinned by a test that fails against the previous wording. - The fixer test fixture registered its out-of-band client BEFORE background initialization had run. LoadConfiguredServers snapshots the manager's clients and schedules `go RemoveServer(name)` for each one not in cfg.Servers, so a client present at that snapshot carries an asynchronous removal that can land after any later re-registration; re-registering at the call site only narrowed that window. The fixture now waits for the "Server is ready" message, which backgroundInitialization publishes right after LoadConfiguredServers returns, so the client is registered after the snapshot and that remover never targets it. The call-site re-registration stays for the periodic reconcile, and the comments now say which remover each half handles. - ErrorPanel's superseding id for its long-lived preview toast lived in the component while the toast lived in the global store; ServerDetail unmounts the panel on every navigation between servers, so the id was lost, the toast stayed, and previews opened across several servers still stacked past the top of the viewport. The panel now removes its preview in onBeforeUnmount. Spec fails before the change (toast count 1, want 0) and passes after. - GetToolCount memoises the last successful index count but prefers it over the upstream cache only when it is positive; the review read the doc comment as promising the memoised value unconditionally. The behaviour is kept and the comment corrected: a memoised zero means the index genuinely held no tools, and in that state the cache cannot produce the structural zero the guard exists for — it can only report more (tools live clients hold that the index has not caught up with), which is the closer answer. Verification: go test -race ./internal/server (fixer + GetServerLogs tests, -count=2) and ./internal/runtime green; go vet and golangci-lint v2 0 issues; frontend vitest 1262/1262.
…at arrive after unmount Second cross-model review round (gpt-6-astra) on the previous fix-up. - The reworded sign-in fallback claimed `mcpproxy auth login` "prints the authorization URL when the browser cannot be opened". That is true only in standalone mode. With the daemon running — the normal case whenever the Sign in button is visible — the CLI takes daemon mode, and although the REST login response carries auth_url and browser_opened, cliclient.TriggerOAuthLogin discards both and auth_cmd prints a generic success line. The message now says what the command does (start the sign-in again) and nothing about a URL; the test forbids the promise. Surfacing auth_url through the CLI is a separate change. - ErrorPanel's unmount cleanup only disposed of a preview that already existed. A click whose request was still in flight at unmount resumed afterwards and raised a 60s toast nothing owned — the same accumulation, one navigation later. A `disposed` flag set in onBeforeUnmount now drops a result that arrives after the panel is gone. Spec drives a deferred invokeDiagnosticFix, unmounts, then resolves it: fails before the guard (toast count 1), passes after. Verification: frontend vitest 1263/1263, vue-tsc clean; go test ./internal/server message test green; go vet and golangci-lint v2 0 issues.
…nly the ownerless preview
Third cross-model review round (gpt-6-astra) on the previous fix-up: the
disposal guard returned before any outcome was examined, so a fix the user
had submitted that then FAILED after they navigated away was never reported.
api.ts folds transport errors into resolved {success:false} responses, so
leaving `catch` unguarded preserved nothing.
The guard is now the narrowest thing that closes the original defect: after
unmount, only a successful MULTI-LINE preview — the 60s toast with nothing
left to supersede or dispose of it — is dropped. A late failure, a late
{success:false}, and a one-line late success ("Sign-in started") are all
still raised at the default dwell; multiLine is forced false after disposal
so no long-lived toast can be created without an owner, and the parent emit
is skipped.
Spec "still reports a failure whose request resolves after the panel
unmounted" resolves outcome=failed after unmount and expects one error toast
at 5000ms: fails against the previous guard (0 toasts), passes now. The
earlier late-success spec still expects zero toasts.
Verification: frontend vitest 1264/1264, vue-tsc clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four fixes to instruments that turned out to be measuring themselves rather than the product. Provoked by production telemetry; each is independently verified and independently revertable. Schema bump 10 → 11.
1.
tool_countread a cache that indexing zeroes (ae5414787)GetToolCountfed the heartbeat'stool_count, which the activation funnel and every retention cut key on. It read the upstream manager's per-client tool-count cache — but both indexing paths callInvalidateAllToolCountCaches()as their last step (internal/runtime/lifecycle.go:466,:644), and the only writers that refill that cache without re-zeroing it are UI/API-triggeredListToolscalls.So the field largely reported "has the owner opened the dashboard recently", not "has this install indexed any tools" — which confounds both the 1221-connected → 914-with-tools funnel step and the day-8-14 retention split (27.3% with 100+ tools vs 12.1% with none): dashboard engagement would drive both independently.
The Web UI and REST API were never affected — they sum the durable
StateView.ToolCount, whichreconcilepreserves with a carry-forward guard — which is why no user ever filed a "0 tools" bug.Now reads
index.Manager.GetDocumentCount(): one Bleve document per tool, durable across restarts. Per-profile indexes derive from this shared index (RebuildProfileFromShared), so profile users are not undercounted. The old cache path stays as a fallback when no index manager is wired.2.
diagnostics.error_code_counts_24hwas level-triggered (cfb06ef3d,223eb645b)Supervisor.reconcileruns on a 30s ticker and re-classified every configured server's stickyLastErroreach pass with no change detection.ConnectionInfo.LastErroris cleared only on a transition to Ready, so a server parked awaiting an OAuth login — making zero connection attempts — emitted 86400/30 = 2,880 "events" a day. An install's number tracked failing-server count × uptime.Field data corroborates:
MCPX_OAUTH_LOGIN_REQUIREDaveraged 5,212 per install-day when non-zero; 484 install-days exceeded 2,880; days above the tick cadence averaged 3.89× it on installs with a mean of 18.8 configured servers.Both stateview writers now capture the previous code and
RetryCountat the top of theUpdateServerclosure and notify only on an edge — a different classified code, or a changedRetryCount. The comparison is!=not>deliberately:RetryCountalso resets to 0 on Ready, so a decrease marks a new failure episode after an unobserved recovery. This also collapses the duplicate notification an event pass and a reconcile pass used to produce for one failure.classifyAndAttachis untouched, so the standing diagnostic still renders in UI/REST/CLI. Only the telemetry notification became edge-triggered.The companion is not optional. Edge-triggering alone deletes the "installs currently affected" signal: the counter decays, carries
omitempty, and the wholeDiagnosticsobject drops viaisZero(), so a permanently parked install would emit once and then vanish from the payload — trading an inflated number for a missing one that downstream reads as zero. So this shipsdiagnostics.current_error_codesin the same change: MCPX code → number of configured servers currently in that state, recomputed at heartbeat time from the supervisor's live stateview and joined before theisZero()check.Anonymity: low cardinality (the MCPX catalog), counts only, no names/URLs/commands/free text. Three independent guards — supervisor MCPX prefix filter,
sanitizeMCPXCodeMapat assembly, and a new structural rule in the anonymity scanner (generalised over both maps; catalog-registered keys, non-negative ints, never echoing the offending key).223eb645bthen tightenedsanitizeMCPXCodeMaptoisValidMCPXCodeso the producer filter matches the wire gate exactly —ScanForPIIdrops the entire heartbeat on a violation, so a well-shaped but uncataloged code would have silently destroyed every heartbeat for as long as that standing state persisted.Only enabled, non-quarantined servers are counted (
TestCurrentErrorCodes_ExcludesDisabledAndQuarantined) — a quarantined server is deliberately not connected and a disabled one is not wanted.update_failure.go'sMCPX_UPDATE_*_FAILEDproducer is deliberately untouched: those are genuine one-shot events.3. The classifier only knew one spelling of "http" (
79a75cf5a,43fdd1c2d)Every HTTP status/timeout/status-text arm was gated on
hints.Transport == "http". Buttransport.DetermineTransportTypereturnsconfig.Protocolverbatim when set, and"streamable-http"when not — so one transport reaches the classifier under four names config validation all accepts:streamable-http(a bare-URL server),http(registry add path, Claude Code / Gemini importers, the Add-Server modal),sse, andauto. Three of the four had every arm switched off and fell through toMCPX_UNKNOWN_UNCLASSIFIEDwith its "file a bug report" CTA. No test in the package used any spelling but"http", which is how it shipped.diagnostics.CanonicalTransportfolds the HTTP family to one value and passes everything else through lower-cased.hints.Forcalls it, so every production hint is normalized at the one place hints are built;classifyHTTPcalls it too so a hand-builtClassifierHintscannot reintroduce the bug.matchHTTPStatusTextalso accepts the marker"status code: "— mcp-go's SSE connect path words its failureunexpected status code: %d, where the digits do not follow"status ". An SSE 401/502 at connect time matched nothing on every spelling, including"http".MCPX_HTTP_RATE_LIMITED(429),MCPX_HTTP_4XX(the enumerated set 400/408/409/410/451 — not a blanket range, which would claim statuses a higher layer resolves better),MCPX_HTTP_CONN_RESET(typedECONNRESET),MCPX_HTTP_CANCELED(typedcontext.Canceled, severity info). Plus anet.Error-with-Timeout()branch routing to the existingMCPX_HTTP_TIMEOUT, which neither existing arm could see (i/o timeoutandhttp.Clientdeadlines are notcontext.DeadlineExceeded).RetryPermanent; they keep the zeroRetryClassMCPX_UNKNOWN_UNCLASSIFIEDcarries today (GH Deterministic, unrecoverable connection failures retry forever (55 attempts / 19h on one server) #1145). Naming these failures changes the message a user reads, nothing else.MCPX_STDIO_EXIT_BEFORE_INITIALIZE; reading them here would double-classify.43fdd1c2dfixes three defects a cross-model review found in the above, all reproduced:""is reachable in production (supervisor passes it wheneverstate.Configis nil), and three of the four arms it unlocked are transport-agnostic. Measured: a stdio handshake timeout returnedMCPX_HTTP_TIMEOUT, a canceled stdio connectMCPX_HTTP_CANCELED, and a stdio exit whose stderr tail quoted a child's 503 returnedMCPX_HTTP_5XX.""now passes through.matchHTTPStatusTextscanned one whole marker across the string before trying the next, and"status "is a strict prefix of"status code: "— so a status quoted later outranked the one the transport reported. The scan is now positional, left to right, longest marker first at each position."request failed with status 402: status 401"answered 401 — "fix your credentials" for a billing failure. It now stops at the first well-formed status token. A four-digit run is not a token, so"status 4011"keeps scanning.Both phrasings matter because mcp-go interpolates the raw response body into
request failed with status %d: %s.Docs: four new
docs/errors/pages,docs/errors/README.mdindex entries, andwebsite/sidebars.jsentries so the fix-step links resolve.4. The registered "fixers" were placeholders (
faefdd05c,f34ca9630,c080ab981)internal/diagnostics/builtin_fixers.gosays the advanced fixers "are registered by higher layers at startup". Nothing ever did — the onlydiagnostics.Registercall outside the package was in a test. So all four registered fixers were the package's own placeholders:stdio_show_last_logsreturnedoutcome=SUCCESSwith the canned string"log tail unavailable in this build", and the other three returnedBLOCKEDunder execute.Two of those are the only action their codes offer, and they are wide in the field:
MCPX_STDIO_EXIT_BEFORE_INITIALIZE(270 installs) andMCPX_STDIO_HANDSHAKE_TIMEOUT(315) both point at "Show last server log lines";MCPX_OAUTH_LOGIN_REQUIRED(208) at "Sign in". And because the placeholder reported SUCCESS,diagnostics.fix_succeeded_24hwas counting no-ops.New
internal/server/diagnostics_fixers.goregisters two runtime-backed fixers fromNewServerWithConfigPath:stdio_show_last_logs→(*Server).GetServerLogs(name, 50), the same call backingGET /api/v1/servers/{id}/logs. Reusing that call rather than reading the file is the point: it runs every line throughparseLogLine→scrubUpstreamText(quarantine_security list_quarantined returns env/headers/oauth secrets in plaintext, unauthenticated #1148), so the text crossing the REST API is masked by the identical scrubber. A missing/unreadable log now returnsFAILEDinstead of a fake success.oauth_reauth→Runtime.TriggerOAuthLogin, the same callPOST /api/v1/servers/{name}/loginmakes.dry_runstill previews only.The two config-mutating placeholders are deliberately untouched — writing the user's config from a one-click button is a larger design question.
Gating is unchanged and verified, not assumed: destructive-flag handling, the 409 mode guard (
TestDiagnosticsFix_ModeGuardstill passes), ErrorPanel'swindow.confirm, and the existing duplicate-sign-in guard inconnection_oauth.go(#975).f34ca9630then sized the delivery path for the new payload:ToastContainerrenders the messagewhitespace-pre-wrapwith a bounded scrollable height (a plain div folded 50 newlines into one paragraph);ErrorPanelgives a multi-line fix message a 60s dwell instead of 5s; and a newdiagnosticsPreviewMaxBytes(8KB) drops the oldest lines — a tail is read bottom-up — stating how many it dropped. The newest line always survives whole.c080ab981then makesErrorPanelsupersede its own previous long-lived preview, because the 60s dwell let tall previews stack upward past the top of the viewport, close button included.Also in this branch
CLAUDE.md: cross-model reviewer switched togpt-6-astraper maintainer directive 2026-09-06, plus the two invocation rules (close stdin, wrap ingtimeout; an empty result is a failed review, not a clean one).Cross-model review (opencode, gpt-6-astra)
Reviewed in four parts — telemetry accounting, the classifier, the fixers and their delivery path, and the
tool_countsource — then re-reviewed. Findings accepted:(code, RetryCount), butSetOAuthErroradvancesoauthRetryCountand notretryCount— so a server failing OAuth repeatedly edged once, ever. Unbounded coalescing, not the cadence-bounded kind the comment promised.ConnectionInfo.LastRetryTimejoins the basis. Every failure setter stamps it; nothing else advances it; re-observing a sticky error does not touch it. Two ABA routes that needed no dropped event close with it.code_edge_count_24h_). Neither prefix is a prefix of the other; the v10 orphans are inert and decay-stale.MCPX_HTTP_CANCELEDis the catalog's onlyseverity=infocode, andServerDetail's gate accepted only warn/error — which did not hide it, but fell through to the generic red "Server Error" box. The calmest code rendered the loudest, stripped of its message, fix steps and docs link.ErrorPanelalready styles info calmly, so the component needed nothing.Runtime.TriggerOAuthLogindirectly, skipping the management service'scheckWriteGates— so it worked on an install withread_only_modeordisable_managementset, where the REST login route refuses.TriggerOAuthLoginQuick, the same callPOST /servers/{id}/loginmakes. Fails closed if that service is unavailable.OAuthStartResult.BrowserOpened/BrowserErrornow drive the message, with the URL to finish manually.< 3×capto<= cap, which was not asserting the cap at all.Close()closed the Bleve index beforetelemetryService.Stop(), which performs the final graceful heartbeat — so the last heartbeat of every clean shutdown fell through to the disconnected upstream cache and reportedtool_count=0. The very confound this PR removes, reappearing on the way out.GetToolCountmemoises the last successful count so a transient index error cannot produce a zero either.Every fix has a bite check: the guard was verified to fail against the unfixed code with the package still building.
A further round then reverted half of #4. Routing through
TriggerOAuthLoginQuickto inherit the write gates also swapped in a materially different OAuth implementation: it runs startup synchronously under its own 30-minute context (so the fix endpoint's 15s deadline stops bounding the call), its watcher cancels the callback context after ~120s (so slow 2FA loses a flow the user was told had started),HasRecentOAuthCompletioncan make that watcher exit on its first poll after a recent sign-in, and it drops the SSE dispatch. The asyncRuntime.TriggerOAuthLoginis back, with the gate applied explicitly in the fixer using the same message textcheckWriteGatesuses, failing closed if the config cannot be read. The gate test is unchanged and still bites.Separately, CI caught a defect in this branch's own test helper:
newFixerTestServerregistered its upstream client out of band, and the supervisor's reconcile — which diffscfg.Serversagainst the manager and deletes the difference — removed it again. Passed on every developer machine, failed on CI under-racewithserver not found. Reproduced locally by inserting a 3s sleep, then fixed by re-registering at the call site; the two tidier-looking alternatives (a disabled entry incfg.Servers, an enabled one) were both tried and both rejected with evidence, in the commit message.Rejected, with reasons: a producer/scanner predicate "mismatch" that is a one-directional producer restriction and cannot cause the heartbeat drop it guards; three further ABA/staleness variants (two closed by #1, the third an over-count inherent to diffing sampled projections and documented rather than papered over);
GetServerLogsignoring the 15s deadline (per-server logs are lumberjack-rotated at 10MB);diagnostics.Registerbeing process-global (documented; production builds oneServerper process); duplicate sign-in clicks (the fixer now makes the same call as the REST login button, so the behaviour is that route's, pre-existing and unchanged here); long-lived toast previews stacking across panels (ErrorPanelhas exactly one mount site, under av-ifon a single server).Verification
Every commit records its own RED-first evidence and bite checks; see the commit messages. Aggregate:
go build ./...and-tags serverOK,go vetclean,gofmtclean,golangci-lintv2 with.github/.golangci.yml0 issues,go test -racegreen acrossinternal/diagnostics,internal/runtime,internal/runtime/supervisor,internal/telemetry,internal/server,internal/httpapi,internal/health,internal/upstream, plus 1178 frontend unit tests.scripts/test-api-e2e.shwas not run — it blanket-pkills every mcpproxy core on the machine and sibling worktrees are live; no route, handler or payload schema on the HTTP surface changed.scripts/check-errors-docs-links.shfails on the same 8 pre-existing pages asmainand on none of the new codes.🤖 Generated with Claude Code