Skip to content

feat(user-testing): tell a tester when a server didn't connect - #4062

Open
olartgabo wants to merge 3 commits into
olartgabo/user-testing-guest-brandingfrom
olartgabo/user-testing-connection-truth
Open

feat(user-testing): tell a tester when a server didn't connect#4062
olartgabo wants to merge 3 commits into
olartgabo/user-testing-guest-brandingfrom
olartgabo/user-testing-connection-truth

Conversation

@olartgabo

@olartgabo olartgabo commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #4061. Review that one first; this PR's diff is only the second
commit.

The team ran a User Testing scenario built on an Excalidraw MCP server. It never
connected, the session said nothing, and the tester spent the whole session
asking a model with no tools why it couldn't draw.

Two things caused that:

  • Nothing in the guest runtime connects before the first message.
    /api/web/scenarios/redeem is a config fetch that never touches the servers it
    lists; the first real connection attempt happens inside a chat turn.
  • ScenarioChatPage then hard-coded connectionStatus: "connected" for every
    server in the payload. That map is what the composer's server list reads, so a
    server nothing had ever reached showed a green dot.

What this does

Probes each server the OAuth gate doesn't already own, up front, over the same
/api/web/servers/validate hop the chat turn would make — so a pass here means
the turn's connection works for the same reasons, and a failure here is the
failure the turn would have hit.

  • A server that doesn't answer reads failed, not connected.
  • A banner names it to the tester in terms they can act on. Transport detail goes
    to the console: someone who followed a link can't act on an SSE status code,
    and every other visitor-facing string on this route is curated for the same
    reason.
  • The composer holds until the probes answer. Sending early withholds a
    still-unknown server from the turn, which is the same silent failure wearing a
    different hat.
  • Unreachable servers are dropped from hostedContext.selectedServerIds. That
    field wins over the status-filtered names inside ChatTabV2, so it has to be
    filtered here or the turn still ships the dead server — and one server failing
    listTools rejects the Promise.all behind the tool set, killing the whole
    turn.

Scoping

Only servers with useOAuth: false are probed. Every useOAuth row — including
a discover-mode one, which the mirror also reports as true — belongs to the OAuth
gate, which verifies them against this same endpoint. Probing those here would
double-connect and report a server merely awaiting consent as broken. So a
discover-mode server that is simply down stays silent for now; that's deliberate,
not an oversight.

A wire failure gets one retry, and the probe has a client-side deadline. Both
matter: the verdict is final for the session, so a 502 or a dropped connection
must not permanently brand a healthy server unreachable, and an unbounded probe
would leave the composer shut forever.

Testing

Three new cases in ScenarioChatPage.test.tsx: the unreachable server is named
and reported failed and dropped from the turn; a transient blip is retried
rather than believed; the composer holds until the probe answers.

  • ScenarioChatPage.test.tsx 43/43.
  • npm run typecheck and npm run typecheck:client both exit 0.
  • Full client suite green (9,075 tests). Repo guards, SDK build and
    build:inspector all pass.

Still open, deliberately out of scope: nothing records connection outcome
server-side, so a scenario's session count still reads clean when every tester
got a dead server.


Summary by cubic

Probes non‑OAuth scenario servers on load and tells testers when a server didn’t connect. Previously all servers showed “connected” until the first turn; now we pre‑validate, show a failure banner, block sending until probes finish, and exclude failed servers from the turn.

  • Adds useScenarioServerReachability: probes via POST /api/web/servers/validate with one retry for wire failures, a 15s client deadline, abort on timeout/unmount, and verdicts scoped to the session’s scenarioId. Losing the bootstrap race leaves the server reachable (let the turn try). Skips useOAuth servers.
  • ScenarioChatPage maps results to connecting/connected/failed, blocks the composer until probes register (including the first frame), drops unreachable servers from hostedContext.selectedServerIds, and shows an unreachable‑servers banner. Transport details log to the console.
  • Verdicts reset and re‑probe when the tester opens another scenario; they do not reset on accessVersion changes. Threads an AbortSignal through validateHostedServer and webPost to prevent parallel connects and to tear down on unmount.
  • Tests cover banner naming, retry‑on‑blip, first‑frame composer block, abort on unmount, single‑deadline timeout (no second wait), and re‑probe on scenario switch.

Written for commit 28e937e. Summary will update on new commits.

Review in cubic

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. enhancement New feature or request labels Aug 17, 2026
@chelojimenez

chelojimenez commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Internal preview

Preview URL: https://mcp-inspector-pr-4062.up.railway.app
Deployed commit: 2a18197
PR head commit: 28e937e
Backend target: staging fallback.
Health: ✅ Convex reachable
Access is employee-only in non-production environments.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 4 files

You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread mcpjam-inspector/client/src/hooks/hosted/use-scenario-server-reachability.ts Outdated
Comment thread mcpjam-inspector/client/src/components/hosted/ScenarioChatPage.tsx Outdated
Comment thread mcpjam-inspector/client/src/hooks/hosted/use-scenario-server-reachability.ts Outdated
@olartgabo
olartgabo force-pushed the olartgabo/user-testing-connection-truth branch from 51eb159 to 3dff7ff Compare August 17, 2026 23:35
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Aug 21, 2026
A scenario built on an Excalidraw server ran a whole session without it.
Nothing in the guest runtime ever connects before the first message —
`/api/web/chatboxes/redeem` is a config fetch that never touches the servers
it lists — and `ChatboxChatPage` then hard-coded `connectionStatus:
"connected"` for every row in the payload. So the composer showed a green
dot for a server nothing had reached, the turn quietly included it, and the
tester was left asking a model with no tools why it couldn't draw.

Probe each server the OAuth gate doesn't already own, up front, over the
same /validate hop the chat turn would make. A server that doesn't answer
reads `failed` instead of `connected` — which is what both the composer's
dot and the turn's server selection read — and a banner names it to the
tester in terms they can act on. The composer holds until the probes answer:
sending early withholds a still-unknown server from the turn, which is the
same silent failure wearing a different hat.

Transport detail stays in the console. Someone who followed a link can't act
on an SSE status code, and every other visitor-facing string on this route
is curated for the same reason.
… bound its probes

Three defects in the reachability probe, all reachable from a normal tester
session.

A verdict outlived the session that produced it. `ScenarioChatPage` is rendered
without a `key` and swaps sessions in place, so `probedServerIdsRef` survived a
scenario switch: a server id listed by both scenarios kept the first session's
verdict and was never re-probed, showing an answer the new session never got.
The hook now takes a `sessionKey` and resets probe state when it changes, and a
probe that resolves after the switch is discarded. Keyed on `scenarioId`, not
`accessVersion` — a mid-session re-redeem bumps the version without changing
which servers the tester is exercising, and re-probing there would shut the
composer on every access recovery.

A timed-out probe kept running. `withProbeTimeout` rejected at the 15s deadline
but left the request in flight, so the retry opened a second connection to the
same server and both outlived unmount. `validateHostedServer` now accepts an
`AbortSignal` (threaded through `webPost`); the deadline aborts as well as
rejects, and unmount aborts whatever is still in flight.

An unprobed server did not hold the composer. `reachabilityByServerId` is
populated from an effect, so the first render after a session resolved had an
empty map and `=== "checking"` read false — a send could include a server
nothing had contacted. A probe-eligible server with no entry now reads as
checking. `reachableSessionServerIds` and `unreachableServerNames` still key on
a proven `unreachable` verdict: excluding unprobed servers from the turn would
drop them for no reason, and the banner must only name servers that failed.

Covered by "holds the composer on the very first frame, before the probe
registers", "abandons a probe that outlives the page", and "re-probes a shared
server when the tester opens another scenario". The first and third fail on the
old code with `true`/`false` and `"connected"`/`"failed"` respectively.

Not changed: the reviewer's claim that a `{ success: false }` body is treated as
a successful probe. `/api/web/servers/validate` returns 200 only via
`buildConnectSuccessEnvelope`, whose type is `success: true`, and `webPost`
throws `WebApiError` on any non-ok response, so that branch is unreachable.
Two ways the reachability probe told a tester a healthy server was unreachable.

Losing the bootstrap race branded the server. `BOOTSTRAP_ATTEMPTS` exists
because `/redeem` resolves before `useApiContext` has published this scenario's
ids, so the first probes throw `BootstrapNotReadyError` without a round trip.
Running out of those six attempts — 1.8s total — fell through to the same
`unreachable` initializer as a refused connection, so a slow bootstrap showed
the banner, painted the dot red, and dropped the server from
`selectedServerIds`, with nothing left to re-probe it for the rest of the
session. Nothing had reached the wire, so there was no evidence either way:
exhausting the bootstrap attempts now leaves the server reachable and lets the
turn make its own attempt and report its own failure. The sibling
`useHostedOAuthRequirements` already resolves its version of this race to the
benign default.

A lost response cost two deadlines. `PROBE_TIMEOUT_MS` is 15s and the route's
own connect timeout is 10s (`WEB_CONNECT_TIMEOUT_MS`), so reaching the deadline
means the response was lost, not that the server was slow — but the timeout
rejected with a plain `Error` and was counted as an ordinary wire failure, so it
was retried for another 15s. The composer is shut for that whole time behind
"Connecting to this session's tools…", with no way for the tester to send.
`ProbeTimeoutError` now marks the attempt terminal, capping the hold at one
deadline; the retry stays for the blips it was added for, which reject fast.

Also stops counting the teardown abort as an attempt. Unmount aborts whatever is
in flight, which lands in the same catch as a real failure; the verdict was then
discarded anyway, after an unnecessary retry delay. It returns immediately now.

Covered by "keeps a server reported connected when the probe never got to run"
and "does not wait out a second deadline after the first response is lost". Both
fail on the old code — the first with the banner present, the second with two
`validateHostedServer` calls instead of one.

Not changed: reading `result.success` before believing the probe. The reviewer
raised it and the previous commit answered it — `/api/web/servers/validate`
returns 200 only through `buildConnectSuccessEnvelope`, typed `success: true`,
and `webPost` throws on any non-ok response. A test for that branch could only
assert its own mock.
@olartgabo
olartgabo force-pushed the olartgabo/user-testing-connection-truth branch from b916784 to 28e937e Compare August 21, 2026 15:04
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants