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
2 changed files with 238 additions and 5 deletions
|
|
@ -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 () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const { rerender } = renderWithMantine(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Prompts"}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue