web: deep-link openApp/appArgs/autoOpen — pre-select & pre-fill an app from the URL (#1577)
Closes #1577 Extends the deep link (#1576) to land on a rendered MCP App: - `&openApp=<toolName>` — once connected and the tool appears in the app list, InspectorView switches to the Apps tab and pre-selects it. - `&appArgs=<base64url(JSON)>` — pre-fills the tool form; values are merged **over** `collectSchemaDefaults()` so required-with-default fields don't disable "Open App". - `&autoOpen=<token>` — 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
parent
0e93b13426
commit
cbb2afe80c
5 changed files with 238 additions and 5 deletions
|
|
@ -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-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. |
|
| `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=<toolName>&appArgs=<base64url(JSON)>&autoOpen=<token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 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/`)
|
## Theme (`src/theme/`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,40 @@ describe("AppsScreen", () => {
|
||||||
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
|
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(
|
||||||
|
<ControlledAppsScreen
|
||||||
|
autoOpen
|
||||||
|
ui={{
|
||||||
|
selectedAppName: "weather",
|
||||||
|
formValues: { city: "Reykjavik" },
|
||||||
|
search: "",
|
||||||
|
}}
|
||||||
|
onOpenApp={onOpenApp}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(<ControlledAppsScreen autoOpen onOpenApp={onOpenApp} />);
|
||||||
|
// 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 () => {
|
it("invokes onOpenApp with form values when Open App is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onOpenApp = vi.fn();
|
const onOpenApp = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useState, type Ref } from "react";
|
import { useCallback, useEffect, useRef, useState, type Ref } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -64,6 +64,14 @@ export interface AppsScreenProps {
|
||||||
onCloseApp: () => void;
|
onCloseApp: () => void;
|
||||||
/** Surfaces bridge/runtime failures from the renderer (e.g. no client). */
|
/** Surfaces bridge/runtime failures from the renderer (e.g. no client). */
|
||||||
onError?: (err: Error) => void;
|
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
|
// Selected app, its form values, and the sidebar search — controlled by the
|
||||||
|
|
@ -333,6 +341,7 @@ export function AppsScreen({
|
||||||
onOpenApp,
|
onOpenApp,
|
||||||
onCloseApp,
|
onCloseApp,
|
||||||
onError,
|
onError,
|
||||||
|
autoOpen = false,
|
||||||
}: AppsScreenProps) {
|
}: AppsScreenProps) {
|
||||||
const { selectedAppName, formValues, search } = ui;
|
const { selectedAppName, formValues, search } = ui;
|
||||||
const [running, setRunning] = useState(false);
|
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
|
// 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`
|
// 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
|
// is set for `handleOpen`, where the staged fragments are about to be consumed
|
||||||
// by the renderer and must not be cleared first.
|
// by the renderer and must not be cleared first. Memoized (stable setState
|
||||||
function resetAppChannels(opts?: { keepPartials?: boolean }) {
|
// calls only) so the deep-link auto-open effect's deps don't churn.
|
||||||
|
const resetAppChannels = useCallback((opts?: { keepPartials?: boolean }) => {
|
||||||
setAppHeight(undefined);
|
setAppHeight(undefined);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setAppLogs([]);
|
setAppLogs([]);
|
||||||
|
|
@ -424,7 +434,7 @@ export function AppsScreen({
|
||||||
setAppStatus("idle");
|
setAppStatus("idle");
|
||||||
setAppError(undefined);
|
setAppError(undefined);
|
||||||
if (!opts?.keepPartials) setPartialStages([]);
|
if (!opts?.keepPartials) setPartialStages([]);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
// Capture the error locally (so it can be shown in the card and surfaced as
|
// 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
|
// `data-app-error`) and forward it to the parent's onError. The renderer
|
||||||
|
|
@ -500,6 +510,36 @@ export function AppsScreen({
|
||||||
onCloseApp();
|
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() {
|
function handleBackToInput() {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
setMaximized(false);
|
setMaximized(false);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
<StatefulInspectorViewHost
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
tools: [sampleAppTool],
|
||||||
|
onSelectApp,
|
||||||
|
deepLink: {
|
||||||
|
serverId: "deep-link",
|
||||||
|
serverConfig: {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://example.com/mcp",
|
||||||
|
},
|
||||||
|
openApp: "ops",
|
||||||
|
appArgs: {},
|
||||||
|
autoOpen: false,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<StatefulInspectorViewHost
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
tools: [fieldedAppTool],
|
||||||
|
deepLink: {
|
||||||
|
serverId: "deep-link",
|
||||||
|
serverConfig: {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://example.com/mcp",
|
||||||
|
},
|
||||||
|
openApp: "cohorts",
|
||||||
|
// Overrides no default (zip), leaving `metric`'s schema default intact.
|
||||||
|
appArgs: { zip: "10001" },
|
||||||
|
autoOpen: false,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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(
|
||||||
|
<StatefulInspectorViewHost
|
||||||
|
{...makeProps({
|
||||||
|
servers: [sampleServer],
|
||||||
|
activeServer: "alpha",
|
||||||
|
connectionStatus: "connected",
|
||||||
|
initializeResult: connectedInit,
|
||||||
|
tools: [sampleAppTool],
|
||||||
|
deepLink: {
|
||||||
|
serverId: "deep-link",
|
||||||
|
serverConfig: {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://example.com/mcp",
|
||||||
|
},
|
||||||
|
openApp: "does-not-exist",
|
||||||
|
appArgs: {},
|
||||||
|
autoOpen: false,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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 () => {
|
it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => {
|
||||||
const user = userEvent.setup({ delay: null });
|
const user = userEvent.setup({ delay: null });
|
||||||
const { rerender } = renderWithMantine(
|
const { rerender } = renderWithMantine(
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen
|
||||||
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
|
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
|
||||||
import { getServerType } from "@inspector/core/mcp/config.js";
|
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||||
import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs";
|
import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs";
|
||||||
|
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
|
||||||
import { MONITOR_COLUMN_ANIM_MS } from "./monitorColumnAnimation";
|
import { MONITOR_COLUMN_ANIM_MS } from "./monitorColumnAnimation";
|
||||||
|
|
||||||
const SORT_DEFAULT: SortDirection = "newest-first";
|
const SORT_DEFAULT: SortDirection = "newest-first";
|
||||||
|
|
@ -520,6 +521,7 @@ export interface InspectorViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectorView({
|
export function InspectorView({
|
||||||
|
deepLink,
|
||||||
deepLinkStatus,
|
deepLinkStatus,
|
||||||
servers: serversInput,
|
servers: serversInput,
|
||||||
serverListWritable = true,
|
serverListWritable = true,
|
||||||
|
|
@ -866,6 +868,48 @@ export function InspectorView({
|
||||||
? firstNonServersTab
|
? firstNonServersTab
|
||||||
: SERVERS_TAB;
|
: 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
|
// 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
|
// switch to a stdio server drops Network). `?? LOGS_TAB` is a types-only
|
||||||
// fallback: the column only renders while `monitorAvailable.length > 0`.
|
// fallback: the column only renders while `monitorAvailable.length > 0`.
|
||||||
|
|
@ -1146,6 +1190,9 @@ export function InspectorView({
|
||||||
onOpenApp={onOpenApp}
|
onOpenApp={onOpenApp}
|
||||||
onCloseApp={onCloseApp}
|
onCloseApp={onCloseApp}
|
||||||
onError={onAppError}
|
onError={onAppError}
|
||||||
|
autoOpen={
|
||||||
|
Boolean(deepLink?.openApp) && (deepLink?.autoOpen ?? false)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Prompts"}>
|
<ScreenStage active={activeTab === "Prompts"}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue