inspector/clients/web/src/utils/toasts/progressToasts.ts
cliffhall f5a3ccedbb fix: type-discriminate progress toast ids, CSPRNG attempt-id fallback
Both findings from the v2.5.0 milestone-merge review (#2215).

1. `progressToastId` collided across distinct progress streams.
   `ProgressToken` is `string | number`, so `String(token)` mapped the
   numeric token 7 and the string token "7" onto one id — and because
   notifications keyed by the same id are *replaced* rather than
   stacked, two concurrent streams overwrote each other's toast. The
   absent case was worse: it hardcoded the sentinel "default", which a
   server is free to send as a genuine string token. The id now carries
   the token's type (`progress-n:7` / `progress-s:7`) and gives the
   no-token case a prefix of its own (`progress-none`) that no token
   value can produce.

2. `newAttemptId`'s fallback now prefers `crypto.getRandomValues`.
   `randomUUID` needs a secure context; `getRandomValues` does not and
   exists in every browser that has `crypto` at all — so the exact
   situation the fallback exists for (a `file://` page, a plain-HTTP
   non-loopback host) still has a CSPRNG on hand. `Math.random` stays
   as the last resort for a `crypto`-less global, and the "never a
   security token" comment stays too. Retires CodeQL alert 72
   (`js/insecure-randomness`) honestly rather than by dismissing it.

Tests: collision cases for 7 vs "7" and the absent token vs "default" /
"none", a distinctness sweep over the whole id space; and the attempt-id
fallback test split into a getRandomValues arm (asserting the CSPRNG is
used and `Math.random` is not) and a no-crypto-at-all arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uw1s4LRBUzrFwLzPT4mAJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-05 13:36:30 -04:00

42 lines
2 KiB
TypeScript

import type { Progress, ProgressToken } from "@modelcontextprotocol/client";
// How long a progress toast lingers after its last tick. Each new tick on the
// same progress stream resets this window (via `notifications.update`), so a
// steady stream keeps one toast alive; the toast clears a few seconds after
// progress stops (i.e. the call finished or went quiet).
export const PROGRESS_TOAST_AUTOCLOSE_MS = 5000;
// Stable toast id for a progress stream. Notifications keyed by this id are
// replaced (not stacked) so a chatty server updates one toast per stream
// rather than flooding the corner. The injected `progressToken` correlates a
// stream with the request that triggered it; when absent (the common case —
// the inspector doesn't expose a caller token), all ticks share one toast.
//
// The token's *type* is part of the key. `ProgressToken` is `string | number`,
// so a bare `String(token)` maps the number 7 and the string "7" — two
// distinct streams per the spec — onto one id, and the two streams then
// overwrite each other's toast (id collision means replacement, which is the
// whole point of the id). The `n:`/`s:` discriminator keeps them apart, and
// the no-token case gets a prefix of its own rather than the sentinel
// `"default"`, which a server is free to send as a genuine string token.
export function progressToastId(token: ProgressToken | undefined): string {
if (token === undefined) {
return "progress-none";
}
return typeof token === "number"
? `progress-n:${token}`
: `progress-s:${token}`;
}
// One-line toast body: "<message> — <progress> / <total> (NN%)". The fraction
// and percentage are omitted when the server sends no `total`.
export function formatProgressToastMessage(
detail: Progress & { progressToken?: ProgressToken },
): string {
const { progress, total, message } = detail;
const ratio =
total !== undefined && total > 0
? `${progress} / ${total} (${Math.round((progress / total) * 100)}%)`
: `${progress}`;
return message ? `${message} — ${ratio}` : ratio;
}