diff --git a/clients/web/README.md b/clients/web/README.md index 329317e4c..346371349 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -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=&transport=http|sse&autoConnect= +``` + +| 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.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme` 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. diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 255d71e31..1511ce27c 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -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, @@ -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, @@ -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 @@ -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 { @@ -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, @@ -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, @@ -2457,7 +2503,6 @@ function App() { activeServerId, connectionStatus, inspectorClient, - servers, setupClientForServer, prepareOAuthRedirect, finalizeExplicitDisconnect, @@ -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; @@ -3691,11 +3805,14 @@ function App() { ) : null} { expect(onToggleConnection).toHaveBeenCalledWith("alpha"); }); + it("exposes the machine-readable connection-status header attributes for drivers", () => { + renderWithMantine( + , + ); + 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( + , + ); + 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( - + {connectionStatus === "connected" && initializeResult ? ( { + it("returns undefined when no serverUrl param is present", () => { + expect(parseDeepLink("?autoConnect=" + TOKEN, TOKEN)).toBeUndefined(); + }); + + it("returns undefined when autoConnect is missing", () => { + expect( + parseDeepLink("?serverUrl=https%3A%2F%2Fexample.com%2Fmcp", TOKEN), + ).toBeUndefined(); + }); + + it("rejects when autoConnect does not match the session token (CSRF guard)", () => { + expect( + parseDeepLink( + "?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=1", + TOKEN, + ), + ).toBeUndefined(); + }); + + it("rejects when there is no session token to compare against", () => { + expect( + parseDeepLink( + "?serverUrl=https%3A%2F%2Fexample.com%2Fmcp&autoConnect=" + TOKEN, + undefined, + ), + ).toBeUndefined(); + }); + + it("rejects non-http(s) serverUrl schemes", () => { + for (const url of [ + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,