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:
parent
dd0f5de820
commit
e3b0b6a908
31 changed files with 569 additions and 331 deletions
|
|
@ -109,6 +109,7 @@ All work should be driven by items on the project board.
|
||||||
- after making the changes, respond to each review comment with what was done (or why it was ignored)
|
- after making the changes, respond to each review comment with what was done (or why it was ignored)
|
||||||
|
|
||||||
### Lint-fixed, Formatted code
|
### Lint-fixed, Formatted code
|
||||||
|
- ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
|
||||||
- ALWAYS do `npm run validate` before pushing any changes, this runs the various lint, build, format checks, etc.
|
- ALWAYS do `npm run validate` before pushing any changes, this runs the various lint, build, format checks, etc.
|
||||||
- Also run `npm run test:storybook` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step after the unit/lint/build checks; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite.
|
- Also run `npm run test:storybook` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step after the unit/lint/build checks; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/src/theme/assets/MCP.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
MessageEntry,
|
MessageEntry,
|
||||||
ServerEntry,
|
ServerEntry,
|
||||||
|
ServerType,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
import { API_SERVER_ENV_VARS } from "@inspector/core/mcp/remote/constants.js";
|
import { API_SERVER_ENV_VARS } from "@inspector/core/mcp/remote/constants.js";
|
||||||
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
|
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
|
||||||
|
|
@ -50,6 +51,8 @@ import {
|
||||||
type ServerConfigModalMode,
|
type ServerConfigModalMode,
|
||||||
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
||||||
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
|
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
|
||||||
|
import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
|
||||||
|
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||||
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
|
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
|
||||||
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
||||||
import { createWebEnvironment } from "./lib/environmentFactory";
|
import { createWebEnvironment } from "./lib/environmentFactory";
|
||||||
|
|
@ -176,6 +179,7 @@ function App() {
|
||||||
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
|
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
const [connectionInfoModalOpen, setConnectionInfoModalOpen] = useState(false);
|
||||||
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(null);
|
||||||
|
|
||||||
// The active connection target. `null` between sessions; set as soon as
|
// The active connection target. `null` between sessions; set as soon as
|
||||||
|
|
@ -232,9 +236,6 @@ function App() {
|
||||||
// intervening rerenders don't reset it.
|
// intervening rerenders don't reset it.
|
||||||
const connectStartRef = useRef<number | undefined>(undefined);
|
const connectStartRef = useRef<number | undefined>(undefined);
|
||||||
const [latencyMs, setLatencyMs] = useState<number | undefined>(undefined);
|
const [latencyMs, setLatencyMs] = useState<number | undefined>(undefined);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hook layer. Each hook subscribes to its respective event source and
|
// Hook layer. Each hook subscribes to its respective event source and
|
||||||
// re-renders the App on change. When `inspectorClient` / state managers
|
// re-renders the App on change. When `inspectorClient` / state managers
|
||||||
|
|
@ -242,6 +243,7 @@ function App() {
|
||||||
const {
|
const {
|
||||||
status: connectionStatus,
|
status: connectionStatus,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
clientCapabilities,
|
||||||
serverInfo,
|
serverInfo,
|
||||||
instructions,
|
instructions,
|
||||||
} = useInspectorClient(inspectorClient);
|
} = useInspectorClient(inspectorClient);
|
||||||
|
|
@ -314,6 +316,9 @@ function App() {
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
const onDisconnect = () => {
|
const onDisconnect = () => {
|
||||||
setActiveServerId(undefined);
|
setActiveServerId(undefined);
|
||||||
|
// Drop the open flag too — without this the modal would pop back the
|
||||||
|
// next time `initializeResult` re-becomes truthy (e.g. reconnect).
|
||||||
|
setConnectionInfoModalOpen(false);
|
||||||
};
|
};
|
||||||
inspectorClient.addEventListener("disconnect", onDisconnect);
|
inspectorClient.addEventListener("disconnect", onDisconnect);
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -335,6 +340,73 @@ function App() {
|
||||||
};
|
};
|
||||||
}, [connectionStatus, capabilities, serverInfo, instructions]);
|
}, [connectionStatus, capabilities, serverInfo, instructions]);
|
||||||
|
|
||||||
|
// The Server Info modal needs the active server's transport and (optional)
|
||||||
|
// OAuth details — both are co-located here so the modal opens against the
|
||||||
|
// same connection snapshot the header is reading.
|
||||||
|
const activeServer = useMemo<ServerEntry | undefined>(
|
||||||
|
() => servers.find((s) => s.id === activeServerId),
|
||||||
|
[servers, activeServerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// `config.type` is optional in the schema (a bare `command: ...`
|
||||||
|
// entry implies stdio), so we materialize the default here rather
|
||||||
|
// than at the render site — the modal's `transport` prop is a
|
||||||
|
// required `ServerType`, and we only render the modal once we know
|
||||||
|
// there's an active server (see the `{initializeResult && activeServer && …}`
|
||||||
|
// guard below).
|
||||||
|
const connectionInfoTransport: ServerType =
|
||||||
|
activeServer?.config.type ?? "stdio";
|
||||||
|
|
||||||
|
// OAuth details rendered in the Connection Info modal — read from the
|
||||||
|
// active InspectorClient's guided-OAuth state machine snapshot
|
||||||
|
// (synchronous), with configured scopes pulled from the server's
|
||||||
|
// persisted settings. All three fields are independently optional; if
|
||||||
|
// none are populated we return undefined so the modal hides the OAuth
|
||||||
|
// section entirely.
|
||||||
|
//
|
||||||
|
// Snapshot-at-last-derivation semantics: the memo deps
|
||||||
|
// (`connectionStatus`, `inspectorClient`, `activeServer`) don't
|
||||||
|
// include `oauthStepChange` / `oauthComplete`, so a token refresh
|
||||||
|
// that happens while none of those change won't update the rendered
|
||||||
|
// token. The memo will still re-run on the natural triggers — server
|
||||||
|
// switch, reconnect, or a settings edit that re-references
|
||||||
|
// `activeServer` — which matches the modal's overall framing
|
||||||
|
// ("info about the connection at this moment") and avoids
|
||||||
|
// subscribing to a third event source from a dialog whose primary
|
||||||
|
// job is read-only. If we ever surface live token refresh state,
|
||||||
|
// switch to subscribing on those events.
|
||||||
|
//
|
||||||
|
// For `authUrl` we prefer the authorization-server-advertised
|
||||||
|
// `authorization_endpoint` over the full `authorizationUrl`. The
|
||||||
|
// latter is the per-flight URL the user was redirected to (with
|
||||||
|
// `state`, `code_challenge`, etc.) — informative for a debugger,
|
||||||
|
// noisy for a connection summary; the endpoint is the stable
|
||||||
|
// identifier of "which AS is in use here."
|
||||||
|
//
|
||||||
|
// Scope splitter: OAuth 2.1 §3.3 specifies space-separated scopes;
|
||||||
|
// the persisted value is config-controlled (user-typed into the
|
||||||
|
// server settings form), so the literal `" "` split is sufficient
|
||||||
|
// and matches the spec.
|
||||||
|
const connectionInfoOAuth = useMemo<OAuthDetails | undefined>(() => {
|
||||||
|
if (connectionStatus !== "connected" || !inspectorClient) return undefined;
|
||||||
|
const oauthState = inspectorClient.getOAuthState();
|
||||||
|
const authUrl =
|
||||||
|
oauthState?.oauthMetadata?.authorization_endpoint ??
|
||||||
|
oauthState?.authorizationUrl?.toString();
|
||||||
|
const accessToken = oauthState?.oauthTokens?.access_token;
|
||||||
|
const scopes = activeServer?.settings?.oauthScopes
|
||||||
|
?.split(" ")
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!authUrl && !accessToken && !(scopes && scopes.length > 0)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(authUrl && { authUrl }),
|
||||||
|
...(scopes && scopes.length > 0 && { scopes }),
|
||||||
|
...(accessToken && { accessToken }),
|
||||||
|
};
|
||||||
|
}, [connectionStatus, inspectorClient, activeServer]);
|
||||||
|
|
||||||
// Derive log entries from the message log. Filters for
|
// Derive log entries from the message log. Filters for
|
||||||
// `notifications/message` (the response to `logging/setLevel`).
|
// `notifications/message` (the response to `logging/setLevel`).
|
||||||
const logs = useMemo<LogEntryData[]>(
|
const logs = useMemo<LogEntryData[]>(
|
||||||
|
|
@ -473,7 +545,6 @@ function App() {
|
||||||
setActiveServerId(id);
|
setActiveServerId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrorMessage(undefined);
|
|
||||||
connectStartRef.current = Date.now();
|
connectStartRef.current = Date.now();
|
||||||
try {
|
try {
|
||||||
// `settings.connectionTimeout` is consumed inside InspectorClient.connect
|
// `settings.connectionTimeout` is consumed inside InspectorClient.connect
|
||||||
|
|
@ -482,13 +553,19 @@ function App() {
|
||||||
// same behavior by reading from `serverSettings` on the client.
|
// same behavior by reading from `serverSettings` on the client.
|
||||||
await client.connect();
|
await client.connect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Handshake-only. A mid-session transport failure transitions the
|
// Handshake-only. A mid-session transport failure does not throw,
|
||||||
// client status to "error" without rejecting any pending promise,
|
// so a future error event from InspectorClient is the right hook
|
||||||
// and `errorMessage` stays stale. TODO(#1323): consume an `error`
|
// for surfacing those (TODO(#1323)). For now: toast on the
|
||||||
// event from `InspectorClientEventMap` once it exists.
|
// handshake error so the user actually sees what went wrong
|
||||||
|
// instead of the ConnectionToggle silently reverting to
|
||||||
|
// "disconnected".
|
||||||
connectStartRef.current = undefined;
|
connectStartRef.current = undefined;
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setErrorMessage(message);
|
notifications.show({
|
||||||
|
title: `Failed to connect to "${target.name}"`,
|
||||||
|
message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|
@ -878,7 +955,6 @@ function App() {
|
||||||
connectionStatus={connectionStatus}
|
connectionStatus={connectionStatus}
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
latencyMs={latencyMs}
|
latencyMs={latencyMs}
|
||||||
errorMessage={errorMessage}
|
|
||||||
tools={tools}
|
tools={tools}
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
resources={resources}
|
resources={resources}
|
||||||
|
|
@ -905,7 +981,7 @@ function App() {
|
||||||
onServerImportConfig={todoNoop}
|
onServerImportConfig={todoNoop}
|
||||||
onServerImportJson={todoNoop}
|
onServerImportJson={todoNoop}
|
||||||
onServerExport={onServerExport}
|
onServerExport={onServerExport}
|
||||||
onServerInfo={todoNoop}
|
onConnectionInfo={() => setConnectionInfoModalOpen(true)}
|
||||||
onServerSettings={(id) => setSettingsModalTargetId(id)}
|
onServerSettings={(id) => setSettingsModalTargetId(id)}
|
||||||
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
|
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
|
||||||
onServerClone={(id) => setConfigModal({ mode: "clone", targetId: id })}
|
onServerClone={(id) => setConfigModal({ mode: "clone", targetId: id })}
|
||||||
|
|
@ -962,6 +1038,16 @@ function App() {
|
||||||
onClose={onSettingsModalClose}
|
onClose={onSettingsModalClose}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>
|
/>
|
||||||
|
{initializeResult && activeServer && (
|
||||||
|
<ConnectionInfoModal
|
||||||
|
opened={connectionInfoModalOpen}
|
||||||
|
onClose={() => setConnectionInfoModalOpen(false)}
|
||||||
|
initializeResult={initializeResult}
|
||||||
|
clientCapabilities={clientCapabilities}
|
||||||
|
transport={connectionInfoTransport}
|
||||||
|
oauth={connectionInfoOAuth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ServerRemoveConfirmModal
|
<ServerRemoveConfirmModal
|
||||||
opened={removeTarget !== null}
|
opened={removeTarget !== null}
|
||||||
target={removeTarget}
|
target={removeTarget}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { InlineError } from "./InlineError";
|
|
||||||
|
|
||||||
const meta: Meta<typeof InlineError> = {
|
|
||||||
title: "Elements/InlineError",
|
|
||||||
component: InlineError,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof InlineError>;
|
|
||||||
|
|
||||||
export const ShortError: Story = {
|
|
||||||
args: {
|
|
||||||
error: { message: "Connection timeout after 20s" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithRetryCount: Story = {
|
|
||||||
args: {
|
|
||||||
error: { message: "Connection failed" },
|
|
||||||
retryCount: 3,
|
|
||||||
maxRetries: 5,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithDetails: Story = {
|
|
||||||
args: {
|
|
||||||
error: {
|
|
||||||
message: "Connection refused",
|
|
||||||
data: "Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithDocLink: Story = {
|
|
||||||
args: {
|
|
||||||
error: { message: "Connection failed" },
|
|
||||||
docLink: "https://example.com/troubleshooting",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FullError: Story = {
|
|
||||||
args: {
|
|
||||||
error: {
|
|
||||||
message: "Connection refused",
|
|
||||||
data: "Error: ECONNREFUSED 127.0.0.1:3000\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)",
|
|
||||||
},
|
|
||||||
retryCount: 3,
|
|
||||||
maxRetries: 5,
|
|
||||||
docLink: "https://example.com/troubleshooting",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { InlineError } from "./InlineError";
|
|
||||||
|
|
||||||
describe("InlineError", () => {
|
|
||||||
it("renders the error message", () => {
|
|
||||||
renderWithMantine(<InlineError error={{ message: "boom" }} />);
|
|
||||||
expect(screen.getByText("boom")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders retry attempt count without max", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<InlineError error={{ message: "boom" }} retryCount={2} />,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Retry attempt 2")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders retry attempt count with max", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<InlineError error={{ message: "boom" }} retryCount={2} maxRetries={5} />,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Retry attempt 2 of 5")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show expand button when there is nothing to expand", () => {
|
|
||||||
renderWithMantine(<InlineError error={{ message: "boom" }} />);
|
|
||||||
expect(screen.queryByText("Show more")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("expands details on click", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(
|
|
||||||
<InlineError error={{ message: "boom", data: "extra" }} />,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Show more" }));
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Show less" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("extra")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats non-string data as JSON", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(
|
|
||||||
<InlineError error={{ message: "boom", data: { code: 42 } }} />,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Show more" }));
|
|
||||||
expect(screen.getByText(/"code": 42/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a doc link button when docLink is provided and expanded", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(
|
|
||||||
<InlineError
|
|
||||||
error={{ message: "boom" }}
|
|
||||||
docLink="https://example.com/docs"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Show more" }));
|
|
||||||
const link = screen.getByRole("link", { name: /Troubleshooting/ });
|
|
||||||
expect(link).toHaveAttribute("href", "https://example.com/docs");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { Alert, Button, Collapse, Group, Stack, Text } from "@mantine/core";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export interface InlineErrorProps {
|
|
||||||
error: { message: string; data?: unknown };
|
|
||||||
retryCount?: number;
|
|
||||||
maxRetries?: number;
|
|
||||||
docLink?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HeaderRow = Group.withProps({
|
|
||||||
justify: "space-between",
|
|
||||||
wrap: "nowrap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const MessageGroup = Group.withProps({
|
|
||||||
gap: "sm",
|
|
||||||
wrap: "nowrap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const ErrorMessage = Text.withProps({
|
|
||||||
size: "sm",
|
|
||||||
fw: 500,
|
|
||||||
c: "red",
|
|
||||||
});
|
|
||||||
|
|
||||||
const RetryText = Text.withProps({
|
|
||||||
size: "sm",
|
|
||||||
c: "dimmed",
|
|
||||||
});
|
|
||||||
|
|
||||||
const ExpandButton = Button.withProps({
|
|
||||||
variant: "transparent",
|
|
||||||
size: "compact-xs",
|
|
||||||
c: "var(--inspector-text-primary)",
|
|
||||||
});
|
|
||||||
|
|
||||||
const DocLinkButton = Button.withProps({
|
|
||||||
variant: "light",
|
|
||||||
size: "xs",
|
|
||||||
component: "a",
|
|
||||||
target: "_blank",
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatRetryLabel(retryCount: number, maxRetries?: number): string {
|
|
||||||
return maxRetries !== undefined
|
|
||||||
? `Retry attempt ${retryCount} of ${maxRetries}`
|
|
||||||
: `Retry attempt ${retryCount}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDetails(data: unknown): string {
|
|
||||||
if (typeof data === "string") return data;
|
|
||||||
return JSON.stringify(data, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InlineError({
|
|
||||||
error,
|
|
||||||
retryCount,
|
|
||||||
maxRetries,
|
|
||||||
docLink,
|
|
||||||
}: InlineErrorProps) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const details =
|
|
||||||
error.data !== undefined ? formatDetails(error.data) : undefined;
|
|
||||||
const hasExpandable = details !== undefined || docLink !== undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert color="red" variant="light">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<HeaderRow>
|
|
||||||
<MessageGroup>
|
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
|
||||||
{retryCount !== undefined && (
|
|
||||||
<RetryText>{formatRetryLabel(retryCount, maxRetries)}</RetryText>
|
|
||||||
)}
|
|
||||||
</MessageGroup>
|
|
||||||
{hasExpandable && (
|
|
||||||
<ExpandButton onClick={() => setExpanded((v) => !v)}>
|
|
||||||
{expanded ? "Show less" : "Show more"}
|
|
||||||
</ExpandButton>
|
|
||||||
)}
|
|
||||||
</HeaderRow>
|
|
||||||
<Collapse in={expanded}>
|
|
||||||
<Stack gap="xs">
|
|
||||||
{details && <Text size="sm">{details}</Text>}
|
|
||||||
{docLink && (
|
|
||||||
<DocLinkButton href={docLink}>
|
|
||||||
View Troubleshooting Guide
|
|
||||||
</DocLinkButton>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Collapse>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ const meta: Meta<typeof ServerCard> = {
|
||||||
component: ServerCard,
|
component: ServerCard,
|
||||||
args: {
|
args: {
|
||||||
onToggleConnection: fn(),
|
onToggleConnection: fn(),
|
||||||
onServerInfo: fn(),
|
onConnectionInfo: fn(),
|
||||||
onSettings: fn(),
|
onSettings: fn(),
|
||||||
onEdit: fn(),
|
onEdit: fn(),
|
||||||
onClone: fn(),
|
onClone: fn(),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ const sseConfig: MCPServerConfig = {
|
||||||
|
|
||||||
const connected: ConnectionState = { status: "connected" };
|
const connected: ConnectionState = { status: "connected" };
|
||||||
const disconnected: ConnectionState = { status: "disconnected" };
|
const disconnected: ConnectionState = { status: "disconnected" };
|
||||||
|
const connecting: ConnectionState = { status: "connecting" };
|
||||||
const errored: ConnectionState = {
|
const errored: ConnectionState = {
|
||||||
status: "error",
|
status: "error",
|
||||||
retryCount: 2,
|
retryCount: 2,
|
||||||
|
|
@ -31,7 +32,7 @@ const errored: ConnectionState = {
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
onToggleConnection: vi.fn(),
|
onToggleConnection: vi.fn(),
|
||||||
onServerInfo: vi.fn(),
|
onConnectionInfo: vi.fn(),
|
||||||
onSettings: vi.fn(),
|
onSettings: vi.fn(),
|
||||||
onEdit: vi.fn(),
|
onEdit: vi.fn(),
|
||||||
onClone: vi.fn(),
|
onClone: vi.fn(),
|
||||||
|
|
@ -80,11 +81,6 @@ describe("ServerCard", () => {
|
||||||
expect(screen.getByText("https://api.example.com/sse")).toBeInTheDocument();
|
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", () => {
|
it("hides body content in compact mode", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ServerCard {...baseProps} connection={disconnected} compact />,
|
<ServerCard {...baseProps} connection={disconnected} compact />,
|
||||||
|
|
@ -100,7 +96,7 @@ describe("ServerCard", () => {
|
||||||
const onClone = vi.fn();
|
const onClone = vi.fn();
|
||||||
const onEdit = vi.fn();
|
const onEdit = vi.fn();
|
||||||
const onRemove = vi.fn();
|
const onRemove = vi.fn();
|
||||||
const onServerInfo = vi.fn();
|
const onConnectionInfo = vi.fn();
|
||||||
const onSettings = vi.fn();
|
const onSettings = vi.fn();
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ServerCard
|
<ServerCard
|
||||||
|
|
@ -108,19 +104,19 @@ describe("ServerCard", () => {
|
||||||
onClone={onClone}
|
onClone={onClone}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
onServerInfo={onServerInfo}
|
onConnectionInfo={onConnectionInfo}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: "Clone" }));
|
await user.click(screen.getByRole("button", { name: "Clone" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
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" }));
|
await user.click(screen.getByRole("button", { name: "Settings" }));
|
||||||
expect(onClone).toHaveBeenCalledWith("srv-1");
|
expect(onClone).toHaveBeenCalledWith("srv-1");
|
||||||
expect(onEdit).toHaveBeenCalledWith("srv-1");
|
expect(onEdit).toHaveBeenCalledWith("srv-1");
|
||||||
expect(onRemove).toHaveBeenCalledWith("srv-1");
|
expect(onRemove).toHaveBeenCalledWith("srv-1");
|
||||||
expect(onServerInfo).toHaveBeenCalledWith("srv-1");
|
expect(onConnectionInfo).toHaveBeenCalledWith("srv-1");
|
||||||
expect(onSettings).toHaveBeenCalledWith("srv-1");
|
expect(onSettings).toHaveBeenCalledWith("srv-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -157,4 +153,39 @@ describe("ServerCard", () => {
|
||||||
renderWithMantine(<ServerCard {...baseProps} info={undefined} />);
|
renderWithMantine(<ServerCard {...baseProps} info={undefined} />);
|
||||||
expect(screen.queryByText("1.2.0")).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@ import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/Serv
|
||||||
import { TransportBadge } from "../../elements/TransportBadge/TransportBadge";
|
import { TransportBadge } from "../../elements/TransportBadge/TransportBadge";
|
||||||
import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle";
|
import { ConnectionToggle } from "../../elements/ConnectionToggle/ConnectionToggle";
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
import { InlineError } from "../../elements/InlineError/InlineError";
|
|
||||||
|
|
||||||
export interface ServerCardProps extends ServerEntry {
|
export interface ServerCardProps extends ServerEntry {
|
||||||
activeServer?: string;
|
activeServer?: string;
|
||||||
onToggleConnection: (id: string) => void;
|
onToggleConnection: (id: string) => void;
|
||||||
onServerInfo: (id: string) => void;
|
onConnectionInfo: (id: string) => void;
|
||||||
onSettings: (id: string) => void;
|
onSettings: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
onClone: (id: string) => void;
|
onClone: (id: string) => void;
|
||||||
|
|
@ -92,7 +91,7 @@ export function ServerCard({
|
||||||
connection,
|
connection,
|
||||||
activeServer,
|
activeServer,
|
||||||
onToggleConnection,
|
onToggleConnection,
|
||||||
onServerInfo,
|
onConnectionInfo,
|
||||||
onSettings,
|
onSettings,
|
||||||
onEdit,
|
onEdit,
|
||||||
onClone,
|
onClone,
|
||||||
|
|
@ -142,16 +141,6 @@ export function ServerCard({
|
||||||
copyable
|
copyable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{connection.error && (
|
|
||||||
<InlineError
|
|
||||||
error={{
|
|
||||||
message: connection.error.message,
|
|
||||||
data: connection.error.details,
|
|
||||||
}}
|
|
||||||
retryCount={connection.retryCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<ActionsRow>
|
<ActionsRow>
|
||||||
<SubtleButton onClick={() => onClone(id)}>Clone</SubtleButton>
|
<SubtleButton onClick={() => onClone(id)}>Clone</SubtleButton>
|
||||||
|
|
@ -159,9 +148,11 @@ export function ServerCard({
|
||||||
<RemoveButton onClick={() => onRemove(id)}>Remove</RemoveButton>
|
<RemoveButton onClick={() => onRemove(id)}>Remove</RemoveButton>
|
||||||
</ActionsRow>
|
</ActionsRow>
|
||||||
<ActionsRow>
|
<ActionsRow>
|
||||||
<SubtleButton onClick={() => onServerInfo(id)}>
|
{connection.status === "connected" && (
|
||||||
Server Info
|
<SubtleButton onClick={() => onConnectionInfo(id)}>
|
||||||
</SubtleButton>
|
Connection Info
|
||||||
|
</SubtleButton>
|
||||||
|
)}
|
||||||
<SubtleButton onClick={() => onSettings(id)}>
|
<SubtleButton onClick={() => onSettings(id)}>
|
||||||
Settings
|
Settings
|
||||||
</SubtleButton>
|
</SubtleButton>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const meta: Meta<typeof ServerListScreen> = {
|
||||||
onImportServerJson: fn(),
|
onImportServerJson: fn(),
|
||||||
onExport: fn(),
|
onExport: fn(),
|
||||||
onToggleConnection: fn(),
|
onToggleConnection: fn(),
|
||||||
onServerInfo: fn(),
|
onConnectionInfo: fn(),
|
||||||
onSettings: fn(),
|
onSettings: fn(),
|
||||||
onEdit: fn(),
|
onEdit: fn(),
|
||||||
onClone: fn(),
|
onClone: fn(),
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const baseProps = {
|
||||||
onImportServerJson: vi.fn(),
|
onImportServerJson: vi.fn(),
|
||||||
onExport: vi.fn(),
|
onExport: vi.fn(),
|
||||||
onToggleConnection: vi.fn(),
|
onToggleConnection: vi.fn(),
|
||||||
onServerInfo: vi.fn(),
|
onConnectionInfo: vi.fn(),
|
||||||
onSettings: vi.fn(),
|
onSettings: vi.fn(),
|
||||||
onEdit: vi.fn(),
|
onEdit: vi.fn(),
|
||||||
onClone: vi.fn(),
|
onClone: vi.fn(),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export interface ServerListScreenProps {
|
||||||
/** Download the current server list as a canonical `mcp.json` file. */
|
/** Download the current server list as a canonical `mcp.json` file. */
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
onToggleConnection: (id: string) => void;
|
onToggleConnection: (id: string) => void;
|
||||||
onServerInfo: (id: string) => void;
|
onConnectionInfo: (id: string) => void;
|
||||||
onSettings: (id: string) => void;
|
onSettings: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
onClone: (id: string) => void;
|
onClone: (id: string) => void;
|
||||||
|
|
@ -40,7 +40,7 @@ export function ServerListScreen({
|
||||||
onImportServerJson,
|
onImportServerJson,
|
||||||
onExport,
|
onExport,
|
||||||
onToggleConnection,
|
onToggleConnection,
|
||||||
onServerInfo,
|
onConnectionInfo,
|
||||||
onSettings,
|
onSettings,
|
||||||
onEdit,
|
onEdit,
|
||||||
onClone,
|
onClone,
|
||||||
|
|
@ -77,7 +77,7 @@ export function ServerListScreen({
|
||||||
compact={compact}
|
compact={compact}
|
||||||
activeServer={activeServer}
|
activeServer={activeServer}
|
||||||
onToggleConnection={onToggleConnection}
|
onToggleConnection={onToggleConnection}
|
||||||
onServerInfo={onServerInfo}
|
onConnectionInfo={onConnectionInfo}
|
||||||
onSettings={onSettings}
|
onSettings={onSettings}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onClone={onClone}
|
onClone={onClone}
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,6 @@ const meta: Meta<typeof InspectorView> = {
|
||||||
connectionStatus: "disconnected",
|
connectionStatus: "disconnected",
|
||||||
initializeResult: undefined,
|
initializeResult: undefined,
|
||||||
latencyMs: undefined,
|
latencyMs: undefined,
|
||||||
errorMessage: undefined,
|
|
||||||
|
|
||||||
// Misc state
|
// Misc state
|
||||||
currentLogLevel: "info",
|
currentLogLevel: "info",
|
||||||
|
|
@ -331,7 +330,7 @@ const meta: Meta<typeof InspectorView> = {
|
||||||
onServerImportConfig: fn(),
|
onServerImportConfig: fn(),
|
||||||
onServerImportJson: fn(),
|
onServerImportJson: fn(),
|
||||||
onServerExport: fn(),
|
onServerExport: fn(),
|
||||||
onServerInfo: fn(),
|
onConnectionInfo: fn(),
|
||||||
onServerSettings: fn(),
|
onServerSettings: fn(),
|
||||||
onServerEdit: fn(),
|
onServerEdit: fn(),
|
||||||
onServerClone: fn(),
|
onServerClone: fn(),
|
||||||
|
|
@ -391,6 +390,5 @@ export const ConnectionError: Story = {
|
||||||
args: {
|
args: {
|
||||||
activeServer: demoServers[0]!.id,
|
activeServer: demoServers[0]!.id,
|
||||||
connectionStatus: "error",
|
connectionStatus: "error",
|
||||||
errorMessage: "Handshake timeout",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue