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>
42 lines
2 KiB
TypeScript
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;
|
|
}
|