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..cd18bdedc 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,7 @@ export function InspectorView({ onOpenApp={onOpenApp} onCloseApp={onCloseApp} onError={onAppError} + autoOpen={Boolean(deepLink?.openApp && deepLink.autoOpen)} />