inspector/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx
cliffhall ec09628705 fix: address Copilot review round 3 on #2318
- fetchTracking: the close report is guarded so a throwing listener
  cannot become an unhandled rejection off the discarded watcher; a
  bare CR that is the last byte of the stream terminates its event; a
  moving event count is coalesced to one report per interval (default
  250ms, 0 in tests), with the open and the close still immediate.
- MessageTrackingTransport rolls back a request whose signal was already
  aborted before the send began, and suppresses the rollback only for an
  abort that lands during the send.
- useTickingClock takes an `enabled` flag; Connection Info installs no
  interval when there is no activity section.
- The diagnostics-callbacks test justifies its private-seam cast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 22:53:08 -04:00

595 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Badge,
Button,
Code,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import type {
ClientCapabilities,
DiscoverResult,
InitializeResult,
ProtocolEra,
ServerCapabilities,
} from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js";
import type { ConnectionDiagnostics } from "@inspector/core/mcp/connectionDiagnostics.js";
import {
formatLastResponse,
formatNotificationStream,
formatOutstandingRequests,
NO_OUTSTANDING_REQUESTS_LABEL,
} from "../../../utils/connectionActivity";
import { useTickingClock } from "../../../hooks/useTickingClock";
import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { getSkillsExtension } from "@inspector/core/mcp/skills.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
import {
CapabilityItem,
type CapabilityKey,
} from "../../elements/CapabilityItem/CapabilityItem";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { EraBadge } from "../../elements/EraBadge/EraBadge";
import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { OAuthTokenField } from "./OAuthTokenField";
export interface OAuthDetails {
protocol: "standard" | "ema";
authorized: boolean;
clientId?: string;
clientRegistrationKind?: OAuthClientRegistrationKind;
authUrl?: string;
scopes?: string[];
accessToken?: string;
/**
* OIDC `id_token` from the stored token set, when the authorization server
* returned one. Shown for inspection only — the MCP authorization spec is
* plain OAuth 2.1 and the Inspector never treats this as a credential
* (#2019). Absent when the token set carries none, so no empty row renders.
*/
idToken?: string;
/** EMA only — install-level IdP session for legs 1–2. */
idpSession?: "none" | "logged_in" | "expired";
}
export interface ConnectionInfoContentProps {
initializeResult: InitializeResult;
/**
* Whether the server actually reported `serverInfo`. When false (a modern
* server that omitted the optional `_meta` stamp), `initializeResult`'s name is
* a client-side catalog fallback, so the Server Implementation section renders
* "not reported" rather than presenting the inferred name as server-sent — the
* exact fact a user opens this modal to check (#1772). Defaults to `true`
* (server-reported), which is the norm and what fixtures with a real
* `serverInfo` want.
*/
serverInfoReported?: boolean;
clientCapabilities: ClientCapabilities;
transport: ServerType;
/**
* Protocol era negotiated with the server (SEP §7.8). `"modern"` connections
* are sessionless and learn capabilities from `server/discover`; `"legacy"`
* (or undefined, on a plain legacy connect) use the initialize handshake.
* (#1626)
*/
protocolEra?: ProtocolEra;
/**
* The `server/discover` result on a modern connection — supported versions,
* capabilities, and extensions learned up front. Undefined on legacy. (#1626)
*/
discoverResult?: DiscoverResult;
/**
* What the client is still waiting on, when it last heard back, and the
* state of the notification stream (#2318). Renders the Connection Activity
* section when present — the same facts a request timeout reports, shown
* here while the request is still in flight. Durations first read against
* the snapshot's own `capturedAt` and then tick once a second while the
* panel is open (`useTickingClock`), so an in-flight request's age keeps
* moving rather than freezing at the moment it went out.
*/
diagnostics?: ConnectionDiagnostics;
oauth?: OAuthDetails;
onClearOAuth?: () => void;
}
// Label/value pairs in this modal read label-bold, value-normal: the label is
// the fixed scaffolding a reader scans down, and the value is the thing that
// differs per connection. The reverse (which this was until #2328) bolded every
// answer, so nothing stood out and the two columns fought each other.
const FieldLabel = Text.withProps({
size: "sm",
fw: 600,
});
const ValueText = Text.withProps({
size: "sm",
});
// A badge standing in as the *value* half of a label/value row. The app-wide
// `ThemeBadge` defaults to `fw: 600`, which is right for a standalone chip but
// makes these rows read bold-label/bold-value — the one convention this modal
// is not supposed to have. The chip still reads as a chip: its emphasis comes
// from the outline and colour, not the font weight (#2328).
const ValueBadge = Badge.withProps({ variant: "outline", fw: 400 });
// Shown for Name/Version when the server didn't report `serverInfo` — an em dash
// plus an explicit note so the client-side catalog fallback is never mistaken
// for a value the server sent. Exported so tests/stories assert against it
// rather than re-typing the copy.
export const SERVER_INFO_NOT_REPORTED_LABEL = "— (not reported by server)";
const SectionHeading = Title.withProps({
// `order: 3` (not 5) keeps the heading level one below the modal's `h2`
// `Modal.Title`, so the outline doesn't skip a level (axe `heading-order`);
// `size: "h5"` preserves the original small visual size.
order: 3,
size: "h5",
variant: "section",
});
// Long OAuth values (client id, auth URL). The `wrapping` variant wraps the
// value onto multiple lines instead of leaving it in a horizontally-scrolling
// `Code` block — that keeps the whole value visible and removes a scroll region
// that would otherwise need its own keyboard access (axe
// `scrollable-region-focusable`).
const ValueCode = Code.withProps({ variant: "wrapping-plain" });
// A long OAuth value (client id, auth URL) gets its label on its own line and
// the value across the full modal width beneath it, the way `OAuthTokenField`
// already lays out a token. In the two-column grid these values had roughly
// half the width and wrapped mid-token — `…/client-metadata.` / `json` — which
// reads as a rendering fault rather than as one URL (#2328).
const FullWidthField = Stack.withProps({ gap: 4 });
// One declared sub-option of an extension. Mirrors `CapabilityItem`'s ✓/✗ row
// rather than reusing it: that element's `capability` prop is the closed union
// of spec capability keys, and widening it to accept an arbitrary extension
// sub-option name would collapse it to `string` and lose the typo protection
// the union buys every other caller.
const SubOptionRow = Group.withProps({ gap: "xs", wrap: "nowrap" });
const SubOptionMark = Text.withProps({ fw: 600 });
const ClearOAuthButton = Button.withProps({
variant: "subtle",
color: "red",
size: "compact-xs",
});
function formatScopes(scopes: string[]): string {
return scopes.join(", ");
}
function formatProtocol(protocol: OAuthDetails["protocol"]): string {
return protocol === "ema" ? "Enterprise-managed" : "Standard";
}
function formatIdpSession(
session: NonNullable<OAuthDetails["idpSession"]>,
): string {
switch (session) {
case "logged_in":
return "Signed in";
case "expired":
return "Session expired";
default:
return "Not signed in";
}
}
function formatClientRegistrationKind(
kind: OAuthClientRegistrationKind,
): string {
switch (kind) {
case "static":
return "Static (preregistered)";
case "dcr":
return "Dynamic (DCR)";
case "cimd":
return "Client ID Metadata (CIMD)";
}
}
// `isModernEra` / `formatEra` are shared with the Protocol view via the
// EraBadge element (single source of truth for the legacy/modern distinction).
// The session concept is HTTP-only: modern HTTP connections are sessionless (no
// `Mcp-Session-Id`, nothing to DELETE on disconnect) while a legacy HTTP
// connection may carry a server session. stdio has no HTTP session at all, so
// the row is not applicable there.
function formatSession(
era: ProtocolEra | undefined,
transport: ServerType,
): string {
if (transport === "stdio") return "N/A (stdio)";
return isModernEra(era) ? "Sessionless" : "Session-based";
}
// The extension identifiers in an `extensions` capability map (SEP-2133), one
// per rendered row, or a single em dash when none are present. Works for either
// side's map: the server's negotiated `capabilities.extensions` (present on both
// eras via `getServerCapabilities()`) or the Inspector's own advertised
// `clientCapabilities.extensions`. (#1740)
//
// A list rather than a comma-joined string (#2234): an identifier is ~30
// characters and two of them wrap mid-name in a half-width column, which is
// what made the joined form hard to read at a glance.
function formatExtensions(
extensions: Record<string, unknown> | undefined,
): string[] {
const keys = extensions ? Object.keys(extensions) : [];
return keys.length > 0 ? keys : ["\u2014"];
}
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
"tools",
"resources",
"prompts",
"logging",
"completions",
"tasks",
"experimental",
];
const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [
"roots",
"sampling",
"elicitation",
"experimental",
];
export const CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL =
"Clear OAuth state and disconnect";
/**
* Server capabilities the modern (2026-07-28) era expresses as a negotiated
* *extension* rather than a top-level `capabilities` key. `tasks` is the only
* one today: SEP-2663 moved task support to
* `capabilities.extensions["io.modelcontextprotocol/tasks"]`, so a modern
* tasks-capable server left the Tasks row showing a red ✗ while the very same
* extension id was listed under "Server Extensions" two sections below
* (#1887). Keyed the same way `InspectorClient.isTasksExtensionNegotiated()`
* gates the Tasks tab, so the checkmark and the tab agree.
*/
const MODERN_EXTENSION_BACKED_CAPABILITIES: Partial<
Record<CapabilityKey, string>
> = {
tasks: TASKS_EXTENSION_KEY,
};
function isCapabilityPresent(
capabilities: Record<string, unknown>,
key: CapabilityKey,
): boolean {
return key in capabilities && capabilities[key] != null;
}
function getCapabilityEntries(
capabilities: Record<string, unknown>,
knownKeys: CapabilityKey[],
): { capability: CapabilityKey; supported: boolean }[] {
return knownKeys.map((key) => ({
capability: key,
supported: isCapabilityPresent(capabilities, key),
}));
}
/**
* Server-side capability entries. Same presence rule as the client column,
* plus the modern extension fallback above — gated on the negotiated era so a
* legacy connection is still judged purely on its `initialize` capabilities.
*/
function getServerCapabilityEntries(
capabilities: ServerCapabilities,
era: ProtocolEra | undefined,
): { capability: CapabilityKey; supported: boolean }[] {
const modern = isModernEra(era);
return SERVER_CAPABILITY_KEYS.map((key) => {
const extensionKey = MODERN_EXTENSION_BACKED_CAPABILITIES[key];
const viaExtension =
modern &&
extensionKey !== undefined &&
capabilities.extensions?.[extensionKey] != null;
return {
capability: key,
supported: isCapabilityPresent(capabilities, key) || viaExtension,
};
});
}
export function ConnectionInfoContent({
initializeResult,
serverInfoReported = true,
clientCapabilities,
transport,
protocolEra,
discoverResult,
diagnostics,
oauth,
onClearOAuth,
}: ConnectionInfoContentProps) {
const { serverInfo, protocolVersion, capabilities, instructions } =
initializeResult;
// `undefined` when the server declared no Skills extension, which is what
// hides the section below — an absent extension has no sub-flags to report.
const skillsExtension = getSkillsExtension(capabilities);
// Only trust `serverInfo` when the server actually reported it; otherwise the
// name is a catalog fallback. Both rows `?.trim()` before the `||` (not `??`)
// so a reported-but-blank name/version — empty, whitespace-only (" "), or a
// non-conforming runtime-absent field — reads as unknown ("—") rather than a
// blank row. The optional chain preserves the prior tolerance of a missing
// field (the field is typed non-null, but a non-conforming server can omit
// it); whitespace-only is the same class InspectorView's
// `resolveHeaderServerInfo` handles for the header (#1774). `initialize`
// mandates the fields, not non-empty values. Stays faithful: the fallback is
// "—", never a borrowed catalog name.
const displayName = serverInfoReported
? serverInfo.name?.trim() || "—"
: SERVER_INFO_NOT_REPORTED_LABEL;
const displayVersion = serverInfoReported
? serverInfo.version?.trim() || "—"
: SERVER_INFO_NOT_REPORTED_LABEL;
// The activity rows' clock: the snapshot's own on first paint (pure), then
// the wall clock once a second so "sent 5s ago" keeps counting. No timer
// at all when there is no activity section to tick.
const now = useTickingClock(
diagnostics?.capturedAt ?? 0,
1000,
diagnostics !== undefined,
);
const serverCaps = getServerCapabilityEntries(capabilities, protocolEra);
const clientCaps = getCapabilityEntries(
clientCapabilities,
CLIENT_CAPABILITY_KEYS,
);
return (
<Stack gap="md">
<Stack gap="xs">
<SectionHeading>Server Implementation</SectionHeading>
<SimpleGrid cols={2}>
<FieldLabel>Name</FieldLabel>
<ValueText>{displayName}</ValueText>
<FieldLabel>Version</FieldLabel>
<ValueText>{displayVersion}</ValueText>
<FieldLabel>Protocol</FieldLabel>
<ValueText>{protocolVersion || "—"}</ValueText>
<FieldLabel>Transport</FieldLabel>
<ValueBadge>{transport}</ValueBadge>
<FieldLabel>Era</FieldLabel>
<EraBadge era={protocolEra} fw={400} />
<FieldLabel>Session</FieldLabel>
<ValueText>{formatSession(protocolEra, transport)}</ValueText>
</SimpleGrid>
</Stack>
{diagnostics && (
<Stack gap="xs">
<SectionHeading>Connection Activity</SectionHeading>
<SimpleGrid cols={2}>
<FieldLabel>Unanswered requests</FieldLabel>
{/* A `Stack` so several in-flight requests read as a list, the
way the extension sections do; one line per request. */}
<Stack gap={2} data-testid="connection-activity-outstanding">
{diagnostics.outstandingRequests.length === 0 ? (
<ValueText>{NO_OUTSTANDING_REQUESTS_LABEL}</ValueText>
) : (
formatOutstandingRequests(diagnostics, now).map(
(line, index) => (
<ValueText key={diagnostics.outstandingRequests[index]!.id}>
{line}
</ValueText>
),
)
)}
</Stack>
<FieldLabel>Last response</FieldLabel>
<ValueText>{formatLastResponse(diagnostics, now)}</ValueText>
<FieldLabel>Notification stream</FieldLabel>
<ValueText>
{formatNotificationStream(
diagnostics.notificationStream,
transport,
now,
)}
</ValueText>
</SimpleGrid>
</Stack>
)}
{discoverResult && (
<Stack gap="xs">
<SectionHeading>Discovery</SectionHeading>
<SimpleGrid cols={2}>
<FieldLabel>Supported versions</FieldLabel>
<ValueText>
{discoverResult.supportedVersions.length > 0
? discoverResult.supportedVersions.join(", ")
: "—"}
</ValueText>
</SimpleGrid>
</Stack>
)}
<SimpleGrid cols={2}>
<Stack gap="xs">
<SectionHeading>Server Capabilities</SectionHeading>
{serverCaps.map((cap) => (
<CapabilityItem
key={cap.capability}
capability={cap.capability}
supported={cap.supported}
/>
))}
</Stack>
<Stack gap="xs">
<SectionHeading>Client Capabilities</SectionHeading>
{clientCaps.map((cap) => (
<CapabilityItem
key={cap.capability}
capability={cap.capability}
supported={cap.supported}
/>
))}
</Stack>
</SimpleGrid>
{/* Extensions (SEP-2133) shown for both eras: the server's from its
negotiated capabilities, the Inspector's from what it advertised
(the Advertised Extensions setting). Mirrors the server/client
capability columns above. (#1740) */}
<SimpleGrid cols={2}>
<Stack gap="xs">
<SectionHeading>Server Extensions</SectionHeading>
{/* A plain `Text`, not the bold `ValueText`: these sections list
*items*, the way the capability columns above do, rather than
giving the value half of a label/value pair. Bolding them made
them read as emphasized answers to a question the section never
asks, and set them in a different font from the checklist rows
they sit directly beneath. */}
{formatExtensions(capabilities.extensions).map((extension) => (
<Text key={extension}>{extension}</Text>
))}
</Stack>
<Stack gap="xs">
<SectionHeading>Client Advertised Extensions</SectionHeading>
{formatExtensions(clientCapabilities.extensions).map((extension) => (
<Text key={extension}>{extension}</Text>
))}
</Stack>
</SimpleGrid>
{/* Skills (SEP-2640). The "Server Extensions" row above already names the
identifier, so repeating it here would say nothing: what this section
adds is the extension's SUB-OPTIONS, which a flat list of keys cannot
show. `directoryRead` is the only one SEP-2640 defines, and whether a
server declared it is the fact a server author opens this modal to
check — it gates `resources/directory/read` (#2234). Rendered with the
same ✓/✗ vocabulary as the capability columns above so it reads as the
same kind of claim. */}
{skillsExtension && (
<Stack gap="xs">
<SectionHeading>Skills Extension Options</SectionHeading>
<SubOptionRow
data-testid="skills-directory-read"
data-supported={skillsExtension.directoryRead}
>
<SubOptionMark c={skillsExtension.directoryRead ? "green" : "red"}>
{skillsExtension.directoryRead ? "\u2713" : "\u2717"}
</SubOptionMark>
<Text>
Directory read — <Code>resources/directory/read</Code>
</Text>
</SubOptionRow>
</Stack>
)}
{instructions && (
<Stack gap="xs">
<SectionHeading>Server Instructions</SectionHeading>
{/* Cap the instructions block so a long server prompt scrolls
inside the section instead of pushing the OAuth section and
modal chrome off-screen. */}
<ScrollArea.Autosize mah={280}>
<ContentViewer
block={{ type: "text", text: instructions }}
copyable
/>
</ScrollArea.Autosize>
</Stack>
)}
{oauth && (
<Stack gap="xs">
<SectionHeading>OAuth Details</SectionHeading>
<Stack gap="xs">
<SimpleGrid cols={2}>
<FieldLabel>Protocol</FieldLabel>
<ValueText>{formatProtocol(oauth.protocol)}</ValueText>
<FieldLabel>Status</FieldLabel>
<ValueBadge color={oauth.authorized ? "green" : "gray"}>
{oauth.authorized ? "Authorized" : "Not authorized"}
</ValueBadge>
</SimpleGrid>
{oauth.clientRegistrationKind && (
<SimpleGrid cols={2}>
<FieldLabel>Client registration</FieldLabel>
<ValueText>
{formatClientRegistrationKind(oauth.clientRegistrationKind)}
</ValueText>
</SimpleGrid>
)}
{oauth.protocol === "ema" && oauth.idpSession && (
<SimpleGrid cols={2}>
<FieldLabel>IdP session</FieldLabel>
<ValueText>{formatIdpSession(oauth.idpSession)}</ValueText>
</SimpleGrid>
)}
{oauth.scopes && oauth.scopes.length > 0 && (
<SimpleGrid cols={2}>
<FieldLabel>Scopes</FieldLabel>
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{/* The full-width fields are kept together at the end of the
section, directly above the token rows, which use the same
label-over-value layout. Interleaved with the inline two-column
rows they broke the scan down the label column for every row
after them, and Client ID — the one most likely to be long —
was the worst offender (#2328). */}
{oauth.authUrl && (
<FullWidthField>
<FieldLabel>Auth URL</FieldLabel>
<ValueCode>{oauth.authUrl}</ValueCode>
</FullWidthField>
)}
{oauth.clientId && (
<FullWidthField>
<FieldLabel>Client ID</FieldLabel>
<ValueCode>{oauth.clientId}</ValueCode>
</FullWidthField>
)}
{oauth.accessToken && (
<OAuthTokenField
label="Access Token"
token={oauth.accessToken}
onClear={onClearOAuth}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>
)}
{/* Viewer only — an `id_token` the AS happened to return, decoded
on request. It carries no clear action: clearing OAuth state is
one action for the whole token set, owned by the access-token
row (or the standalone button below when there is none). */}
{oauth.idToken && (
<OAuthTokenField label="ID Token" token={oauth.idToken} />
)}
{!oauth.accessToken && onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)}
</Stack>
</Stack>
)}
</Stack>
);
}