From 0e93b13426ac7535f9424f5d5986da520463322d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 22:48:28 -0400 Subject: [PATCH 01/11] web: deep-link auto-connect + machine-readable connection status (#1576) Closes #1576 Adds `?serverUrl=&transport=http|sse&autoConnect=` URL-driven auto-connect so a driver can reach a connected inspector with one navigate. - `clients/web/src/utils/deepLink.ts`: `parseDeepLink()` (http(s)-only serverUrl; `autoConnect` CSRF-gated on the per-launch API token), `deepLinkParseStatus()`, `deepLinkConfigEquals()`, `DEEP_LINK_SERVER_ID` (stable `deep-link` row so reloads reconnect to the same catalog row). - App.tsx: ensure/connect effects (two-phase upsert-then-connect against the hydrated list), `serversRef` read in `onToggleConnection`, `connectErrorMessage` / `recordConnectError` recording connection-level failures. - InspectorView header: `data-testid="connection-status"` with `data-status`, `data-error-message`, `data-deeplink` so drivers can `waitForSelector` and read failure reasons without scraping toasts. - Docs: deep-link scheme + `data-*` contract in clients/web/README.md. Re-implemented informed by PR #1510 (33fac3f); adapted to the post-#1417 lifted-tab architecture and dropping the non-existent `normalizeServerUrl` import in favor of `URL.href` canonicalization. Coverage gate green (deepLink.ts 97.8/95.1/100/97.4). Part of the #1579 decomposition (Wave 4). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/README.md | 25 +++ clients/web/src/App.tsx | 106 +++++++++- .../InspectorView/InspectorView.test.tsx | 36 ++++ .../views/InspectorView/InspectorView.tsx | 32 ++- clients/web/src/utils/deepLink.test.ts | 199 ++++++++++++++++++ clients/web/src/utils/deepLink.ts | 180 ++++++++++++++++ 6 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 clients/web/src/utils/deepLink.test.ts create mode 100644 clients/web/src/utils/deepLink.ts 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..5eb7f760f 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,36 @@ 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 deepLinkConnectRef = useRef(false); + const showReAuthBanner = useCallback( ( serverId: string, @@ -2338,7 +2374,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 +2394,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 +2475,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 +2490,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 +2502,6 @@ function App() { activeServerId, connectionStatus, inspectorClient, - servers, setupClientForServer, prepareOAuthRedirect, finalizeExplicitDisconnect, @@ -2473,6 +2517,61 @@ 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 two phases: first render attempts a one-shot `addServer` (a 409 means + // the row already exists on disk and hydration will surface it); a later + // render where `servers` actually contains the deep-link row then updates the + // URL if it has changed and connects. Connecting in the same closure as + // `addServer` would resolve against a stale (empty) `servers` and silently + // no-op. 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(() => { + // 409 = already on disk; hydration will surface it on a later render. + }); + return; + } + + if (deepLinkConnectRef.current) return; + deepLinkConnectRef.current = true; + void (async () => { + if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) { + await updateServer( + deepLink.serverId, + deepLink.serverId, + deepLink.serverConfig, + ); + } + if (activeServerId !== deepLink.serverId) { + await onToggleConnection(deepLink.serverId); + } + })().catch((err) => { + // Surface deep-link automation failures (updateServer 5xx, connect throw) + // on the machine-readable error attribute instead of dropping them. The + // toast still fires from inside `onToggleConnection` for the common + // cases; this catch covers the rest. + 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 +3790,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,