inspector/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx
cliffhall 1e427c05f3 fix: mark a rejected standalone response entry as an error too
extractStatus returned none for any non-request entry, so a clientError
annotation that landed on a standalone response frame — the fallback
messageLogState uses when no request entry was there to fold into, e.g. a
trimmed log or a reconnect boundary — rendered with no status badge at
all. The clientError check now runs before the request-only lifecycle.

Caught in Copilot's review of #1953.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
2026-08-08 17:11:34 -04:00

496 lines
17 KiB
TypeScript

import { useState } from "react";
import {
Alert,
Anchor,
Badge,
Card,
Collapse,
Divider,
Group,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { RiErrorWarningLine } from "react-icons/ri";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
import { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
import { McpErrorBadge } from "../../elements/McpErrorBadge/McpErrorBadge";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
import { PinToggle } from "../../elements/PinToggle/PinToggle";
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
import { useValueChange } from "../../../hooks/useValueChange";
import {
classifyProtocolSpecError,
type McpSpecError,
} from "../../../utils/mcpNetworkHeaders";
import {
extractMethod,
extractResultType,
extractSubscriptionId,
isReplayableProtocolMethod,
} from "../protocolUtils.js";
export interface ProtocolEntryProps {
entry: MessageEntry;
isPinned: boolean;
isListExpanded: boolean;
onReplay: () => void;
onTogglePin: () => void;
/**
* Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is
* time + direction + duration + status; line 2 is the method (and target) with
* the controls — Replay as an icon — on the right.
*/
embedded?: boolean;
/**
* When provided (a spec-error entry with a correlated Network request), the
* expanded alert shows a "view in Network" link that jumps to, and expands,
* the matching HTTP entry.
*/
onRevealInNetwork?: () => void;
/**
* HTTP status of this entry's correlated Network fetch, when known. Used to
* gate the generic `-32601` to a genuine modern 404 (an in-band `-32601` on a
* 200 is an ordinary error, not the modern taxonomy). Omitted when there is no
* correlated HTTP record.
*/
correlatedHttpStatus?: number;
}
const EntryContainer = Card.withProps({
withBorder: true,
padding: "md",
variant: "inset",
});
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
});
// Left / right clusters within a compact header line. The left cluster shrinks
// (`miw: 0`) so a long target can truncate rather than push the row wider.
const HeaderCluster = Group.withProps({
gap: "sm",
wrap: "nowrap",
miw: 0,
});
const ControlsCluster = Group.withProps({
gap: "xs",
wrap: "nowrap",
});
const TimestampText = Text.withProps({
size: "sm",
c: "dimmed",
ff: "monospace",
});
const TargetText = Text.withProps({
size: "sm",
fw: 500,
});
// Compact-header target (e.g. a long resource URI): never wraps, so it scrolls
// horizontally inside its ScrollArea instead of truncating with an ellipsis
// (mirrors NetworkEntry's URL).
const TargetScroll = Text.withProps({
size: "sm",
fw: 500,
variant: "nowrap",
});
const DurationText = Text.withProps({
size: "sm",
c: "dimmed",
});
// The `subscriptionId` tag on a modern push notification (spec §7.4). Shown with
// a copy button so the id can be correlated against the `subscriptions/listen`
// stream that opened it.
const SubscriptionLabel = Text.withProps({
size: "sm",
c: "dimmed",
});
const SubscriptionId = Text.withProps({
size: "sm",
ff: "monospace",
});
const SubscriptionCluster = Group.withProps({
gap: 4,
wrap: "nowrap",
miw: 0,
});
// Friendly summary alert for a modern spec error (title is per-error, dynamic).
const SpecErrorAlert = Alert.withProps({
variant: "light",
color: "red",
icon: <RiErrorWarningLine />,
});
// The client rejected an otherwise well-formed response (#1953). Distinct from
// SpecErrorAlert: nothing is wrong with the server's JSON-RPC frame — the
// Inspector's own decoding refused the result — so the title says who rejected it.
const ClientErrorAlert = Alert.withProps({
variant: "light",
color: "red",
icon: <RiErrorWarningLine />,
title: "Rejected by the Inspector",
});
// Link (button-styled) that jumps to the correlated HTTP entry in the Network tab.
const RevealLink = Anchor.withProps({
component: "button",
type: "button",
size: "xs",
});
const TargetScrollArea = ScrollArea.withProps({
scrollbarSize: 6,
flex: 1,
miw: 0,
// The target scrolls horizontally but has no focusable child, so make the
// viewport itself keyboard-scrollable (WCAG SC 2.1.1). Scrollbar auto-hides
// via the `type="scroll"` theme default.
viewportProps: { tabIndex: 0 },
});
// Trailing controls row (replay / pin / expand) in the wide layout.
const ToggleRow = Group.withProps({
gap: "xs",
justify: "flex-end",
});
// `complete` is green — it's the success signal now that the redundant "OK"
// status badge is suppressed, so a modern success keeps the same at-a-glance
// green affordance a legacy success has. `input_required` is yellow (in
// progress: awaiting input before the retry).
function resultTypeColor(resultType: "complete" | "input_required"): string {
return resultType === "input_required" ? "yellow" : "green";
}
function resultTypeLabel(resultType: "complete" | "input_required"): string {
return resultType === "input_required" ? "input required" : "complete";
}
function formatDuration(ms: number): string {
return `${ms}ms`;
}
function formatTimestamp(date: Date): string {
return date.toISOString();
}
// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO
// string would eat most of the narrow line-1 width (#1616).
function formatTimestampCompact(date: Date): string {
return date.toISOString().slice(11, 19);
}
function extractTarget(entry: MessageEntry): string | undefined {
const msg = entry.message;
if (!("params" in msg) || !msg.params) return undefined;
const params = msg.params as Record<string, unknown>;
if (typeof params.name === "string") return params.name;
if (typeof params.uri === "string") return params.uri;
return undefined;
}
// The resource URI when the target is one (e.g. `resources/read`), so it can be
// copied. Tool/prompt targets are plain names, not URIs, and get no copy button.
function extractResourceUri(entry: MessageEntry): string | undefined {
const msg = entry.message;
if (!("params" in msg) || !msg.params) return undefined;
const params = msg.params as Record<string, unknown>;
return typeof params.uri === "string" ? params.uri : undefined;
}
// The pending → OK/Error lifecycle only applies to requests: messageLogState
// attaches a `response` to request entries by JSON-RPC id. A notification is
// fire-and-forget (no id, no response, ever) and an unmatched standalone
// response has none either — so those carry no request-style status ("none")
// and render no badge, rather than a misleading permanent "Pending".
function extractStatus(
entry: MessageEntry,
): "success" | "error" | "pending" | "none" {
// A response the CLIENT refused is an error whichever entry carries it, so
// this is checked BEFORE the request-only lifecycle below (#1953).
// messageLogState annotates the request entry when the response was folded
// into one, but falls back to the standalone response frame when there was
// no matching request (a trimmed log, or a reconnect boundary) — and that
// entry would otherwise fall straight through to "none" and render no badge.
if (entry.clientError) return "error";
if (entry.direction !== "request") return "none";
if (!entry.response) return "pending";
if ("error" in entry.response) return "error";
return "success";
}
function statusColor(status: "success" | "error" | "pending"): string {
if (status === "success") return "green";
if (status === "error") return "red";
return "gray";
}
function statusLabel(status: "success" | "error" | "pending"): string {
if (status === "success") return "OK";
if (status === "error") return "Error";
return "Pending";
}
function serializeMessage(value: unknown): string {
return JSON.stringify(value);
}
// The JSON-RPC error carried by a message — either the folded error `response`
// on a request, or an error message frame itself — classified as a modern spec
// error (SEP-2243 / SEP-2575) or null. Protocol errors the SDK throws rather
// than delivers (e.g. -32601) are folded onto the pending request upstream (see
// `enrichProtocolEntries`), so they land here too.
function extractSpecError(
entry: MessageEntry,
httpStatus?: number,
): McpSpecError | null {
const error =
entry.response && "error" in entry.response
? entry.response.error
: "error" in entry.message
? entry.message.error
: undefined;
if (!error || typeof error.code !== "number") return null;
return classifyProtocolSpecError(error.code, error.data, httpStatus);
}
// Friendly summary of a modern spec error, shown in the expanded detail. The
// HTTP-level facts (status, mirrored headers) live on the correlated Network
// entry, reachable via the "view in Network" link when one exists.
function McpSpecErrorAlert({
error,
onReveal,
}: {
error: McpSpecError;
onReveal?: () => void;
}) {
return (
<SpecErrorAlert title={`${error.code} ${error.name}`}>
<Stack gap="xs">
<Text size="xs">{error.description}</Text>
{error.supported && (
<Text size="xs">Server supports: {error.supported.join(", ")}</Text>
)}
{onReveal && (
<RevealLink onClick={onReveal}>
View the HTTP request in the Network tab →
</RevealLink>
)}
</Stack>
</SpecErrorAlert>
);
}
export function ProtocolEntry({
entry,
isPinned,
isListExpanded,
onReplay,
onTogglePin,
embedded = false,
onRevealInNetwork,
correlatedHttpStatus,
}: ProtocolEntryProps) {
const [isExpanded, setIsExpanded] = useState(isListExpanded);
const method = extractMethod(entry);
const target = extractTarget(entry);
const resourceUri = extractResourceUri(entry);
const status = extractStatus(entry);
const canReplay = isReplayableProtocolMethod(method);
const resultType = extractResultType(entry);
const subscriptionId = extractSubscriptionId(entry);
// The list-level Expand/Collapse toggle is authoritative: any per-entry
// override is discarded whenever the parent changes `isListExpanded`.
// Mirrors NetworkEntry; do not change without aligning both.
useValueChange(isListExpanded, setIsExpanded);
const directionBadge = entry.origin && (
<MessageDirectionBadge
direction={entry.origin === "client" ? "outgoing" : "incoming"}
/>
);
// Distinct chip for a modern spec error (SEP-2243 / SEP-2575). Shown only in
// the wide layout (right after the method chip); the compact sidebar relies on
// its ERROR status badge to keep the two-line row uncluttered.
const specError = extractSpecError(entry, correlatedHttpStatus);
const specErrorBadge = specError && (
<McpErrorBadge
code={specError.code}
name={specError.name}
description={specError.description}
/>
);
// The modern `resultType` on the paired result (spec §7.3): `input_required`
// (the operation isn't done — it needs input and will be retried) vs the
// ordinary `complete`. Only present on modern results, so it doubles as a
// per-result modern signal without inferring the connection era.
const resultTypeBadge = resultType && (
<Badge color={resultTypeColor(resultType)} variant="status">
{resultTypeLabel(resultType)}
</Badge>
);
// Suppress the redundant green "OK" when a `resultType` badge already conveys
// the outcome (a modern success is `complete`/`input required`); errors and
// pending have no `resultType`, so their status badge still shows.
const statusBadge = status !== "none" &&
(!resultType || entry.clientError) && (
<Badge color={statusColor(status)} variant="status">
{statusLabel(status)}
</Badge>
);
const subscriptionBadge = subscriptionId && (
<SubscriptionCluster>
<SubscriptionLabel>sub</SubscriptionLabel>
<CopyButton value={subscriptionId} />
<SubscriptionId>{subscriptionId}</SubscriptionId>
</SubscriptionCluster>
);
const durationText = entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
);
return (
<EntryContainer>
<Stack gap="sm">
{embedded ? (
// Compact two-line header for the narrow column.
<Stack gap="xs">
<HeaderRow>
<HeaderCluster>
<TimestampText>
{formatTimestampCompact(entry.timestamp)}
</TimestampText>
{directionBadge}
</HeaderCluster>
<ControlsCluster>
{durationText}
{resultTypeBadge}
{statusBadge}
{/* The subscription-id tag rides the top line's trailing edge
(a notification row's duration/status slots are empty) so the
method badge on the line below gets the full column width and
doesn't truncate against the pin control (#1630). */}
{subscriptionBadge}
</ControlsCluster>
</HeaderRow>
<HeaderRow>
<HeaderCluster flex={1}>
<MethodBadge method={method} />
{target && (
<>
{resourceUri && <CopyButton value={resourceUri} />}
<TargetScrollArea>
<TargetScroll>{target}</TargetScroll>
</TargetScrollArea>
</>
)}
</HeaderCluster>
<ControlsCluster>
{canReplay && <ReplayButton onReplay={onReplay} />}
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</ControlsCluster>
</HeaderRow>
</Stack>
) : (
<>
<HeaderRow>
<Group gap="sm">
<TimestampText>
{formatTimestamp(entry.timestamp)}
</TimestampText>
{directionBadge}
<MethodBadge method={method} />
{specErrorBadge}
{subscriptionBadge}
{target && (
<>
{resourceUri && <CopyButton value={resourceUri} />}
<TargetText>{target}</TargetText>
</>
)}
</Group>
<Group gap="sm">
{durationText}
{resultTypeBadge}
{statusBadge}
</Group>
</HeaderRow>
<ToggleRow>
{canReplay && <ReplayButton onReplay={onReplay} />}
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</ToggleRow>
</>
)}
<Collapse in={isExpanded}>
<Stack gap="sm">
<Divider />
{entry.clientError && (
<ClientErrorAlert>
<Text size="xs">{entry.clientError}</Text>
</ClientErrorAlert>
)}
{specError && (
<McpSpecErrorAlert
error={specError}
// Only header/HTTP-status errors gain from the raw HTTP entry;
// a protocol-only error (e.g. -32021) shows no link.
onReveal={
specError.httpRelevant ? onRevealInNetwork : undefined
}
/>
)}
{"params" in entry.message && entry.message.params && (
<Stack gap="xs">
<Text size="sm">Parameters:</Text>
<ContentViewer
block={{
type: "text",
text: serializeMessage(entry.message.params),
}}
copyable
/>
</Stack>
)}
{entry.response && (
<Stack gap="xs">
<Text size="sm">Response:</Text>
<ContentViewer
block={{
type: "text",
text: serializeMessage(entry.response),
}}
copyable
/>
</Stack>
)}
</Stack>
</Collapse>
</Stack>
</EntryContainer>
);
}