inspector/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx
Cliff Hall 820d38c438
fix(web): render the connected header when a modern server omits serverInfo (#1773)
A modern-era server/discover makes serverInfo optional (SHOULD, not MUST); a conforming modern server that omits it left initializeResult undefined, collapsing the entire connected header + tab bar so the server was uninspectable. Gate initializeResult on connectionStatus alone with a catalog-name fallback, and thread a serverInfoReported flag so the Connection Info modal reports the omission truthfully instead of presenting the inferred name as server-sent. Closes #1772.
2026-07-25 11:40:31 -04:00

387 lines
12 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,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import type {
ClientCapabilities,
DiscoverResult,
InitializeResult,
ProtocolEra,
} from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.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 { OAuthAccessTokenField } from "./OAuthAccessTokenField";
export interface OAuthDetails {
protocol: "standard" | "ema";
authorized: boolean;
clientId?: string;
clientRegistrationKind?: OAuthClientRegistrationKind;
authUrl?: string;
scopes?: string[];
accessToken?: 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;
oauth?: OAuthDetails;
onClearOAuth?: () => void;
}
const ValueText = Text.withProps({
size: "sm",
fw: 600,
});
// 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" });
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";
}
// Render an `extensions` capability map (SEP-2133) as a comma-separated list of
// its extension identifiers, or an 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)
function formatExtensions(
extensions: Record<string, unknown> | undefined,
): string {
const keys = extensions ? Object.keys(extensions) : [];
return keys.length > 0 ? keys.join(", ") : "—";
}
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";
function getCapabilityEntries(
capabilities: Record<string, unknown>,
knownKeys: CapabilityKey[],
): { capability: CapabilityKey; supported: boolean }[] {
return knownKeys.map((key) => ({
capability: key,
supported: key in capabilities && capabilities[key] != null,
}));
}
export function ConnectionInfoContent({
initializeResult,
serverInfoReported = true,
clientCapabilities,
transport,
protocolEra,
discoverResult,
oauth,
onClearOAuth,
}: ConnectionInfoContentProps) {
const { serverInfo, protocolVersion, capabilities, instructions } =
initializeResult;
// Only trust `serverInfo` when the server actually reported it; otherwise the
// name is a catalog fallback. Both rows use `||` (not `??`) so a reported-but-
// empty name/version reads as unknown ("—") rather than a blank row —
// `initialize` mandates the fields, not non-empty values.
const displayName = serverInfoReported
? serverInfo.name || "—"
: SERVER_INFO_NOT_REPORTED_LABEL;
const displayVersion = serverInfoReported
? serverInfo.version || "—"
: SERVER_INFO_NOT_REPORTED_LABEL;
const serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS);
const clientCaps = getCapabilityEntries(
clientCapabilities,
CLIENT_CAPABILITY_KEYS,
);
return (
<Stack gap="md">
<Stack gap="xs">
<SectionHeading>Server Implementation</SectionHeading>
<SimpleGrid cols={2}>
<Text size="sm">Name</Text>
<ValueText>{displayName}</ValueText>
<Text size="sm">Version</Text>
<ValueText>{displayVersion}</ValueText>
<Text size="sm">Protocol</Text>
<ValueText>{protocolVersion || "—"}</ValueText>
<Text size="sm">Transport</Text>
<Badge variant="outline">{transport}</Badge>
<Text size="sm">Era</Text>
<EraBadge era={protocolEra} />
<Text size="sm">Session</Text>
<ValueText>{formatSession(protocolEra, transport)}</ValueText>
</SimpleGrid>
</Stack>
{discoverResult && (
<Stack gap="xs">
<SectionHeading>Discovery</SectionHeading>
<SimpleGrid cols={2}>
<Text size="sm">Supported versions</Text>
<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>
<ValueText>{formatExtensions(capabilities.extensions)}</ValueText>
</Stack>
<Stack gap="xs">
<SectionHeading>Client Advertised Extensions</SectionHeading>
<ValueText>
{formatExtensions(clientCapabilities.extensions)}
</ValueText>
</Stack>
</SimpleGrid>
{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}>
<Text size="sm">Protocol</Text>
<ValueText>{formatProtocol(oauth.protocol)}</ValueText>
<Text size="sm">Status</Text>
<Badge
variant="outline"
color={oauth.authorized ? "green" : "gray"}
>
{oauth.authorized ? "Authorized" : "Not authorized"}
</Badge>
</SimpleGrid>
{oauth.clientId && (
<SimpleGrid cols={2}>
<Text size="sm">Client ID</Text>
<ValueCode>{oauth.clientId}</ValueCode>
</SimpleGrid>
)}
{oauth.clientRegistrationKind && (
<SimpleGrid cols={2}>
<Text size="sm">Client registration</Text>
<ValueText>
{formatClientRegistrationKind(oauth.clientRegistrationKind)}
</ValueText>
</SimpleGrid>
)}
{oauth.protocol === "ema" && oauth.idpSession && (
<SimpleGrid cols={2}>
<Text size="sm">IdP session</Text>
<ValueText>{formatIdpSession(oauth.idpSession)}</ValueText>
</SimpleGrid>
)}
{oauth.authUrl && (
<SimpleGrid cols={2}>
<Text size="sm">Auth URL</Text>
<ValueCode>{oauth.authUrl}</ValueCode>
</SimpleGrid>
)}
{oauth.scopes && oauth.scopes.length > 0 && (
<SimpleGrid cols={2}>
<Text size="sm">Scopes</Text>
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{oauth.accessToken ? (
<OAuthAccessTokenField
accessToken={oauth.accessToken}
onClear={onClearOAuth}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>
) : (
onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)
)}
</Stack>
</Stack>
)}
</Stack>
);
}