From cbb2afe80c0954ebfbf8f69d3249cce59c06184c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 22:58:34 -0400 Subject: [PATCH 1/2] =?UTF-8?q?web:=20deep-link=20openApp/appArgs/autoOpen?= =?UTF-8?q?=20=E2=80=94=20pre-select=20&=20pre-fill=20an=20app=20from=20th?= =?UTF-8?q?e=20URL=20(#1577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1577 Extends the deep link (#1576) to land on a rendered MCP App: - `&openApp=` — once connected and the tool appears in the app list, InspectorView switches to the Apps tab and pre-selects it. - `&appArgs=` — pre-fills the tool form; values are merged **over** `collectSchemaDefaults()` so required-with-default fields don't disable "Open App". - `&autoOpen=` — token-gated auto-click of "Open App". Adapted to the current architecture (differs from PR #1510's reference): - Tab state is lifted to App.tsx (#1417), so the openApp effect lives in InspectorView and drives `onActiveTabChange("Apps")` + `onAppsUiChange` seed + `onSelectApp`, guarded by `deepLinkOpenAppRef` to fire once. - AppsScreen owns the `running`/iframe state, so `autoOpen` is threaded to it as a prop; a fire-once effect defers the open one microtask past the synchronous effect body (satisfying `react-hooks/set-state-in-effect`, matching App.tsx's connect effect). `resetAppChannels` memoized so the effect's deps don't churn. Tests: InspectorView (tab-switch + pre-select + appArgs-over-defaults merge + non-app openApp ignored), AppsScreen (autoOpen fires once with seeded values; no-selection stays idle). README documents the params. Coverage gate green. 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 | 16 ++- .../screens/AppsScreen/AppsScreen.test.tsx | 34 +++++++ .../screens/AppsScreen/AppsScreen.tsx | 48 ++++++++- .../InspectorView/InspectorView.test.tsx | 98 +++++++++++++++++++ .../views/InspectorView/InspectorView.tsx | 47 +++++++++ 5 files changed, 238 insertions(+), 5 deletions(-) diff --git a/clients/web/README.md b/clients/web/README.md index 346371349..a836f4329 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -94,7 +94,21 @@ The deep link upserts a stable `deep-link` catalog row (so a reload reconnects t | `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. +### Landing on a rendered app + +Three further params extend the deep link to pre-select — and optionally auto-open — an MCP App, so a driver reaches a rendered widget with zero clicks: + +``` +…&openApp=&appArgs=&autoOpen= +``` + +| Param | Meaning | +| --- | --- | +| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. | +| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. | +| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. | + +The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically. ## Theme (`src/theme/`) diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx index 7c9c0dcfa..deaa3e8ff 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -274,6 +274,40 @@ describe("AppsScreen", () => { expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument(); }); + it("auto-opens the pre-selected app with its seeded form values when autoOpen is set (deep-link)", async () => { + const onOpenApp = vi.fn(); + renderWithMantine( + , + ); + // No click: the fire-once effect opens the seeded app with its form values. + await vi.waitFor(() => + expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" }), + ); + expect(onOpenApp).toHaveBeenCalledTimes(1); + // Renderer mounted; the Open App button is gone. + expect( + screen.queryByRole("button", { name: /Open App/ }), + ).not.toBeInTheDocument(); + expect(screen.getByTitle("Weather Widget")).toBeInTheDocument(); + }); + + it("does not auto-open when autoOpen is set but no app is pre-selected", async () => { + const onOpenApp = vi.fn(); + renderWithMantine(); + // Give the effect a chance to run; with no selection it must stay idle. + await Promise.resolve(); + expect(onOpenApp).not.toHaveBeenCalled(); + expect(screen.getByText("Select an app to view details")).toBeVisible(); + }); + it("invokes onOpenApp with form values when Open App is clicked", async () => { const user = userEvent.setup(); const onOpenApp = vi.fn(); diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index dd85b02ae..0a38c2eae 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type Ref } from "react"; +import { useCallback, useEffect, useRef, useState, type Ref } from "react"; import { ActionIcon, Button, @@ -64,6 +64,14 @@ export interface AppsScreenProps { onCloseApp: () => void; /** Surfaces bridge/runtime failures from the renderer (e.g. no client). */ onError?: (err: Error) => void; + /** + * Deep-link auto-open (#1577): when true and an app is already pre-selected + * (the parent seeds `ui.selectedAppName` + `ui.formValues` from the URL), + * the screen fires "Open App" automatically — no explicit click. Token-gated + * upstream in `parseDeepLink` (the URL value must equal the session token), + * so a third-party link cannot auto-invoke a tool. Fires exactly once. + */ + autoOpen?: boolean; } // Selected app, its form values, and the sidebar search — controlled by the @@ -333,6 +341,7 @@ export function AppsScreen({ onOpenApp, onCloseApp, onError, + autoOpen = false, }: AppsScreenProps) { const { selectedAppName, formValues, search } = ui; const [running, setRunning] = useState(false); @@ -415,8 +424,9 @@ export function AppsScreen({ // Clear the message + log panels (and the reported height). Called when a run // ends or the selected app changes so a new run starts clean. `keepPartials` // is set for `handleOpen`, where the staged fragments are about to be consumed - // by the renderer and must not be cleared first. - function resetAppChannels(opts?: { keepPartials?: boolean }) { + // by the renderer and must not be cleared first. Memoized (stable setState + // calls only) so the deep-link auto-open effect's deps don't churn. + const resetAppChannels = useCallback((opts?: { keepPartials?: boolean }) => { setAppHeight(undefined); setMessages([]); setAppLogs([]); @@ -424,7 +434,7 @@ export function AppsScreen({ setAppStatus("idle"); setAppError(undefined); if (!opts?.keepPartials) setPartialStages([]); - } + }, []); // Capture the error locally (so it can be shown in the card and surfaced as // `data-app-error`) and forward it to the parent's onError. The renderer @@ -500,6 +510,36 @@ export function AppsScreen({ onCloseApp(); } + // Deep-link auto-open (#1577): the parent seeds `ui.selectedAppName` + + // `ui.formValues` from the URL and sets `autoOpen`; fire "Open App" here so + // the driver lands on a rendered widget with zero clicks. Ref-guarded to fire + // exactly once — a later manual close leaves `running` false without + // re-triggering. The open (running + channel resets + `onOpenApp`) is + // deferred one microtask past the synchronous effect body: it calls setState, + // which the set-state-in-effect lint rightly flags in general, but this is a + // ref-guarded run-once effect so the cascading-render concern doesn't apply + // (same pattern as App.tsx's deep-link connect effect). `resetAppChannels` + // changes identity each render, so the effect re-runs, but the ref guard + // makes every run after the first a no-op. + const autoOpenFiredRef = useRef(false); + useEffect(() => { + if (!autoOpen || autoOpenFiredRef.current) return; + if (!selectedTool || running) return; + autoOpenFiredRef.current = true; + void Promise.resolve().then(() => { + resetAppChannels({ keepPartials: true }); + setRunning(true); + onOpenApp(selectedTool.name, formValues); + }); + }, [ + autoOpen, + selectedTool, + running, + formValues, + onOpenApp, + resetAppChannels, + ]); + function handleBackToInput() { setRunning(false); setMaximized(false); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 367787ff0..2f494904b 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -689,6 +689,104 @@ describe("InspectorView", () => { }); }); + it("deep-link openApp auto-switches to the Apps tab and pre-selects the app once connected", async () => { + const onSelectApp = vi.fn(); + renderWithMantine( + , + ); + // No click: the effect flips the lifted tab to Apps and seeds the selection. + expect(await screen.findByText("MCP Apps (1)")).toBeInTheDocument(); + await waitFor(() => expect(onSelectApp).toHaveBeenCalledWith("ops")); + }); + + it("deep-link appArgs are merged over the schema defaults into the pre-filled form", async () => { + const fieldedAppTool: Tool = { + name: "cohorts", + title: "Cohort Data", + inputSchema: { + type: "object", + properties: { + metric: { type: "string", default: "retention" }, + zip: { type: "string" }, + }, + }, + _meta: { ui: { resourceUri: "ui://apps/cohorts" } }, + }; + renderWithMantine( + , + ); + // The form is pre-filled: `zip` from appArgs, `metric` from the schema default. + expect(await screen.findByDisplayValue("10001")).toBeInTheDocument(); + expect(screen.getByDisplayValue("retention")).toBeInTheDocument(); + }); + + it("ignores a deep-link openApp whose tool is not an app (no tab switch)", async () => { + renderWithMantine( + , + ); + // The target app never appears, so the effect never switches tabs: the + // Apps screen is never activated even though the Apps tab is available. + const radios = await screen.findAllByRole("radio"); + expect(radios.map((r) => r.getAttribute("value"))).toContain("Apps"); + expect(screen.queryByText("MCP Apps (1)")).not.toBeInTheDocument(); + }); + it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => { const user = userEvent.setup({ delay: null }); const { rerender } = renderWithMantine( diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 39fc74058..29d561196 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -89,6 +89,7 @@ import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle"; import { getServerType } from "@inspector/core/mcp/config.js"; import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs"; +import { collectSchemaDefaults } from "../../../utils/jsonUtils"; import { MONITOR_COLUMN_ANIM_MS } from "./monitorColumnAnimation"; const SORT_DEFAULT: SortDirection = "newest-first"; @@ -520,6 +521,7 @@ export interface InspectorViewProps { } export function InspectorView({ + deepLink, deepLinkStatus, servers: serversInput, serverListWritable = true, @@ -866,6 +868,48 @@ export function InspectorView({ ? firstNonServersTab : SERVERS_TAB; + // Deep-link auto-open (#1577): once connected and the requested app appears in + // the app-tools list, switch to the Apps tab and pre-select it with the + // supplied form values. The `autoOpen` flag is forwarded to AppsScreen (which + // owns the running/iframe state) to fire the actual "Open App" — see its + // `autoOpen` prop. Guarded by a ref so it fires exactly once even though the + // effect re-runs as `appTools`/`availableTabs` settle. + const deepLinkOpenAppRef = useRef(false); + useEffect(() => { + if (!deepLink?.openApp) return; + if (deepLinkOpenAppRef.current) return; + if (connectionStatus !== "connected") return; + if (!availableTabs.includes("Apps")) return; + const target = appTools.find((t) => t.name === deepLink.openApp); + if (!target) return; + deepLinkOpenAppRef.current = true; + // Seed the schema's defaults THEN overlay the deep-link's appArgs. Without + // the defaults, a required field the form would display with its default + // value is absent from `formValues`, the schema-form's validity check + // fails, and Open App is silently disabled — an automated driver's click + // then no-ops and the iframe-wait spins forever. + const formValues = { + ...collectSchemaDefaults(target.inputSchema), + ...deepLink.appArgs, + }; + onActiveTabChange("Apps"); + onAppsUiChange({ + ...appsUi, + selectedAppName: target.name, + formValues, + }); + onSelectApp(target.name); + }, [ + deepLink, + connectionStatus, + availableTabs, + appTools, + appsUi, + onActiveTabChange, + onAppsUiChange, + onSelectApp, + ]); + // The monitor tab shown in the column, clamped to what's available (e.g. a // switch to a stdio server drops Network). `?? LOGS_TAB` is a types-only // fallback: the column only renders while `monitorAvailable.length > 0`. @@ -1146,6 +1190,9 @@ export function InspectorView({ onOpenApp={onOpenApp} onCloseApp={onCloseApp} onError={onAppError} + autoOpen={ + Boolean(deepLink?.openApp) && (deepLink?.autoOpen ?? false) + } /> From b8cd4fd431223bab1715af697ab919b92732b62c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 11 Jul 2026 23:24:20 -0400 Subject: [PATCH 2/2] =?UTF-8?q?web:=20address=20#1671=20review=20nit=20?= =?UTF-8?q?=E2=80=94=20simplify=20redundant=20autoOpen=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepLink.autoOpen is a non-optional boolean, so the `?? false` was dead once openApp was truthy. Collapse to Boolean(deepLink?.openApp && deepLink.autoOpen) — still false when there's no deep link, still gated on openApp so a malformed autoOpen-without-openApp link can't fire. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- .../web/src/components/views/InspectorView/InspectorView.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 29d561196..cd18bdedc 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1190,9 +1190,7 @@ export function InspectorView({ onOpenApp={onOpenApp} onCloseApp={onCloseApp} onError={onAppError} - autoOpen={ - Boolean(deepLink?.openApp) && (deepLink?.autoOpen ?? false) - } + autoOpen={Boolean(deepLink?.openApp && deepLink.autoOpen)} />