Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions clients/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ The Apps screen exposes a small, stable set of `data-testid` / `data-*` attribut

The renderer lifecycle itself is `AppRendererStatus` (`loading` | `ready` | `error`) reported via `AppRenderer`'s `onAppStatusChange`; the screen maps it to `data-app-status`. Resource-read failures (malformed/404 UI resource) are surfaced as a toast via the bridge factory's `onResourceError`; because the app never reaches `ready` in that case, a driver times out on `data-app-status` and reads the toast.

## Deep-link auto-connect

A driver (launcher, CLI `--print-handoff`, CI review harness) can reach a **connected** inspector with a single navigate by encoding the target in the URL query string. Parsing + security gating live in `src/utils/deepLink.ts` (`parseDeepLink`), and a returned `DeepLink` is proof the link passed validation.

```
http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token>
```

| Param | Meaning |
| --- | --- |
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
| `autoConnect` | **CSRF gate.** Must equal the per-launch `MCP_INSPECTOR_API_TOKEN`. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing `?MCP_INSPECTOR_API_TOKEN=` param. Without a match the link is ignored. |

The deep link upserts a stable `deep-link` catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the `AppShell.Header` as a machine-readable contract, so a driver can `waitForSelector` and read the failure reason without scraping a transient toast:

| Attribute | Where | Meaning |
| --- | --- | --- |
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |

The `openApp` / `appArgs` / `autoOpen` params extend this to land on a rendered MCP App; see the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above for the app-side `data-*` signals.

## Theme (`src/theme/`)

Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme<Name>` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider.
Expand Down
121 changes: 119 additions & 2 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ import {
} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
import {
parseDeepLink,
deepLinkConfigEquals,
deepLinkParseStatus,
} from "./utils/deepLink";
import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink";
import {
applyOAuthResumeUi,
buildTabUiSnapshot,
Expand Down Expand Up @@ -1347,6 +1353,37 @@ function App() {
serversRef.current = servers;
}, [activeServer, activeServerId, servers]);

// Last connection-level error message, surfaced as `data-error-message` on
// the InspectorView header so an automated driver can read *why* a connect
// failed without scraping a transient toast. Cleared on the next connect
// attempt and on successful connection.
const [connectErrorMessage, setConnectErrorMessage] = useState<
string | undefined
>(undefined);
// Named writer so a single call site can be extended later (e.g. telemetry)
// and the intent (record a connection-level failure) stays explicit.
const recordConnectError = useCallback((message: string) => {
setConnectErrorMessage(message);
}, []);

// Deep-link parameters parsed once from the initial URL. Security gating
// (auth-token match, http(s)-only serverUrl) happens inside `parseDeepLink`,
// so a `DeepLink` value here is already validated. The parse status is
// surfaced as `data-deeplink` so an automated driver can tell "no deep link"
// from "deep link present but rejected" — both leave `data-status` idle.
const [deepLink, deepLinkStatus] = useMemo<
[DeepLink | undefined, DeepLinkParseStatus]
>(() => {
/* v8 ignore next -- SSR guard: happy-dom always defines window in tests */
if (typeof window === "undefined") return [undefined, "none"];
const search = window.location.search;
const parsed = parseDeepLink(search, getAuthToken());
return [parsed, deepLinkParseStatus(search, parsed)];
}, []);
const deepLinkEnsureRef = useRef(false);
const deepLinkUpdateRef = useRef(false);
const deepLinkConnectRef = useRef(false);

const showReAuthBanner = useCallback(
(
serverId: string,
Expand Down Expand Up @@ -2338,7 +2375,11 @@ function App() {
return;
}

const target = servers.find((s) => s.id === id);
// Read from the ref so a caller that already awaited an
// addServer/updateServer in the same async tick (e.g. the deep-link
// auto-connect IIFE) sees the freshly-mutated list, not the stale array
// captured by this callback's closure.
const target = serversRef.current.find((s) => s.id === id);
if (!target) return;

// Always rebuild the InspectorClient on a (re)connect so the latest
Expand All @@ -2354,6 +2395,9 @@ function App() {
// the red border on the last-failed card is removed (#1621). If this
// attempt also fails, the catch below re-sets it for this server.
setFailedServerId(undefined);
// Clear the machine-readable connect error for the same reason; a fresh
// attempt starts from a clean `data-error-message`.
setConnectErrorMessage(undefined);

connectStartRef.current = Date.now();
try {
Expand Down Expand Up @@ -2432,6 +2476,7 @@ function App() {
}
const message =
authErr instanceof Error ? authErr.message : String(authErr);
setConnectErrorMessage(message);
notifications.show({
title: `OAuth authorization failed for "${target.name}"`,
message,
Expand All @@ -2446,6 +2491,7 @@ function App() {
// "disconnected", and flag the card with a red border (#1621).
setFailedServerId(id);
const message = err instanceof Error ? err.message : String(err);
setConnectErrorMessage(message);
notifications.show({
title: `Failed to connect to "${target.name}"`,
message,
Expand All @@ -2457,7 +2503,6 @@ function App() {
activeServerId,
connectionStatus,
inspectorClient,
servers,
setupClientForServer,
prepareOAuthRedirect,
finalizeExplicitDisconnect,
Expand All @@ -2473,6 +2518,75 @@ function App() {
}
}, [inspectorClient, finalizeExplicitDisconnect]);

// Deep-link auto-connect (the URL-driven case of #1183). `useServers`
// hydrates asynchronously (initial `servers` is `[]`), so this effect runs in
// discrete phases keyed on what `servers` currently reflects, one per render:
// 1. ensure — no row yet: one-shot `addServer`.
// 2. update — row present but its persisted config differs from the deep
// link (a stale transport/url from an earlier load under the stable
// `deep-link` id): `updateServer`, then return so the effect re-runs.
// 3. connect — row present AND its config already matches: connect.
// Splitting update and connect across renders (rather than awaiting both in
// one closure) is what makes the connect correct: `onToggleConnection` reads
// the target from `serversRef`, which an earlier passive effect syncs from
// `servers` — so connecting only once `servers` reflects the updated config
// guarantees the client is built from the fresh transport, not the stale one.
// The OAuth callback path takes precedence; a deep link on `/oauth/callback`
// would be a misconfiguration, and the callback handler clears the URL.
useEffect(() => {
if (!deepLink) return;
if (window.location.pathname === OAUTH_CALLBACK_PATH) return;

const existing = servers.find((s) => s.id === deepLink.serverId);
if (!existing) {
if (deepLinkEnsureRef.current) return;
deepLinkEnsureRef.current = true;
void addServer(deepLink.serverId, deepLink.serverConfig).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
// A 409 ("already exists") means the row is on disk and hydration will
// surface it on a later render, so the connect phase still proceeds —
// swallow it. Any other failure (read-only catalog, backend 5xx) would
// otherwise leave the deep link permanently stuck at this guard with no
// signal, so record it on the machine-readable error surface.
if (!message.includes("already exists")) recordConnectError(message);
});
return;
}

if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) {
if (deepLinkUpdateRef.current) return;
deepLinkUpdateRef.current = true;
void updateServer(
deepLink.serverId,
deepLink.serverId,
deepLink.serverConfig,
).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
recordConnectError(message);
});
return;
}

if (deepLinkConnectRef.current) return;
deepLinkConnectRef.current = true;
if (activeServerId !== deepLink.serverId) {
void onToggleConnection(deepLink.serverId).catch((err) => {
// The toast fires from inside `onToggleConnection` for the common
// cases; this catch covers the rest (surfaced on `data-error-message`).
const message = err instanceof Error ? err.message : String(err);
recordConnectError(message);
});
}
}, [
deepLink,
servers,
activeServerId,
addServer,
updateServer,
onToggleConnection,
recordConnectError,
]);

const onReauthenticateFromBanner = useCallback(() => {
if (!reAuthBanner) return;
const serverId = reAuthBanner.serverId;
Expand Down Expand Up @@ -3691,11 +3805,14 @@ function App() {
</Box>
) : null}
<InspectorView
deepLink={deepLink}
deepLinkStatus={deepLinkStatus}
servers={servers}
serverListWritable={serverListWritable}
activeServer={activeServerId}
erroredServerId={failedServerId}
connectionStatus={connectionStatus}
connectErrorMessage={connectErrorMessage}
initializeResult={initializeResult}
latencyMs={latencyMs}
tools={tools}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,42 @@ describe("InspectorView", () => {
expect(onToggleConnection).toHaveBeenCalledWith("alpha");
});

it("exposes the machine-readable connection-status header attributes for drivers", () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
connectionStatus: "error",
connectErrorMessage: "handshake failed: 500",
deepLinkStatus: "rejected",
})}
/>,
);
const header = screen.getByTestId("connection-status");
expect(header).toHaveAttribute("data-status", "error");
expect(header).toHaveAttribute(
"data-error-message",
"handshake failed: 500",
);
expect(header).toHaveAttribute("data-deeplink", "rejected");
});

it("omits the error-message attribute when no connect error is recorded", () => {
renderWithMantine(
<StatefulInspectorViewHost
{...makeProps({
servers: [sampleServer],
connectionStatus: "disconnected",
deepLinkStatus: "none",
})}
/>,
);
const header = screen.getByTestId("connection-status");
expect(header).toHaveAttribute("data-status", "disconnected");
expect(header).not.toHaveAttribute("data-error-message");
expect(header).toHaveAttribute("data-deeplink", "none");
});

it("renders the connected header when connectionStatus + initializeResult are set", () => {
renderWithMantine(
<StatefulInspectorViewHost
Expand Down
32 changes: 31 additions & 1 deletion clients/web/src/components/views/InspectorView/InspectorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ReactNode,
type Ref,
} from "react";
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
import {
AppShell,
Flex,
Expand Down Expand Up @@ -304,6 +305,21 @@ const MonitorColumnGroup = Group.withProps({
});

export interface InspectorViewProps {
/**
* Validated deep-link parameters from the page URL. When present and
* `openApp` is set, the parent switches to the Apps tab and pre-selects that
* app (with `appArgs` as the form values) once the connection is up and the
* app list contains it. The connect itself is driven by the parent.
*/
deepLink?: DeepLink;
/**
* Outcome of parsing the initial-URL deep link, surfaced as `data-deeplink`
* on the `connection-status` testid. Distinguishes "no deep link" from
* "rejected" (token mismatch / bad serverUrl) — both leave `data-status`
* idle, so an automated driver otherwise cannot tell them apart.
*/
deepLinkStatus?: DeepLinkParseStatus;

// Server list (static config; runtime connection state comes from the
// separate fields below and is merged into each card by this component).
servers: ServerEntry[];
Expand All @@ -324,6 +340,13 @@ export interface InspectorViewProps {
*/
erroredServerId?: string;
connectionStatus: ConnectionStatus;
/**
* Last connection-level error message (handshake failure, OAuth start
* failure, deep-link automation failure). Surfaced as `data-error-message`
* on the header's `connection-status` testid so an automated driver can read
* *why* a connect failed without scraping a transient toast.
*/
connectErrorMessage?: string;
initializeResult?: InitializeResult;
latencyMs?: number;

Expand Down Expand Up @@ -497,11 +520,13 @@ export interface InspectorViewProps {
}

export function InspectorView({
deepLinkStatus,
servers: serversInput,
serverListWritable = true,
activeServer,
erroredServerId,
connectionStatus,
connectErrorMessage,
initializeResult,
latencyMs,
tools,
Expand Down Expand Up @@ -1037,7 +1062,12 @@ export function InspectorView({
// Main-slot height clamp + overflow:hidden keep that scroll on the inner
// ScrollArea regions only.
<AppShell header={{ height: 60 }} padding={0}>
<AppShell.Header>
<AppShell.Header
data-testid="connection-status"
data-status={connectionStatus}
data-error-message={connectErrorMessage}
data-deeplink={deepLinkStatus}
>
{connectionStatus === "connected" && initializeResult ? (
<ViewHeader
connected
Expand Down
Loading
Loading