feat(connection-info): wire Connection Info modal to the connection handshake (#1377)

* feat(server-info): wire Server Info modal to the connection handshake

Closes #1364

- Add ServerInfoModal wrapping the existing ServerInfoContent, mirroring the
  ServerSettingsModal layout.
- Gate the Server Info button on ServerCard to render only when the
  connection is "connected".
- Expose `getClientCapabilities()` on InspectorClient (and protocol +
  FakeInspectorClient), and surface it through useInspectorClient so the
  modal can render the client side of the capability handshake without
  poking the SDK Client's private state.
- App.tsx: open the modal from the gated Server Info button, derive the
  active server's transport and OAuth details (synchronously, from the
  guided-OAuth state snapshot + persisted settings), and reset the open
  flag on InspectorClient `disconnect` so the modal does not auto-reopen
  on a future reconnect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-info): dedupe modal header and render instructions in ContentViewer

- Drop the in-content "Server Information" Title from ServerInfoContent and
  rename the modal title to "Server Information" so the modal renders one
  heading instead of two stacked ones.
- Swap the instructions Blockquote for ContentViewer (copyable, with the
  existing wrapping Code variant) so long instructions wrap and the user
  can copy the full payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server-info): rename ServerInfo -> ConnectionInfo

The popup covers server identity AND client capabilities AND OAuth state —
it's a snapshot of the live connection, not just the server. Rename to match
what it actually shows. Also add a "Server Implementation" heading above
the top section (name/version/protocol/transport) to balance the existing
capability headings.

- ServerInfoContent -> ConnectionInfoContent (directory, file, exports)
- ServerInfoModal -> ConnectionInfoModal (directory, file, exports)
- ServerCard button label "Server Info" -> "Connection Info";
  prop onServerInfo -> onConnectionInfo
- Modal title -> "Connection Info"
- App.tsx / ServerListScreen / InspectorView wiring updated to match

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(connection-info): cap instructions block height so it scrolls in place

A long server prompt (e.g. an Everything-style instruction document) was
pushing the OAuth section and the modal chrome off-screen — users had to
scroll the whole modal to reach the close button. Wrap the ContentViewer
in ScrollArea.Autosize with mah=280 so the instructions section scrolls
internally and the rest of the modal stays put.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(connection-info): add subtle background to section headings

Add a `section` variant for Title that paints the heading in the existing
`--inspector-surface-subtle` token (same surface token already used for
modal call-out panels). Apply it to each section heading in
ConnectionInfoContent (Server Implementation, Server Capabilities, Client
Capabilities, Server Instructions, OAuth Details) via a SectionHeading
subcomponent constant, so the sections read as distinct cards against the
modal background instead of bleeding into one stream of text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(stories): wrap long import line in ConnectionInfoModal.stories.tsx

Prettier flagged the import in CI. The line exceeded printWidth=80 and
needed to wrap onto multiple lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): require `npm run format` before committing

Add a pre-commit instruction to run the auto-fixing `npm run format` ahead
of `npm run validate`. `validate` runs `format:check` (non-fixing) which
will fail CI on any unformatted file — running the auto-fixer first
eliminates that class of failure entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(favicon): use theme/assets/MCP.svg as the favicon source of truth

Point index.html's icon link at /src/theme/assets/MCP.svg so the same MCP
logo asset already imported by ViewHeader is also the favicon. Delete the
now-unused clients/web/public/favicon.svg. Vite resolves the source path
during build and emits a hashed copy under dist/assets/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(server-card): surface failed-handshake errors with a 5s auto-dismiss

Two related fixes so the user actually sees what went wrong when a
connect attempt fails (e.g. OAuth-backed server, bad URL, missing dev
auth token):

- App.tsx: add `connectInFlightRef` and skip clearing `activeServerId`
  in the InspectorClient `disconnect` listener while a connect attempt
  is in flight. The transport closes during a failing handshake and
  dispatches `disconnect` before the catch block sets `errorMessage`;
  without this guard InspectorView would map the card to plain
  "disconnected" and the InlineError would never render.

- InlineError: opt-in `autoDismissMs` prop that runs a Mantine `slide-up`
  Transition exit animation after the message has been visible for the
  given duration. Visibility is derived from a "dismissed message"
  state (rather than a mounted boolean + setMounted-in-effect), so the
  timer re-arms whenever `error.message` changes without tripping the
  react-hooks/set-state-in-effect rule. ServerCard passes
  autoDismissMs={5000}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server-card): own InlineError visibility + animate parent-driven removal

Move the 5s auto-dismiss timer and visibility state out of InlineError
and into ServerCard so the slide-up exit animation runs in both cases:

- Timer-driven: after 5s the alert animates out (existing behavior).
- Parent-driven: when `connection.error` is cleared mid-flight (e.g. a
  successful reconnect), the alert ALSO slides up instead of vanishing.

To keep the message painted during the parent-driven exit, ServerCard
tracks a `lastError` state that mirrors `connection.error` only on its
truthy edges. The conditional setState during render is the supported
React pattern for "remember the last seen value of a prop"; gated on
message inequality so it can't loop. The `useRef` version of this
pattern failed `react-hooks/refs` (no ref writes/reads during render).

InlineError is now a pure presentational component: `mounted` prop
controls a Mantine `slide-up` Transition. The internal `autoDismissMs`
prop is gone — its single caller has moved to the controlled `mounted`
pattern.

Closes the "error vanishes the moment the parent removes it" half of #1364
(the timer half landed in 6d147e0f).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server-card): surface connection errors as a toast, drop InlineError

Move handshake-failure messaging out of the ServerCard body and onto a
Mantine toast (`notifications.show`). The inline alert had a 5-second
visibility contract entangled with InspectorView's status pipeline, and
the active card was flipping through error → disconnected → error during
a failing handshake; surfacing the error as a toast sidesteps that whole
timing problem and keeps the card focused on the live status indicator.

- App.tsx: toast on the connect-catch path; drop `errorMessage` state +
  `connectInFlightRef` (the latter was only there to keep the inline
  alert visible past the transport's close event).
- InspectorView: drop the `errorMessage` prop and the
  `connectionStatus === "error" && errorMessage` mapping branch.
- ServerCard: drop the InlineError rendering + the displayedError /
  errorVisible visibility state machine.
- Delete the InlineError component — no other consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(notifications): anchor toasts to the bottom right

Move the Notifications portal from top-right to bottom-right so a
connection-failure toast doesn't overlap the ViewHeader / tabs row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: address PR feedback on the Connection Info modal

1. Hoist `EMPTY_CLIENT_CAPABILITIES = Object.freeze({})` to module scope
   in `useInspectorClient` so the fallback returns a stable reference
   (avoids invalidating downstream `useMemo`/`useEffect` deps when no
   client is attached).
4. Drop the `?? "stdio"` silent fallback on `connectionInfoTransport`
   in the JSX — gate the modal render on `activeServer` too. The
   "stdio" default still lives at the derivation site (config.type is
   schema-optional and `ServerType` is required by the modal prop),
   but the gate makes the invariant explicit.
6. Replace the raw `document.querySelector("button.mantine-CloseButton-root")`
   in `ConnectionInfoModal.test.tsx` with `screen.getByRole("button",
   { name: /close/i })`. Add `aria-label="Close"` to the
   CloseButton so the assertion (and screen readers) have a name to
   match on.
7. Switch the OAuth scope splitter from `/\s+/` to the literal `" "`
   per OAuth 2.1 §3.3.

Also clarify in comments:
2. Note that `connectionInfoOAuth` uses snapshot-at-open semantics on
   purpose — token refreshes mid-session won't re-render the modal.
5. Note that we prefer the AS-advertised `authorization_endpoint`
   over `authorizationUrl` because the latter is per-flight noise
   (state, code_challenge) rather than a stable identifier.

Finding 3 (modal exit animation short-circuited by the `{initializeResult
&& …}` guard) left as intentional — we want the snap-shut on disconnect
so a stale info dialog can't keep rendering with no live client behind it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(connection-info): tighten the OAuth memo comment per re-review

The reviewer noted that "snapshot-at-open" was imprecise — the memo
re-runs on any of its deps (connectionStatus, inspectorClient,
activeServer), which includes settings edits that re-reference the
active server. Reframe as "snapshot-at-last-derivation" and call out
the natural triggers (server switch, reconnect, settings edit) so a
future reader doesn't conclude the memo is open-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-30 16:42:37 -04:00 • committed by GitHub
commit e3b0b6a908
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 569 additions and 331 deletions

View file

@ -0,0 +1,92 @@
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ConnectionInfoContent } from "./ConnectionInfoContent";
const fullResult: InitializeResult = {
protocolVersion: "2025-03-26",
serverInfo: { name: "Everything Server", version: "2.1.0" },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true, listChanged: true },
prompts: { listChanged: true },
logging: {},
completions: {},
},
instructions:
"This server provides access to the project management system. Use the list_projects tool first to discover available projects before querying tasks. Rate limiting applies: max 60 requests per minute.",
};
const fullClientCaps: ClientCapabilities = {
roots: { listChanged: true },
sampling: {},
elicitation: {},
experimental: {},
};
const meta: Meta<typeof ConnectionInfoContent> = {
title: "Groups/ConnectionInfoContent",
component: ConnectionInfoContent,
};
export default meta;
type Story = StoryObj<typeof ConnectionInfoContent>;
export const FullCapabilities: Story = {
args: {
initializeResult: fullResult,
clientCapabilities: fullClientCaps,
transport: "stdio",
},
};
export const MinimalCapabilities: Story = {
args: {
initializeResult: {
protocolVersion: "2025-03-26",
serverInfo: { name: "Simple Server", version: "1.0.0" },
capabilities: {
tools: { listChanged: false },
},
},
clientCapabilities: {
roots: { listChanged: true },
},
transport: "streamable-http",
},
};
export const WithInstructions: Story = {
args: {
initializeResult: fullResult,
clientCapabilities: {
roots: { listChanged: true },
sampling: {},
},
transport: "stdio",
},
};
export const WithOAuth: Story = {
args: {
initializeResult: {
protocolVersion: "2025-03-26",
serverInfo: { name: "Authenticated Server", version: "3.0.0" },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true },
},
},
clientCapabilities: {
roots: { listChanged: true },
},
transport: "streamable-http",
oauth: {
authUrl: "https://auth.example.com/oauth2/authorize",
scopes: ["read", "write", "admin"],
accessToken: "eyJhbGciOiJSUzI1NiIs...truncated",
},
},
};

View file

@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ConnectionInfoContent } from "./ConnectionInfoContent";
const fullResult: InitializeResult = {
protocolVersion: "2025-03-26",
serverInfo: { name: "Everything Server", version: "2.1.0" },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true },
prompts: { listChanged: true },
logging: {},
completions: {},
},
instructions: "Use tools/list first.",
};
const fullClientCaps: ClientCapabilities = {
roots: { listChanged: true },
sampling: {},
elicitation: {},
experimental: {},
};
describe("ConnectionInfoContent", () => {
it("renders server implementation fields under the heading", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
/>,
);
expect(screen.getByText("Server Implementation")).toBeInTheDocument();
expect(screen.getByText("Everything Server")).toBeInTheDocument();
expect(screen.getByText("2.1.0")).toBeInTheDocument();
expect(screen.getByText("2025-03-26")).toBeInTheDocument();
expect(screen.getByText("stdio")).toBeInTheDocument();
});
it("renders an em-dash when server version is missing", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={{
...fullResult,
serverInfo: { name: "No Version Server" } as never,
}}
clientCapabilities={fullClientCaps}
transport="stdio"
/>,
);
expect(screen.getByText("—")).toBeInTheDocument();
});
it("renders server and client capability sections", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
/>,
);
expect(screen.getByText("Server Capabilities")).toBeInTheDocument();
expect(screen.getByText("Client Capabilities")).toBeInTheDocument();
expect(screen.getByText("Tools")).toBeInTheDocument();
expect(screen.getByText("Resources")).toBeInTheDocument();
expect(screen.getByText("Roots")).toBeInTheDocument();
expect(screen.getByText("Sampling")).toBeInTheDocument();
});
it("renders instructions when present", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
/>,
);
expect(screen.getByText("Server Instructions")).toBeInTheDocument();
expect(screen.getByText("Use tools/list first.")).toBeInTheDocument();
});
it("omits the instructions section when not present", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={{ ...fullResult, instructions: undefined }}
clientCapabilities={fullClientCaps}
transport="stdio"
/>,
);
expect(screen.queryByText("Server Instructions")).not.toBeInTheDocument();
});
it("renders OAuth details when provided", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
authUrl: "https://auth.example.com/authorize",
scopes: ["read", "write"],
accessToken: "token-123",
}}
/>,
);
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
expect(screen.getByText("Auth URL")).toBeInTheDocument();
expect(
screen.getByText("https://auth.example.com/authorize"),
).toBeInTheDocument();
expect(screen.getByText("read, write")).toBeInTheDocument();
expect(screen.getByText("token-123")).toBeInTheDocument();
});
it("hides OAuth fields that are not provided", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
oauth={{ scopes: [] }}
/>,
);
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
});
it("does not render OAuth section when oauth prop is omitted", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
/>,
);
expect(screen.queryByText("OAuth Details")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,174 @@
import {
Badge,
Code,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import type { ServerType } from "@inspector/core/mcp/types.js";
import {
CapabilityItem,
type CapabilityKey,
} from "../../elements/CapabilityItem/CapabilityItem";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
export interface OAuthDetails {
authUrl?: string;
scopes?: string[];
accessToken?: string;
}
export interface ConnectionInfoContentProps {
initializeResult: InitializeResult;
clientCapabilities: ClientCapabilities;
transport: ServerType;
oauth?: OAuthDetails;
}
const ValueText = Text.withProps({
size: "sm",
fw: 600,
});
const SectionHeading = Title.withProps({
order: 5,
variant: "section",
});
function formatScopes(scopes: string[]): string {
return scopes.join(", ");
}
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
"tools",
"resources",
"prompts",
"logging",
"completions",
"tasks",
"experimental",
];
const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [
"roots",
"sampling",
"elicitation",
"experimental",
];
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,
clientCapabilities,
transport,
oauth,
}: ConnectionInfoContentProps) {
const { serverInfo, protocolVersion, capabilities, instructions } =
initializeResult;
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>{serverInfo.name}</ValueText>
<Text size="sm">Version</Text>
<ValueText>{serverInfo.version ?? "—"}</ValueText>
<Text size="sm">Protocol</Text>
<ValueText>{protocolVersion}</ValueText>
<Text size="sm">Transport</Text>
<Badge variant="outline">{transport}</Badge>
</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>
{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">
{oauth.authUrl && (
<SimpleGrid cols={2}>
<Text size="sm">Auth URL</Text>
<Code>{oauth.authUrl}</Code>
</SimpleGrid>
)}
{oauth.scopes && oauth.scopes.length > 0 && (
<SimpleGrid cols={2}>
<Text size="sm">Scopes</Text>
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{oauth.accessToken && (
<SimpleGrid cols={2}>
<Text size="sm">Access Token</Text>
<Code>{oauth.accessToken}</Code>
</SimpleGrid>
)}
</Stack>
</Stack>
)}
</Stack>
);
}

View file

@ -0,0 +1,106 @@
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import {
ConnectionInfoModal,
type ConnectionInfoModalProps,
} from "./ConnectionInfoModal";
const initializeResult: InitializeResult = {
protocolVersion: "2025-06-18",
serverInfo: { name: "Everything Server", version: "2.1.0" },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true, listChanged: true },
prompts: { listChanged: true },
logging: {},
completions: {},
},
instructions:
"This server provides access to the project management system. Use the list_projects tool first to discover available projects before querying tasks.",
};
const clientCapabilities: ClientCapabilities = {
elicitation: { form: {}, url: {} },
tasks: {
list: {},
cancel: {},
requests: {
sampling: { createMessage: {} },
elicitation: { create: {} },
},
},
};
function InteractiveRender(args: ConnectionInfoModalProps) {
return (
<AppShell>
<AppShell.Main>
<ConnectionInfoModal {...args} />
</AppShell.Main>
</AppShell>
);
}
const meta: Meta<typeof ConnectionInfoModal> = {
title: "Groups/ConnectionInfoModal",
component: ConnectionInfoModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
onClose: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ConnectionInfoModal>;
export const StdioConnected: Story = {
args: {
initializeResult,
clientCapabilities,
transport: "stdio",
},
};
export const WithOAuth: Story = {
args: {
initializeResult: {
...initializeResult,
serverInfo: { name: "Authenticated Server", version: "3.0.0" },
},
clientCapabilities,
transport: "streamable-http",
oauth: {
authUrl: "https://auth.example.com/oauth2/authorize",
scopes: ["read", "write"],
accessToken: "eyJhbGciOiJSUzI1NiIs...truncated",
},
},
};
export const MinimalCapabilities: Story = {
args: {
initializeResult: {
protocolVersion: "2025-03-26",
serverInfo: { name: "Simple Server", version: "1.0.0" },
capabilities: { tools: { listChanged: false } },
},
clientCapabilities: {},
transport: "stdio",
},
};
export const Closed: Story = {
args: {
opened: false,
initializeResult,
clientCapabilities,
transport: "stdio",
},
};

View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ConnectionInfoModal } from "./ConnectionInfoModal";
const initializeResult: InitializeResult = {
protocolVersion: "2025-06-18",
serverInfo: { name: "Test Server", version: "0.1.0" },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true },
},
instructions: "Be excellent to each other.",
};
const clientCapabilities: ClientCapabilities = {
elicitation: { form: {} },
tasks: { list: {}, cancel: {} },
};
describe("ConnectionInfoModal", () => {
it("does not render content when opened is false", () => {
renderWithMantine(
<ConnectionInfoModal
opened={false}
onClose={vi.fn()}
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport="stdio"
/>,
);
expect(screen.queryByText("Connection Info")).not.toBeInTheDocument();
});
it("renders the modal title and ConnectionInfoContent when opened", () => {
renderWithMantine(
<ConnectionInfoModal
opened
onClose={vi.fn()}
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport="stdio"
/>,
);
expect(screen.getByText("Connection Info")).toBeInTheDocument();
expect(screen.getByText("Test Server")).toBeInTheDocument();
expect(screen.getByText("0.1.0")).toBeInTheDocument();
expect(screen.getByText("2025-06-18")).toBeInTheDocument();
expect(screen.getByText("stdio")).toBeInTheDocument();
expect(screen.getByText("Be excellent to each other.")).toBeInTheDocument();
});
it("forwards oauth details to ConnectionInfoContent", () => {
renderWithMantine(
<ConnectionInfoModal
opened
onClose={vi.fn()}
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport="streamable-http"
oauth={{
authUrl: "https://auth.example.com/authorize",
scopes: ["read", "write"],
accessToken: "token-abc",
}}
/>,
);
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
expect(
screen.getByText("https://auth.example.com/authorize"),
).toBeInTheDocument();
expect(screen.getByText("read, write")).toBeInTheDocument();
expect(screen.getByText("token-abc")).toBeInTheDocument();
});
it("invokes onClose when the CloseButton is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ConnectionInfoModal
opened
onClose={onClose}
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport="stdio"
/>,
);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(onClose).toHaveBeenCalled();
});
it("invokes onClose when Escape is pressed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ConnectionInfoModal
opened
onClose={onClose}
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport="stdio"
/>,
);
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,53 @@
import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core";
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import type { ServerType } from "@inspector/core/mcp/types.js";
import {
ConnectionInfoContent,
type OAuthDetails,
} from "../ConnectionInfoContent/ConnectionInfoContent";
export interface ConnectionInfoModalProps {
opened: boolean;
onClose: () => void;
initializeResult: InitializeResult;
clientCapabilities: ClientCapabilities;
transport: ServerType;
oauth?: OAuthDetails;
}
export function ConnectionInfoModal({
opened,
onClose,
initializeResult,
clientCapabilities,
transport,
oauth,
}: ConnectionInfoModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton={false}
size="lg"
centered
>
<Stack gap="md">
<Group justify="space-between" wrap="nowrap">
<Title order={4} flex={1}>
Connection Info
</Title>
<CloseButton aria-label="Close" onClick={onClose} />
</Group>
<ConnectionInfoContent
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport={transport}
oauth={oauth}
/>
</Stack>
</Modal>
);
}

View file

@ -33,7 +33,7 @@ const meta: Meta<typeof ServerCard> = {
component: ServerCard,
args: {
onToggleConnection: fn(),
onServerInfo: fn(),
onConnectionInfo: fn(),
onSettings: fn(),
onEdit: fn(),
onClone: fn(),

View file

@ -23,6 +23,7 @@ const sseConfig: MCPServerConfig = {
const connected: ConnectionState = { status: "connected" };
const disconnected: ConnectionState = { status: "disconnected" };
const connecting: ConnectionState = { status: "connecting" };
const errored: ConnectionState = {
status: "error",
retryCount: 2,
@ -31,7 +32,7 @@ const errored: ConnectionState = {
const handlers = {
onToggleConnection: vi.fn(),
onServerInfo: vi.fn(),
onConnectionInfo: vi.fn(),
onSettings: vi.fn(),
onEdit: vi.fn(),
onClone: vi.fn(),
@ -80,11 +81,6 @@ describe("ServerCard", () => {
expect(screen.getByText("https://api.example.com/sse")).toBeInTheDocument();
});
it("renders the InlineError when connection has an error", () => {
renderWithMantine(<ServerCard {...baseProps} connection={errored} />);
expect(screen.getByText("Connection refused")).toBeInTheDocument();
});
it("hides body content in compact mode", () => {
renderWithMantine(
<ServerCard {...baseProps} connection={disconnected} compact />,
@ -100,7 +96,7 @@ describe("ServerCard", () => {
const onClone = vi.fn();
const onEdit = vi.fn();
const onRemove = vi.fn();
const onServerInfo = vi.fn();
const onConnectionInfo = vi.fn();
const onSettings = vi.fn();
renderWithMantine(
<ServerCard
@ -108,19 +104,19 @@ describe("ServerCard", () => {
onClone={onClone}
onEdit={onEdit}
onRemove={onRemove}
onServerInfo={onServerInfo}
onConnectionInfo={onConnectionInfo}
onSettings={onSettings}
/>,
);
await user.click(screen.getByRole("button", { name: "Clone" }));
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Remove" }));
await user.click(screen.getByRole("button", { name: "Server Info" }));
await user.click(screen.getByRole("button", { name: "Connection Info" }));
await user.click(screen.getByRole("button", { name: "Settings" }));
expect(onClone).toHaveBeenCalledWith("srv-1");
expect(onEdit).toHaveBeenCalledWith("srv-1");
expect(onRemove).toHaveBeenCalledWith("srv-1");
expect(onServerInfo).toHaveBeenCalledWith("srv-1");
expect(onConnectionInfo).toHaveBeenCalledWith("srv-1");
expect(onSettings).toHaveBeenCalledWith("srv-1");
});
@ -157,4 +153,39 @@ describe("ServerCard", () => {
renderWithMantine(<ServerCard {...baseProps} info={undefined} />);
expect(screen.queryByText("1.2.0")).not.toBeInTheDocument();
});
it("renders Server Info button when connected", () => {
renderWithMantine(<ServerCard {...baseProps} connection={connected} />);
expect(
screen.getByRole("button", { name: "Connection Info" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Settings" }),
).toBeInTheDocument();
});
it.each([
["disconnected", disconnected],
["connecting", connecting],
["error", errored],
] as const)(
"omits Server Info button when status is %s but keeps Settings",
(_label, state) => {
renderWithMantine(<ServerCard {...baseProps} connection={state} />);
expect(
screen.queryByRole("button", { name: "Connection Info" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Settings" }),
).toBeInTheDocument();
},
);
it("does not render an InlineError when the connection has an error", () => {
// Handshake errors are surfaced via a toast at the App level
// (notifications.show); the card itself stays focused on the
// ConnectionToggle status indicator.
renderWithMantine(<ServerCard {...baseProps} connection={errored} />);
expect(screen.queryByText("Connection refused")).not.toBeInTheDocument();
});
});

View file

@ -8,12 +8,11 @@ import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/Serv
import { TransportBadge } from "../../elements/TransportBadge/TransportBadge";
import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { InlineError } from "../../elements/InlineError/InlineError";
export interface ServerCardProps extends ServerEntry {
activeServer?: string;
onToggleConnection: (id: string) => void;
onServerInfo: (id: string) => void;
onConnectionInfo: (id: string) => void;
onSettings: (id: string) => void;
onEdit: (id: string) => void;
onClone: (id: string) => void;
@ -92,7 +91,7 @@ export function ServerCard({
connection,
activeServer,
onToggleConnection,
onServerInfo,
onConnectionInfo,
onSettings,
onEdit,
onClone,
@ -142,16 +141,6 @@ export function ServerCard({
copyable
/>
{connection.error && (
<InlineError
error={{
message: connection.error.message,
data: connection.error.details,
}}
retryCount={connection.retryCount}
/>
)}
<Group justify="space-between">
<ActionsRow>
<SubtleButton onClick={() => onClone(id)}>Clone</SubtleButton>
@ -159,9 +148,11 @@ export function ServerCard({
<RemoveButton onClick={() => onRemove(id)}>Remove</RemoveButton>
</ActionsRow>
<ActionsRow>
<SubtleButton onClick={() => onServerInfo(id)}>
Server Info
</SubtleButton>
{connection.status === "connected" && (
<SubtleButton onClick={() => onConnectionInfo(id)}>
Connection Info
</SubtleButton>
)}
<SubtleButton onClick={() => onSettings(id)}>
Settings
</SubtleButton>